← back to NationalPaperHangers
snapshot before claude-codex debate
dfd87aecde58ed7737860cb10151f2eb0c39bafe · 2026-05-06 10:18:07 -0700 · Steve
Files touched
A .env.exampleA .gitignoreA ARCHITECTURE_PHASE2.mdA DATA_POLICY.mdA DEPLOY_KAMATERA.mdA EMAIL_PROVIDER_DECISION.mdA GO_LIVE.mdA HANDOFF.mdA PROCESS.mdA README.mdA UX_CREATIVE_BACKLOG.mdA data/geo-cache.jsonA db/migrations/001_claim_columns.sqlA db/migrations/002_ad_signals.sqlA db/migrations/003_perf.sqlA db/migrations/004_comms_suppression.sqlA db/migrations/005_users_roles.sqlA db/migrations/006_equipment_and_metrics.sqlA db/migrations/007_portfolio_detail_shots.sqlA db/migrations/008_structured_booking_brief.sqlA db/migrations/009_installer_geo.sqlA db/migrations/010_installer_connect.sqlA db/migrations/011_installer_credentials.sqlA db/migrations/012_installer_templates.sqlA db/migrations/013_consumer_accounts_and_brief.sqlA db/schema.sqlA db/seed.sqlA dns/apply-cloudflare-zone.shA dns/cloudflare-zone.yamlA ecosystem.config.jsA ecosystem.kamatera.config.jsA lib/auth.jsA lib/auth/policies.jsA lib/booking-token.jsA lib/compliance.jsA lib/csrf.jsA lib/db.jsA lib/email.jsA lib/segment-image.jsA lib/services/bookings.jsA lib/services/installers.jsA lib/services/marketplace.jsA lib/services/subscriptions.jsA lib/services/users.jsA lib/slots.jsA lib/stripe.jsA lib/utils.jsA outreach/COMMUNITY_CHANNELS.mdA outreach/IG_DISCOVERED_NEW.mdA outreach/IG_DM_PLAYBOOK.mdA outreach/IG_FEATURE_PIPELINE.mdA outreach/WIA_PARTNERSHIP.mdA package-lock.jsonA package.jsonA public/css/admin.cssA public/css/public.cssA public/css/templates.cssA public/css/theme.cssA public/favicon.svgA public/img/segments/generic/69-drawing-of-an-interior-cabinet-du-salon-met-dp809355.jpgA public/img/segments/generic/70-drawing-of-an-interior-cabinet-du-salon-met-1972-642-9.jpgA public/img/segments/generic/71-drawing-of-an-interior-salon-met-1972-642-8.jpgA public/img/segments/generic/72-drawing-of-an-interior-salon-met-dp809413.jpgA public/img/segments/generic/76-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpgA public/img/segments/generic/77-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpgA public/img/segments/generic/78-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpgA public/img/segments/generic/79-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpgA public/img/segments/generic/80-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpgA public/img/segments/generic/81-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpgA public/img/segments/generic/82-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpgA public/img/segments/generic/83-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpgA public/img/segments/generic/84-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpgA public/img/segments/generic/85-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpgA public/img/segments/grasscloth/43-grasscloth-usa-1890-ch-18386939.jpgA public/img/segments/grasscloth/44-grasscloth-usa-1890-ch-18386939-2.jpgA public/img/segments/grasscloth/45-sidewall-usa-1900-1920-ch-18476209.jpgA public/img/segments/grasscloth/46-sidewall-usa-1900-1920-ch-18476209-2.jpgA public/img/segments/grasscloth/47-sidewall-france-1880-98-ch-18798297.jpgA public/img/segments/grasscloth/48-borders-usa-1900-1920-ch-18471695.jpgA public/img/segments/grasscloth/49-sidewall-fragment-usa-1890-1910-ch-18475367.jpgA public/img/segments/grasscloth/50-sidewall-fragment-usa-1890-1910-ch-18475367-2.jpgA public/img/segments/grasscloth/51-sample-book-usa-ca-1915-ch-18491413-11.jpgA public/img/segments/grasscloth/52-sample-book-usa-ca-1915-ch-18491413-20.jpgA public/img/segments/grasscloth/53-sample-book-usa-ca-1915-ch-18491413-40.jpgA public/img/segments/grasscloth/54-behangstalenboek-r-d-grasscloth-serie-3-objectnr-ka-17948-14.jpgA public/img/segments/grasscloth/55-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpgA public/img/segments/grasscloth/56-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpgA public/img/segments/hand_painted/29-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpgA public/img/segments/hand_painted/30-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpgA public/img/segments/hand_painted/31-wallpaper-panel-met-25607.jpgA public/img/segments/hand_painted/32-wallpaper-panel-met-25906.jpgA public/img/segments/hand_painted/33-wallpaper-panel-met-25609.jpgA public/img/segments/hand_painted/34-wallpaper-panel-met-25610.jpgA public/img/segments/hand_painted/35-wallpaper-panel-met-25900.jpgA public/img/segments/hand_painted/36-wallpaper-panel-met-25903.jpgA public/img/segments/hand_painted/37-wallpaper-panel-met-25611.jpgA public/img/segments/hand_painted/38-wallpaper-panel-met-25612.jpgA public/img/segments/hand_painted/39-behang-chinoiserie.jpgA public/img/segments/hand_painted/40-rex-whistler-wallpaper-in-the-chinoiserie-style-with-a-pictu.jpgA public/img/segments/hand_painted/41-sidewall-france-1890-1900-ch-18606171.jpgA public/img/segments/hand_painted/42-sidewall-france-1890-1900-ch-18606171-2.jpgA public/img/segments/hospitality/13-kenmore-hotel-1100-washington-avenue-lobby-stairway-detail-m.jpgA public/img/segments/hospitality/14-kenmore-hotel-1100-washington-avenue-lobby-stairway35-kenmor.jpgA public/img/segments/hospitality/15-art-deco-geometric-carpets-designed-by-marion-dorn-for-the-l.jpgA public/img/segments/luxury_residential/01-statelibqld-1-254167-drawing-room-in-the-bank-of-new-south-w.jpgA public/img/segments/luxury_residential/02-view-of-mckenzie-richey-house-interior-showing-living-room-a.jpgA public/img/segments/luxury_residential/03-first-floor-veterans-tiffany-room-detail-of-table-with-wallp.jpgA public/img/segments/luxury_residential/04-interior-second-floor-room-6-detail-of-fireplace-john-g-wils.jpgA public/img/segments/luxury_residential/05-drawing-an-interior-1837-40-ch-18708251.jpgA public/img/segments/luxury_residential/06-whittemore-house-parlor.jpgA public/img/segments/luxury_residential/07-victorian-parlor.jpgA public/img/segments/luxury_residential/08-interior-detail-of-fireplace-in-parlor-savannah-victorian-hi.jpgA public/img/segments/luxury_residential/09-interior-detail-of-cornice-around-chimneypiece-in-rear-parlo.jpgA public/img/segments/luxury_residential/10-interior-detail-of-ceiling-medallion-in-rear-parlor-savannah.jpgA public/img/segments/luxury_residential/11-interior-detail-of-ceiling-paper-in-rear-parlor-savannah-vic.jpgA public/img/segments/luxury_residential/12-interior-detail-of-doorway-between-double-parlors-savannah-v.jpgA public/img/segments/luxury_residential/73-414-east-waldburg-street-interior-detail-of-arch-in-middle-p.jpgA public/img/segments/luxury_residential/74-interior-detail-wallpaper-stairhall-first-floor-bowen-house-.jpgA public/img/segments/manifest.jsonA public/img/segments/mural/57-1851-townshend-and-parker-wallpaper-great-exhibition-london.jpgA public/img/segments/mural/58-wallpaper-2.jpgA public/img/segments/mural/59-wallpaper-3.jpgA public/img/segments/mural/60-wallpaper-ca-1791-made-by-jacquemart-benard.jpgA public/img/segments/mural/61-moolenbergh-thomas-theodory.jpgA public/img/segments/mural/62-garden-of-armida-wallpaper-1854-by-douard-muller.jpgA public/img/segments/mural/63-most-beautiful-landscape-wallpaper.jpgA public/img/segments/mural/64-panorama-scenics-photography.jpgA public/img/segments/mural/65-efta00000925-sleek-apple-imac-displays-the-macos-desktop-fea.jpgA public/img/segments/mural/66-interior-wall-decoration-museo-diocesano-genoa-dsc01769.jpgA public/img/segments/mural/67-interior-wall-decoration-museo-diocesano-genoa-dsc01747.jpgA public/img/segments/mural/68-interior-wall-decoration-museo-diocesano-genoa-dsc01764.jpgA public/img/segments/mural/75-church-of-the-eremitani-padua-interior-fresco-of-saint-phili.jpgA public/img/segments/museum/16-household-furniture-and-interior-decoration-met-dp211452.jpgA public/img/segments/museum/17-household-furniture-and-interior-decoration-met-49pp-511r2.jpgA public/img/segments/museum/18-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpgA public/img/segments/museum/19-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpgA public/img/segments/museum/20-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpgA public/img/segments/museum/21-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpgA public/img/segments/museum/22-bowl-with-interior-geometric-decoration-met-cop0206s1.jpgA public/img/segments/museum/23-bowl-with-interior-geometric-decoration-met-dp276089.jpgA public/img/segments/museum/24-bowl-with-interior-geometric-decoration-met-dp276088.jpgA public/img/segments/museum/25-national-museum-of-serbia-staircase-decoration-p1.jpgA public/img/segments/museum/26-national-museum-of-serbia-staircase-decoration-p2.jpgA public/img/segments/museum/27-dining-room-longfellow-national-historic-site-dsc04698.jpgA public/img/segments/museum/28-08152-grand-canyon-historic-interior-of-bright-angel-lodge-d.jpgA public/js/calendar-consumer.jsA public/js/calendar-installer.jsA public/js/find-filter.jsA public/js/portfolio-tabs.jsA public/js/tag-input.jsA public/js/theme-toggle.jsA public/robots.txtA public/vendor/leaflet/images/layers-2x.pngA public/vendor/leaflet/images/layers.pngA public/vendor/leaflet/images/marker-icon-2x.pngA public/vendor/leaflet/images/marker-icon.pngA public/vendor/leaflet/images/marker-shadow.pngA public/vendor/leaflet/leaflet.cssA public/vendor/leaflet/leaflet.jsA public/vendor/markercluster/MarkerCluster.Default.cssA public/vendor/markercluster/MarkerCluster.cssA public/vendor/markercluster/leaflet.markercluster.jsA routes/admin.jsA routes/api.jsA routes/auth-google.jsA routes/auth-linkedin.jsA routes/auth.jsA routes/claim.jsA routes/public.jsA routes/unsubscribe.jsA routes/webhooks.jsA scripts/deploy-kamatera-https.shA scripts/deploy-kamatera-v2.shA scripts/deploy-kamatera-v3-data.shA scripts/deploy-kamatera.shA scripts/enrich-instagram.jsA scripts/fetch-pd-images.jsA scripts/gen-secrets.jsA scripts/generate-ig-dm-drafts.jsA scripts/geocode-installers.jsA scripts/go-live-check.jsA scripts/lead-list.jsA scripts/scan-ad-signals.jsA scripts/scrape-wia-browserbase.jsA scripts/scrape-wia.jsA scripts/send-claim-invitations.jsA scripts/wia-profile-urls.txtA server.jsA tests/app.jsA tests/compliance.test.jsA tests/smoke.test.jsA views/admin/billing.ejsA views/admin/booking-detail.ejsA views/admin/bookings.ejsA views/admin/calendar.ejsA views/admin/dashboard.ejsA views/admin/ops-credentials.ejsA views/admin/partials/admin-header.ejsA views/admin/profile.ejsA views/admin/template.ejsA views/auth/login.ejsA views/auth/signup.ejsA views/partials/footer.ejsA views/partials/head.ejsA views/partials/header.ejsA views/public/404.ejsA views/public/about.ejsA views/public/book.ejsA views/public/booking.ejsA views/public/claim-complete.ejsA views/public/claim.ejsA views/public/error.ejsA views/public/find.ejsA views/public/for-installers.ejsA views/public/home.ejsA views/public/installer-tpl-bilingue.ejsA views/public/installer-tpl-concierge.ejsA views/public/installer-tpl-editorial.ejsA views/public/installer-tpl-heritage.ejsA views/public/installer-tpl-studio.ejsA views/public/installer-tpl-trade-pro.ejsA views/public/installer.ejsA views/public/legal.ejsA views/public/map.ejsA views/public/unsubscribed.ejs
Diff
commit dfd87aecde58ed7737860cb10151f2eb0c39bafe
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 10:18:07 2026 -0700
snapshot before claude-codex debate
---
.env.example | 50 +
.gitignore | 10 +
ARCHITECTURE_PHASE2.md | 103 +
DATA_POLICY.md | 115 +
DEPLOY_KAMATERA.md | 777 ++++++
EMAIL_PROVIDER_DECISION.md | 191 ++
GO_LIVE.md | 280 ++
HANDOFF.md | 139 +
PROCESS.md | 121 +
README.md | 67 +
UX_CREATIVE_BACKLOG.md | 140 +
data/geo-cache.json | 1424 ++++++++++
db/migrations/001_claim_columns.sql | 51 +
db/migrations/002_ad_signals.sql | 15 +
db/migrations/003_perf.sql | 74 +
db/migrations/004_comms_suppression.sql | 64 +
db/migrations/005_users_roles.sql | 144 ++
db/migrations/006_equipment_and_metrics.sql | 37 +
db/migrations/007_portfolio_detail_shots.sql | 16 +
db/migrations/008_structured_booking_brief.sql | 33 +
db/migrations/009_installer_geo.sql | 14 +
db/migrations/010_installer_connect.sql | 36 +
db/migrations/011_installer_credentials.sql | 63 +
db/migrations/012_installer_templates.sql | 14 +
db/migrations/013_consumer_accounts_and_brief.sql | 38 +
db/schema.sql | 298 +++
db/seed.sql | 126 +
dns/apply-cloudflare-zone.sh | 381 +++
dns/cloudflare-zone.yaml | 139 +
ecosystem.config.js | 15 +
ecosystem.kamatera.config.js | 56 +
lib/auth.js | 79 +
lib/auth/policies.js | 89 +
lib/booking-token.js | 33 +
lib/compliance.js | 176 ++
lib/csrf.js | 56 +
lib/db.js | 38 +
lib/email.js | 101 +
lib/segment-image.js | 89 +
lib/services/bookings.js | 206 ++
lib/services/installers.js | 202 ++
lib/services/marketplace.js | 103 +
lib/services/subscriptions.js | 129 +
lib/services/users.js | 170 ++
lib/slots.js | 154 ++
lib/stripe.js | 270 ++
lib/utils.js | 29 +
outreach/COMMUNITY_CHANNELS.md | 119 +
outreach/IG_DISCOVERED_NEW.md | 109 +
outreach/IG_DM_PLAYBOOK.md | 91 +
outreach/IG_FEATURE_PIPELINE.md | 173 ++
outreach/WIA_PARTNERSHIP.md | 73 +
package-lock.json | 2726 ++++++++++++++++++++
package.json | 42 +
public/css/admin.css | 103 +
public/css/public.css | 188 ++
public/css/templates.css | 273 ++
public/css/theme.css | 132 +
public/favicon.svg | 4 +
...f-an-interior-cabinet-du-salon-met-dp809355.jpg | Bin 0 -> 372903 bytes
...an-interior-cabinet-du-salon-met-1972-642-9.jpg | Bin 0 -> 243248 bytes
...drawing-of-an-interior-salon-met-1972-642-8.jpg | Bin 0 -> 756140 bytes
...2-drawing-of-an-interior-salon-met-dp809413.jpg | Bin 0 -> 300936 bytes
...allpaper-sample-book-1-william-morris-and-c.jpg | Bin 0 -> 48020 bytes
...allpaper-sample-book-1-william-morris-and-c.jpg | Bin 0 -> 79628 bytes
...allpaper-sample-book-1-william-morris-and-c.jpg | Bin 0 -> 103234 bytes
...allpaper-sample-book-1-william-morris-and-c.jpg | Bin 0 -> 147025 bytes
...allpaper-sample-book-1-william-morris-and-c.jpg | Bin 0 -> 113048 bytes
...allpaper-sample-book-2-william-morris-and-c.jpg | Bin 0 -> 87085 bytes
...allpaper-sample-book-2-william-morris-and-c.jpg | Bin 0 -> 122233 bytes
...allpaper-sample-book-2-william-morris-and-c.jpg | Bin 0 -> 83390 bytes
...allpaper-sample-book-2-william-morris-and-c.jpg | Bin 0 -> 187968 bytes
...allpaper-sample-book-2-william-morris-and-c.jpg | Bin 0 -> 87695 bytes
.../43-grasscloth-usa-1890-ch-18386939.jpg | Bin 0 -> 327792 bytes
.../44-grasscloth-usa-1890-ch-18386939-2.jpg | Bin 0 -> 304418 bytes
.../45-sidewall-usa-1900-1920-ch-18476209.jpg | Bin 0 -> 1091965 bytes
.../46-sidewall-usa-1900-1920-ch-18476209-2.jpg | Bin 0 -> 1031695 bytes
.../47-sidewall-france-1880-98-ch-18798297.jpg | Bin 0 -> 320588 bytes
.../48-borders-usa-1900-1920-ch-18471695.jpg | Bin 0 -> 422129 bytes
...sidewall-fragment-usa-1890-1910-ch-18475367.jpg | Bin 0 -> 3061092 bytes
...dewall-fragment-usa-1890-1910-ch-18475367-2.jpg | Bin 0 -> 1545789 bytes
.../51-sample-book-usa-ca-1915-ch-18491413-11.jpg | Bin 0 -> 428398 bytes
.../52-sample-book-usa-ca-1915-ch-18491413-20.jpg | Bin 0 -> 489243 bytes
.../53-sample-book-usa-ca-1915-ch-18491413-40.jpg | Bin 0 -> 757132 bytes
...r-d-grasscloth-serie-3-objectnr-ka-17948-14.jpg | Bin 0 -> 109026 bytes
...r-lobby-possibly-the-ante-room-to-the-music.jpg | Bin 0 -> 635612 bytes
...r-lobby-possibly-the-ante-room-to-the-music.jpg | Bin 0 -> 468014 bytes
...paper-at-morgan-s-mount-vernon-farm-of-sout.jpg | Bin 0 -> 356736 bytes
...paper-at-morgan-s-mount-vernon-farm-of-sout.jpg | Bin 0 -> 181661 bytes
.../hand_painted/31-wallpaper-panel-met-25607.jpg | Bin 0 -> 152156 bytes
.../hand_painted/32-wallpaper-panel-met-25906.jpg | Bin 0 -> 207896 bytes
.../hand_painted/33-wallpaper-panel-met-25609.jpg | Bin 0 -> 258009 bytes
.../hand_painted/34-wallpaper-panel-met-25610.jpg | Bin 0 -> 328810 bytes
.../hand_painted/35-wallpaper-panel-met-25900.jpg | Bin 0 -> 483579 bytes
.../hand_painted/36-wallpaper-panel-met-25903.jpg | Bin 0 -> 244159 bytes
.../hand_painted/37-wallpaper-panel-met-25611.jpg | Bin 0 -> 603319 bytes
.../hand_painted/38-wallpaper-panel-met-25612.jpg | Bin 0 -> 513324 bytes
.../hand_painted/39-behang-chinoiserie.jpg | Bin 0 -> 488429 bytes
...paper-in-the-chinoiserie-style-with-a-pictu.jpg | Bin 0 -> 2249751 bytes
.../41-sidewall-france-1890-1900-ch-18606171.jpg | Bin 0 -> 950588 bytes
.../42-sidewall-france-1890-1900-ch-18606171-2.jpg | Bin 0 -> 1166668 bytes
...0-washington-avenue-lobby-stairway-detail-m.jpg | Bin 0 -> 279018 bytes
...0-washington-avenue-lobby-stairway35-kenmor.jpg | Bin 0 -> 166406 bytes
...c-carpets-designed-by-marion-dorn-for-the-l.jpg | Bin 0 -> 316084 bytes
...167-drawing-room-in-the-bank-of-new-south-w.jpg | Bin 0 -> 110462 bytes
...richey-house-interior-showing-living-room-a.jpg | Bin 0 -> 226833 bytes
...ans-tiffany-room-detail-of-table-with-wallp.jpg | Bin 0 -> 594943 bytes
...loor-room-6-detail-of-fireplace-john-g-wils.jpg | Bin 0 -> 253880 bytes
.../05-drawing-an-interior-1837-40-ch-18708251.jpg | Bin 0 -> 370155 bytes
.../06-whittemore-house-parlor.jpg | Bin 0 -> 222460 bytes
.../luxury_residential/07-victorian-parlor.jpg | Bin 0 -> 242166 bytes
...f-fireplace-in-parlor-savannah-victorian-hi.jpg | Bin 0 -> 220594 bytes
...f-cornice-around-chimneypiece-in-rear-parlo.jpg | Bin 0 -> 277749 bytes
...f-ceiling-medallion-in-rear-parlor-savannah.jpg | Bin 0 -> 472384 bytes
...f-ceiling-paper-in-rear-parlor-savannah-vic.jpg | Bin 0 -> 380075 bytes
...f-doorway-between-double-parlors-savannah-v.jpg | Bin 0 -> 136419 bytes
...-street-interior-detail-of-arch-in-middle-p.jpg | Bin 0 -> 96115 bytes
...allpaper-stairhall-first-floor-bowen-house-.jpg | Bin 0 -> 951153 bytes
public/img/segments/manifest.json | 1110 ++++++++
...nd-parker-wallpaper-great-exhibition-london.jpg | Bin 0 -> 506620 bytes
public/img/segments/mural/58-wallpaper-2.jpg | Bin 0 -> 310830 bytes
public/img/segments/mural/59-wallpaper-3.jpg | Bin 0 -> 306283 bytes
...wallpaper-ca-1791-made-by-jacquemart-benard.jpg | Bin 0 -> 962061 bytes
.../mural/61-moolenbergh-thomas-theodory.jpg | Bin 0 -> 113528 bytes
...n-of-armida-wallpaper-1854-by-douard-muller.jpg | Bin 0 -> 547871 bytes
.../63-most-beautiful-landscape-wallpaper.jpg | Bin 0 -> 391247 bytes
.../mural/64-panorama-scenics-photography.jpg | Bin 0 -> 174770 bytes
...k-apple-imac-displays-the-macos-desktop-fea.jpg | Bin 0 -> 268964 bytes
...l-decoration-museo-diocesano-genoa-dsc01769.jpg | Bin 0 -> 351136 bytes
...l-decoration-museo-diocesano-genoa-dsc01747.jpg | Bin 0 -> 271341 bytes
...l-decoration-museo-diocesano-genoa-dsc01764.jpg | Bin 0 -> 349670 bytes
...mitani-padua-interior-fresco-of-saint-phili.jpg | Bin 0 -> 1099447 bytes
...niture-and-interior-decoration-met-dp211452.jpg | Bin 0 -> 345327 bytes
...ture-and-interior-decoration-met-49pp-511r2.jpg | Bin 0 -> 448291 bytes
...ndelabra-and-interior-decoration-met-mm8990.jpg | Bin 0 -> 140840 bytes
...ndelabra-and-interior-decoration-met-mm8990.jpg | Bin 0 -> 153809 bytes
...ndelabra-and-interior-decoration-met-mm8990.jpg | Bin 0 -> 253127 bytes
...ndelabra-and-interior-decoration-met-mm8990.jpg | Bin 0 -> 113457 bytes
...interior-geometric-decoration-met-cop0206s1.jpg | Bin 0 -> 247301 bytes
...-interior-geometric-decoration-met-dp276089.jpg | Bin 0 -> 180002 bytes
...-interior-geometric-decoration-met-dp276088.jpg | Bin 0 -> 157683 bytes
...al-museum-of-serbia-staircase-decoration-p1.jpg | Bin 0 -> 299400 bytes
...al-museum-of-serbia-staircase-decoration-p2.jpg | Bin 0 -> 295211 bytes
...-longfellow-national-historic-site-dsc04698.jpg | Bin 0 -> 282363 bytes
...n-historic-interior-of-bright-angel-lodge-d.jpg | Bin 0 -> 91142 bytes
public/js/calendar-consumer.js | 211 ++
public/js/calendar-installer.js | 109 +
public/js/find-filter.js | 113 +
public/js/portfolio-tabs.js | 15 +
public/js/tag-input.js | 199 ++
public/js/theme-toggle.js | 10 +
public/robots.txt | 9 +
public/vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes
public/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes
public/vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes
public/vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes
public/vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes
public/vendor/leaflet/leaflet.css | 661 +++++
public/vendor/leaflet/leaflet.js | 6 +
.../vendor/markercluster/MarkerCluster.Default.css | 60 +
public/vendor/markercluster/MarkerCluster.css | 14 +
.../vendor/markercluster/leaflet.markercluster.js | 2 +
routes/admin.js | 571 ++++
routes/api.js | 312 +++
routes/auth-google.js | 181 ++
routes/auth-linkedin.js | 174 ++
routes/auth.js | 115 +
routes/claim.js | 198 ++
routes/public.js | 351 +++
routes/unsubscribe.js | 70 +
routes/webhooks.js | 209 ++
scripts/deploy-kamatera-https.sh | 109 +
scripts/deploy-kamatera-v2.sh | 145 ++
scripts/deploy-kamatera-v3-data.sh | 87 +
scripts/deploy-kamatera.sh | 213 ++
scripts/enrich-instagram.js | 150 ++
scripts/fetch-pd-images.js | 215 ++
scripts/gen-secrets.js | 34 +
scripts/generate-ig-dm-drafts.js | 203 ++
scripts/geocode-installers.js | 104 +
scripts/go-live-check.js | 95 +
scripts/lead-list.js | 83 +
scripts/scan-ad-signals.js | 177 ++
scripts/scrape-wia-browserbase.js | 420 +++
scripts/scrape-wia.js | 277 ++
scripts/send-claim-invitations.js | 215 ++
scripts/wia-profile-urls.txt | 17 +
server.js | 199 ++
tests/app.js | 65 +
tests/compliance.test.js | 141 +
tests/smoke.test.js | 308 +++
views/admin/billing.ejs | 134 +
views/admin/booking-detail.ejs | 100 +
views/admin/bookings.ejs | 70 +
views/admin/calendar.ejs | 53 +
views/admin/dashboard.ejs | 177 ++
views/admin/ops-credentials.ejs | 82 +
views/admin/partials/admin-header.ejs | 25 +
views/admin/profile.ejs | 181 ++
views/admin/template.ejs | 220 ++
views/auth/login.ejs | 17 +
views/auth/signup.ejs | 30 +
views/partials/footer.ejs | 37 +
views/partials/head.ejs | 53 +
views/partials/header.ejs | 26 +
views/public/404.ejs | 8 +
views/public/about.ejs | 22 +
views/public/book.ejs | 325 +++
views/public/booking.ejs | 46 +
views/public/claim-complete.ejs | 30 +
views/public/claim.ejs | 81 +
views/public/error.ejs | 9 +
views/public/find.ejs | 97 +
views/public/for-installers.ejs | 102 +
views/public/home.ejs | 117 +
views/public/installer-tpl-bilingue.ejs | 107 +
views/public/installer-tpl-concierge.ejs | 62 +
views/public/installer-tpl-editorial.ejs | 84 +
views/public/installer-tpl-heritage.ejs | 90 +
views/public/installer-tpl-studio.ejs | 90 +
views/public/installer-tpl-trade-pro.ejs | 94 +
views/public/installer.ejs | 276 ++
views/public/legal.ejs | 18 +
views/public/map.ejs | 214 ++
views/public/unsubscribed.ejs | 20 +
225 files changed, 22042 insertions(+)
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3a5a7aa
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,50 @@
+PORT=9765
+NODE_ENV=development
+SESSION_SECRET=change-me-in-prod-32-bytes-min
+
+# Postgres
+# Local dev on macOS: use Unix socket (/tmp) so peer auth works without password.
+# For TCP/remote, set PGHOST=hostname and PGPASSWORD=…
+PGHOST=/tmp
+PGPORT=5432
+PGDATABASE=national_paper_hangers
+PGUSER=stevestudio2
+PGPASSWORD=
+
+# Stripe (will be filled in via secrets-manager)
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
+STRIPE_PRICE_PRO_MONTH=
+STRIPE_PRICE_PRO_YEAR=
+STRIPE_PRICE_SIGNATURE_MONTH=
+STRIPE_PRICE_SIGNATURE_YEAR=
+STRIPE_PRICE_ENTERPRISE_MONTH=
+
+# George Gmail agent (info@nationalpaperhangers.com)
+GEORGE_URL=http://localhost:9850
+GEORGE_ACCOUNT=info
+GEORGE_USER=admin
+GEORGE_PASS=
+EMAIL_FROM=info@nationalpaperhangers.com
+EMAIL_FROM_NAME=National Paper Hangers
+
+# Public origin (for emails / Stripe redirects)
+PUBLIC_URL=http://localhost:9765
+
+# HMAC key for booking-view tokens. Falls back to SESSION_SECRET if unset;
+# in production set explicitly so rotating SESSION_SECRET doesn't invalidate
+# every outstanding booking link in customer inboxes.
+BOOKING_SIGNING_SECRET=
+
+# HMAC key for unsubscribe tokens. Falls back to SESSION_SECRET if unset.
+UNSUBSCRIBE_SIGNING_SECRET=
+
+# CAN-SPAM §7(a)(5) — physical postal address that appears in every commercial
+# email footer. MUST be a real, deliverable address (not a P.O. Box unless USPS-registered).
+# Format: "Studio Name, 123 Real Street, City, ST 12345"
+# REQUIRED before any outbound campaign — assertSendCompliance() throws if missing.
+MAILING_ADDRESS=
+
+# Stripe webhook escape hatch — only honored in dev. Default fail-closed.
+# Set to 1 ONLY in dev to accept unsigned webhook calls for local Stripe CLI testing.
+STRIPE_DEV_ACCEPT_UNSIGNED=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d816869
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.local
+*.log
+.DS_Store
+.vscode/
+.idea/
+dist/
+tmp/
+uploads/
diff --git a/ARCHITECTURE_PHASE2.md b/ARCHITECTURE_PHASE2.md
new file mode 100644
index 0000000..0e79714
--- /dev/null
+++ b/ARCHITECTURE_PHASE2.md
@@ -0,0 +1,103 @@
+# Architecture Phase 2 — Cutover Plan
+
+This document is the rip-and-replace plan that follows Phase 1's additive
+scaffolding (migration `005_users_roles.sql` + `lib/services/*` +
+`lib/auth/policies.js`). Phase 1 changed nothing in the runtime path. Phase 2
+moves identity, RBAC, and the booking/subscription side-effects onto the new
+files, then drops the redundant columns on `installers`.
+
+## Goals
+
+- `req.session` stores `userId`, not `installerId`.
+- `installers.email` and `installers.password_hash` are dropped — identity
+ lives only in `users`.
+- Ops staff (verification queue, COI/W-9/license review) authenticate without
+ a phantom row in `installers`.
+- Routes delegate to `lib/services/*`. `routes/*.js` becomes thin glue.
+
+## Cutover order (smallest blast radius first)
+
+1. **Add `attachUser` middleware** in `server.js`, alongside the existing
+ `attachInstaller`. It loads `req.user` from `req.session.userId` and
+ reads roles via `lib/services/users`. Both middleware run during the
+ dual-write window so existing routes keep working.
+
+2. **Switch `routes/auth.js`** (`/login`, `/signup`, `/logout`):
+ - `/login` → `users.authenticate()`, sets `req.session.userId`. Keep
+ setting `req.session.installerId` for one release as a belt-and-braces.
+ - `/signup` → `users.createUser()` + `installers.create({ ownerUserId })`
+ + `users.grantRole(uid, 'installer_owner')` + `installers.addMember(iid, uid, 'owner')`.
+ Stops writing `installers.email` and `installers.password_hash`.
+ - `/logout` unchanged.
+
+3. **Switch `routes/admin.js`** to use `lib/auth/policies.requireInstallerMember`
+ in place of `requireInstaller`. Resolve the active installer via
+ `users.userInstallers(req.session.userId)` and an `?as=<slug>` param when
+ the user owns multiple. Profile updates delegate to `installers.updateProfile`.
+
+4. **Switch `routes/api.js`** booking endpoints to call
+ `bookings.createBooking()`, `bookings.confirmBooking()`, etc. Email send
+ stays in the route (fire-and-forget after the service returns).
+
+5. **Switch `routes/webhooks.js`** to call
+ `subscriptions.applyCheckoutSession`, `applySubscriptionChanged`,
+ `applySubscriptionDeleted`. The Stripe-signature verify + audit-log
+ idempotency stays in the route (it owns the transaction boundary).
+
+6. **Build the verification ops UI** at `/ops/*` gated by `requireOps`. This
+ is the whole reason for the split — RBAC for non-installer staff.
+
+7. **Drop legacy columns** in a final migration `006_drop_installers_identity.sql`:
+ ```sql
+ ALTER TABLE installers
+ DROP COLUMN email,
+ DROP COLUMN password_hash,
+ DROP COLUMN last_login_at;
+ ```
+ Only run this once steps 1–6 ship and a full smoke + 1 week of prod
+ traffic confirm `users` is the source of truth.
+
+## Keeping smoke tests green throughout
+
+- `tests/smoke.test.js` currently asserts public + claim flows; none touch
+ login / billing. They should stay 10/10 after every step above.
+- Add an integration test for `/login` against a fixture user in `users`
+ before step 2 ships, and one for `/ops/verify` queue before step 6.
+- `seed.sql` needs a parallel `users` + `installer_members` block — write it
+ alongside step 1, not at the end.
+
+## Rollback story
+
+Steps 1–5 are reversible by reverting the route file (the services keep
+working untouched). Step 7 is the one-way door — do not run the column-drop
+migration until two on-call rotations have passed without a related incident.
+
+## What this does NOT change
+
+- `lib/db.js`, `lib/booking-token.js`, `lib/csrf.js`, `lib/email.js`,
+ `lib/slots.js`, `lib/stripe.js` — unchanged.
+- The session store (`connect-pg-simple` against the `session` table) — unchanged.
+- Schema for `bookings`, `installer_portfolio`, `installer_availability`,
+ `installer_time_off`, `installer_reviews`, `consumer_leads`, `lead_offers`,
+ `subscription_events`, `directory_optout`, `scrape_log` — unchanged.
+
+## Decisions for Phase 2
+
+1. **Single-owner per installer — DECIDED YES, 2026-05-05.** Migration 005
+ now ships with a partial UNIQUE index
+ `uq_installer_members_one_owner ON installer_members (installer_id) WHERE role = 'owner'`.
+ v1 NPH assumes one operator per studio: claim flow is domain-restricted
+ to one email, billing is one Stripe customer per installer, dashboard
+ is single-user. v2 multi-owner work (e.g. an agency operating 5 studios)
+ will replace this with a richer `delegations` table — easier than
+ retrofitting consistency on top of dual-owner data later.
+
+## Open questions still pending Steve
+
+1. Self-signup grants `installer_owner` automatically. Should ops review
+ gate that role transition (claim_status flow), or is it fine for an
+ un-verified installer to be an `installer_owner` from minute one?
+2. Where do customers live? They aren't installers and won't be in `users`
+ under this plan — they show up only as `bookings.customer_email`. If we
+ ever want consumer accounts (saved addresses, repeat-customer pricing)
+ they'll need their own table or a `users.role = 'customer'` extension.
diff --git a/DATA_POLICY.md b/DATA_POLICY.md
new file mode 100644
index 0000000..f1925a6
--- /dev/null
+++ b/DATA_POLICY.md
@@ -0,0 +1,115 @@
+# NationalPaperHangers.com — Public Directory Data Policy
+
+**Version:** 1.0 · **Effective:** 2026-05-05 · **Owner:** Steve Abrams (operator)
+
+This policy governs what data NationalPaperHangers.com collects from public sources to seed its installer directory, how that data is stored, and how studios reclaim control of their listings. It is the binding document for `scripts/scrape-*.js` and any related ingestion job.
+
+## 1. Purpose
+
+To bootstrap a directory of luxury wallcovering installation studios that exist publicly but are not yet registered users. Listings exist so trade buyers (designers, architects, hospitality groups) can discover them, and so the studios themselves can claim and complete their profiles.
+
+## 2. Allowed sources
+
+Only **public, no-login, no-paywall, no-CAPTCHA** pages on the public web. Initial allow-list:
+
+| Source | URL pattern | Why it's appropriate |
+|---|---|---|
+| Wallcovering Installers Association | `wallcoveringinstallers.org/*` (public locator + member profile pages) | Trade association with publicly-listed members who self-disclose to be discoverable |
+| Studio websites linked from above | per-studio `https://...` | Self-published business website is per-se public marketing |
+
+Any new source must be added to this section before the scraper runs against it.
+
+**Excluded by policy:** Reddit, Nextdoor, NextDoor, LinkedIn, Facebook, Google review pages, Yelp business pages, Apple Maps, login-gated trade portals (Phillip Jeffries, Schumacher Direct, etc.), authenticated Instagram, anything behind a CAPTCHA or rate-limit gate. (The Reddit/Nextdoor exclusion follows `feedback_no_social_scraping_address_history.md`.)
+
+## 3. Allowed fields
+
+Per studio, only these fields may be collected and stored:
+
+- **Business name** (e.g. "Atelier Bond Wallcoverings")
+- **City, state, country** (publicly listed mailing locality)
+- **Public website URL**
+- **Public bio / studio description** (verbatim copy with attribution, ≤ 600 chars)
+- **Accreditations / certifications** the studio publicly displays (e.g. "WIA Certified Installer")
+- **Public Instagram handle** *only if* it appears as a clickable link on the studio's own public website
+- **Source URL + scrape timestamp** for every record (provenance)
+
+**Forbidden fields** (do NOT collect, even if visible):
+
+- Personal email addresses (`name@studio.com`, `info@studio.com`, anything ending in a domain)
+- Personal phone numbers (mobile or landline)
+- Street addresses (city + state + country only — never `123 Main St`)
+- Names of individual installers or owners
+- Tax IDs, license numbers, COI numbers, dates of birth, SSN/EIN
+- Social media handles other than Instagram (no Twitter/X, TikTok, Facebook, LinkedIn personal)
+- Reviews, testimonials, or ratings from other platforms
+
+## 4. Storage and provenance
+
+- All scraped records land in `installers` with `claim_status='unclaimed'`, `status='pending'`, `verified=false`.
+- `source_url` and `source_scraped_at` are required, NOT NULL on insert.
+- Records do not appear on `/find` until at least one of: (a) staff manually flips `status='active'`, OR (b) the studio claims and completes their profile.
+- Provenance is preserved through claim — when a studio claims, the original `source_url` and `source_scraped_at` are kept for audit.
+
+## 5. Crawl etiquette
+
+- Identify ourselves: `User-Agent: NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)`.
+- Respect `robots.txt` for every domain (RFC 9309 is a request, not authorization, but we honor it as good faith).
+- Maximum 1 request per 3 seconds per domain.
+- Maximum 200 requests per domain per day.
+- 30-second timeout per request; abandon on 4xx/5xx after 2 retries.
+- Stop immediately on any source's request — `info@nationalpaperhangers.com` is the contact for opt-out.
+- Log every request with URL, timestamp, response code to `logs/scrape-*.log`.
+
+## 6. Claim workflow
+
+A studio can claim its unclaimed listing in one of three ways:
+
+1. **Email verification** — claim form sends a verification email to a `info@` or `hello@` address on the studio's public website domain. Studio clicks link → listing converts to `pending`, redirects to signup with prefilled fields.
+2. **Domain proof** — studio adds a `nph-verify=<token>` TXT DNS record on their domain. Cron sweep verifies daily.
+3. **Manual review** — staff confirms identity via website live chat / public phone displayed on studio website / email reply chain.
+
+Until a successful claim, the listing displays *"Unclaimed listing — Is this your business?"* with a `Claim` CTA. Listings older than 365 days without a claim are auto-archived (`status='archived'`).
+
+## 7. No outbound use without separate consent
+
+**This is the hard rule.** Scraped data may NOT be used for:
+
+- Email marketing (CAN-SPAM, CCPA, GDPR concerns)
+- SMS / phone outreach (TCPA, federal/state DNC, Utah CPR)
+- Pre-populating a "lead list" sold or shared with third parties
+- Bulk newsletter sends
+- "Claim your listing" outreach **outside** the explicit, opt-out-honoring channel defined in §6
+
+Any future outbound campaign — including a "claim your listing" mailer to studios that have a public `info@` email on their site — requires prior review by the `comms-compliance` subagent, scrubbing against `comms_suppression`, and a per-message audit row in `comms_send_audit` (per `project_comms_compliance_agent.md`).
+
+## 8. Opt-out
+
+A studio (or anyone) can request listing removal by emailing `info@nationalpaperhangers.com`. We:
+
+1. Remove the listing within 5 business days.
+2. Add the domain to `directory_optout` so it cannot be re-scraped.
+3. Confirm removal in writing.
+
+## 9. Retention and review
+
+- Unclaimed listings: 365 days max, then auto-archive.
+- Claimed listings: indefinite, subject to the studio's own account deletion request.
+- Scrape logs: 90 days, then purged.
+- This policy is reviewed annually and at any source-list change.
+
+## 10. Legal posture
+
+This policy is informed by:
+
+- **RFC 9309** — robots.txt rules are a request we honor, not a basis for our own claim of authorization.
+- **hiQ Labs v. LinkedIn (9th Cir.)** — public, non-authenticated data does not trigger CFAA "without authorization" on its own. Not a license for unlimited scraping; contract / privacy / state law still applies.
+- **CAN-SPAM Act** — applies to commercial email, including B2B. We are not sending commercial email under this policy. Any future send is gated by §7.
+- **CCPA / CPRA** — California residents (including business contacts) have access / deletion rights. The opt-out in §8 is the primary channel.
+- **GDPR / UK GDPR** — if a UK/EU studio is scraped, legitimate-interests basis may apply for directory inclusion, but transparency notice and right-to-object are honored within one month per ICO guidance. Default opt-in for any EU outbound.
+- **California §6155 / Bar restrictions** do NOT apply (this is a wallcovering installer directory, not a lawyer-referral service). Cited only to keep the policy adjacent to `feedback_lawyer_directory_compliance.md` precedent.
+
+## 11. Changes
+
+Material changes to this policy require a new version number and a date in section 1. Prior versions are kept in git history.
+
+— end —
diff --git a/DEPLOY_KAMATERA.md b/DEPLOY_KAMATERA.md
new file mode 100644
index 0000000..78f9401
--- /dev/null
+++ b/DEPLOY_KAMATERA.md
@@ -0,0 +1,777 @@
+# National Paper Hangers — Kamatera Deployment Runbook
+
+**Status: READY TO EXECUTE — awaiting Steve's green-light.**
+**Hard gate on Step H:** DNS cutover requires a new Cloudflare token with Zone:Edit permission.
+The active CF token is DNS-edit only and cannot create zones.
+
+- Source: Mac2 `/Users/stevestudio2/Projects/NationalPaperHangers/` · pm2 `national-paper-hangers` · port 9765
+- Target: Kamatera VPS `45.61.58.125` · `/root/Projects/NationalPaperHangers/` · same port 9765
+- Domain: `nationalpaperhangers.com` (GoDaddy, currently ns63/ns64.domaincontrol.com)
+- Database: standalone PG `national_paper_hangers` (NOT dw_unified)
+
+---
+
+## Step A — Pre-flight
+
+Run these on Kamatera before anything else. SSH as root.
+
+### A1. Disk space
+
+```bash
+ssh root@45.61.58.125 "df -h /"
+```
+
+Expected output: at least 2 GB free (app + dump + npm modules ≈ 800 MB worst case).
+
+Failure: `Use%` is 95%+. Fix: `du -sh /root/Projects/* | sort -rh | head -20` to find what's large,
+then compress old dumps (`find /tmp -name '*.dump' -mtime +7 -delete`) or expand the VPS disk.
+
+### A2. Node version
+
+```bash
+ssh root@45.61.58.125 "node --version && npm --version"
+```
+
+Expected: `v20.x.x` or higher (package.json engines: `>=20`).
+
+Failure: older Node. Fix: `nvm install 20 && nvm alias default 20` or install via NodeSource:
+```bash
+curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+apt-get install -y nodejs
+```
+
+### A3. PostgreSQL version
+
+```bash
+ssh root@45.61.58.125 "psql --version && pg_lsclusters 2>/dev/null || pg_config --version"
+```
+
+Expected: PostgreSQL 14 or higher (schema uses `gen_random_uuid()`, GIN indexes, BRIN).
+
+Failure: PG 12 or lower. Fix: add PG apt repo and upgrade, or use Kamatera's PG 15 cluster if
+already present for another project.
+
+### A4. Port 9765 free
+
+```bash
+ssh root@45.61.58.125 "ss -tlnp | grep ':9765' || echo 'port 9765 is free'"
+```
+
+Expected: `port 9765 is free`
+
+Failure: another process is on 9765. Fix: `ss -tlnp | grep 9765` to identify the owner, then
+either kill it or assign NPH a different port (update ecosystem.kamatera.config.js PORT and nginx
+proxy_pass accordingly).
+
+### A5. Confirm national_paper_hangers DB does not already exist (first deploy only)
+
+```bash
+ssh root@45.61.58.125 "psql -U postgres -lqt | grep national_paper_hangers || echo 'DB absent'"
+```
+
+If absent, create it:
+```bash
+ssh root@45.61.58.125 "createdb -U postgres national_paper_hangers"
+```
+
+If present and this is a re-deploy, the pg_restore in Step B will drop+recreate objects idempotently.
+
+---
+
+## Step B — PG Migration (Mac2 → Kamatera)
+
+These commands run on **Mac2** unless noted.
+
+### B1. Dump the database (Mac2)
+
+```bash
+DUMP=/tmp/nph_$(date +%Y%m%d_%H%M%S).dump
+pg_dump -Fc -d national_paper_hangers -f "$DUMP"
+echo "Dump: $DUMP ($(du -sh $DUMP | cut -f1))"
+```
+
+Expected output: `Dump: /tmp/nph_20260505_143200.dump (2.1M)` — size varies with data volume.
+
+Failure: `pg_dump: error: connection to server on socket "/tmp/.s.PGSQL.5432" failed`.
+Fix: ensure local PG is running (`brew services start postgresql@15` on Mac2) and the DB exists
+(`psql -l | grep national_paper_hangers`).
+
+**Schema-only option for test deploys** (no data, just structure):
+```bash
+pg_dump -Fc --schema-only -d national_paper_hangers -f "$DUMP"
+# Then seed with: ssh root@45.61.58.125 "psql -d national_paper_hangers -f /root/Projects/NationalPaperHangers/db/seed.sql"
+```
+
+### B2. scp dump to Kamatera
+
+```bash
+scp "$DUMP" root@45.61.58.125:/tmp/
+REMOTE_DUMP="/tmp/$(basename $DUMP)"
+echo "Remote dump: $REMOTE_DUMP"
+```
+
+Expected: scp progress bar completes, exit 0.
+
+Failure: `Permission denied (publickey)` — fix: ensure `~/.ssh/config` has the kamatera key entry,
+or add `-i ~/.ssh/kamatera_id_rsa` to the scp command.
+
+### B3. Create the pg role (Kamatera, first deploy only)
+
+The app connects to PG with a dedicated role. On Kamatera, create it if absent:
+
+```bash
+ssh root@45.61.58.125 "psql -U postgres -c \"
+ DO \\\$\\\$ BEGIN
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='nph_app') THEN
+ CREATE ROLE nph_app LOGIN PASSWORD 'STRONG_PASSWORD_HERE';
+ END IF;
+ END \\\$\\\$;
+ GRANT ALL PRIVILEGES ON DATABASE national_paper_hangers TO nph_app;
+\""
+```
+
+Replace `STRONG_PASSWORD_HERE` with the actual PGPASSWORD value you'll use in .env.
+The role name `nph_app` matches the PGUSER env var in the production .env example below.
+
+### B4. pg_restore on Kamatera
+
+```bash
+ssh root@45.61.58.125 "pg_restore --no-owner -c --if-exists \
+ -U postgres \
+ -d national_paper_hangers \
+ $REMOTE_DUMP 2>&1 | tail -30"
+```
+
+Expected: a few lines of SET commands, exit 0. Errors like `ERROR: role "stevestudio2" does not exist`
+from `--no-owner` suppression are normal and harmless.
+
+Failure: `FATAL: database "national_paper_hangers" does not exist` — run Step A5 first.
+
+Failure: `pg_restore: error: could not execute query` on a specific table — check if the migration
+files in `db/migrations/` have been applied. Run migrations manually:
+
+```bash
+ssh root@45.61.58.125 "psql -d national_paper_hangers \
+ -f /root/Projects/NationalPaperHangers/db/migrations/001_claim_columns.sql \
+ -f /root/Projects/NationalPaperHangers/db/migrations/002_ad_signals.sql"
+# Migration 003 uses CONCURRENTLY — cannot run inside a transaction block:
+ssh root@45.61.58.125 "psql -d national_paper_hangers \
+ -f /root/Projects/NationalPaperHangers/db/migrations/003_perf.sql"
+```
+
+### B5. Grant table permissions (Kamatera)
+
+After pg_restore with `--no-owner`, tables are owned by `postgres`. Grant access to the app role:
+
+```bash
+ssh root@45.61.58.125 "psql -U postgres -d national_paper_hangers -c \"
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO nph_app;
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO nph_app;
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO nph_app;
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
+ GRANT USAGE, SELECT ON SEQUENCES TO nph_app;
+\""
+```
+
+### B6. Clean up dump files
+
+```bash
+rm "$DUMP" # Mac2 local copy
+ssh root@45.61.58.125 "rm $REMOTE_DUMP" # Kamatera /tmp copy
+```
+
+---
+
+## Step C — Code Sync
+
+### C1. rsync (Mac2 → Kamatera)
+
+The deploy script handles this automatically. To run manually:
+
+```bash
+rsync -avz --delete \
+ --exclude='node_modules/' \
+ --exclude='.env' \
+ --exclude='.git/' \
+ --exclude='*.dump' \
+ --exclude='*.log' \
+ /Users/stevestudio2/Projects/NationalPaperHangers/ \
+ root@45.61.58.125:/root/Projects/NationalPaperHangers/
+```
+
+Expected: file list scrolls by, `sent N bytes received M bytes` at end, exit 0.
+
+Failure: `ssh: connect to host 45.61.58.125 port 22: Connection refused` — check Tailscale is up
+on Mac2 and Kamatera, or use the direct Kamatera IP with SSH key.
+
+### C2. npm ci on Kamatera
+
+```bash
+ssh root@45.61.58.125 "cd /root/Projects/NationalPaperHangers && npm ci --omit=dev"
+```
+
+Expected: `added N packages` (around 80), `npm warn` lines for optional peers are fine. Exit 0.
+
+Failure: `npm ci can only install packages when package-lock.json is present` — ensure
+`package-lock.json` was NOT in `.gitignore` or rsync excludes. It should be synced.
+
+Failure: bcrypt native compile errors — install build tools:
+```bash
+ssh root@45.61.58.125 "apt-get install -y build-essential python3"
+```
+Then re-run npm ci.
+
+---
+
+## Step D — Environment (.env on Kamatera)
+
+Create `/root/Projects/NationalPaperHangers/.env` on Kamatera. This file is **never** rsync'd
+(intentionally excluded). Write it directly on Kamatera:
+
+```bash
+ssh root@45.61.58.125 "cat > /root/Projects/NationalPaperHangers/.env << 'ENVEOF'
+# ── Runtime ────────────────────────────────────────────────────────────────
+PORT=9765
+NODE_ENV=production
+
+# ── Sessions ────────────────────────────────────────────────────────────────
+# REQUIRED. Generate: openssl rand -base64 48
+SESSION_SECRET=REPLACE_WITH_STRONG_SECRET_MIN_32_CHARS
+
+# ── Booking token signing ────────────────────────────────────────────────────
+# REQUIRED. Generate: openssl rand -base64 48
+BOOKING_SIGNING_SECRET=REPLACE_WITH_STRONG_SECRET_MIN_32_CHARS
+
+# ── Public origin ────────────────────────────────────────────────────────────
+# REQUIRED. Used in email links and Stripe redirect URLs.
+PUBLIC_URL=https://nationalpaperhangers.com
+
+# ── Mailing address (CAN-SPAM §7) ────────────────────────────────────────────
+# REQUIRED. Appears in the footer of every transactional email.
+MAILING_ADDRESS=National Paper Hangers, [Street Address], [City, State ZIP]
+
+# ── Postgres (Kamatera PG) ────────────────────────────────────────────────────
+# REQUIRED.
+PGHOST=localhost
+PGPORT=5432
+PGDATABASE=national_paper_hangers
+PGUSER=nph_app
+PGPASSWORD=REPLACE_WITH_PG_PASSWORD
+
+# ── Stripe ────────────────────────────────────────────────────────────────────
+# REQUIRED for subscription + Stripe webhook flows.
+STRIPE_SECRET_KEY=sk_live_...
+STRIPE_WEBHOOK_SECRET=whsec_...
+# Price IDs from Stripe dashboard (Products → Prices):
+STRIPE_PRICE_PRO_MONTH=price_...
+STRIPE_PRICE_PRO_YEAR=price_...
+STRIPE_PRICE_SIGNATURE_MONTH=price_...
+STRIPE_PRICE_SIGNATURE_YEAR=price_...
+STRIPE_PRICE_ENTERPRISE_MONTH=price_...
+
+# ── George Gmail agent (via Tailscale) ───────────────────────────────────────
+# REQUIRED for all transactional email (booking confirmations, claim flows, etc.)
+# George is on Mac2 Tailscale IP — reachable from Kamatera via Tailnet.
+GEORGE_URL=http://100.107.67.67:9850
+GEORGE_ACCOUNT=info
+GEORGE_USER=admin
+GEORGE_PASS=REPLACE_WITH_GEORGE_PASSWORD
+EMAIL_FROM=info@nationalpaperhangers.com
+EMAIL_FROM_NAME=National Paper Hangers
+
+# ── Optional ──────────────────────────────────────────────────────────────────
+# GA4 measurement ID (injected client-side in views/layout.ejs if set).
+# GA4_MEASUREMENT_ID=G-XXXXXXXXXX
+ENVEOF
+chmod 600 /root/Projects/NationalPaperHangers/.env"
+```
+
+### D — Variable reference
+
+| Variable | Required | Description |
+|---|---|---|
+| `PORT` | yes | Must be 9765; matches nginx proxy_pass |
+| `NODE_ENV` | yes | Must be `production` or server refuses to start |
+| `SESSION_SECRET` | yes | ≥32 random chars; server throws on boot if missing/default |
+| `BOOKING_SIGNING_SECRET` | yes | Signs booking confirmation tokens in lib/booking-token.js |
+| `PUBLIC_URL` | yes | `https://nationalpaperhangers.com` — used in email links and Stripe redirects |
+| `MAILING_ADDRESS` | yes | CAN-SPAM footer address; scrubbed from email.js templates |
+| `PGHOST` | yes | `localhost` for Kamatera local PG |
+| `PGPORT` | yes | `5432` |
+| `PGDATABASE` | yes | `national_paper_hangers` |
+| `PGUSER` | yes | `nph_app` (the role created in Step B3) |
+| `PGPASSWORD` | yes | The password set in Step B3 |
+| `STRIPE_SECRET_KEY` | yes | Live secret key from Stripe dashboard |
+| `STRIPE_WEBHOOK_SECRET` | yes | From Stripe → Webhooks → endpoint secret |
+| `STRIPE_PRICE_PRO_MONTH` | yes | Stripe price ID for Pro monthly tier |
+| `STRIPE_PRICE_PRO_YEAR` | yes | Stripe price ID for Pro annual tier |
+| `STRIPE_PRICE_SIGNATURE_MONTH` | yes | Stripe price ID for Signature monthly |
+| `STRIPE_PRICE_SIGNATURE_YEAR` | yes | Stripe price ID for Signature annual |
+| `STRIPE_PRICE_ENTERPRISE_MONTH` | yes | Stripe price ID for Enterprise monthly |
+| `GEORGE_URL` | yes | `http://100.107.67.67:9850` (Mac2 Tailscale) |
+| `GEORGE_ACCOUNT` | yes | `info` — routes email through info@nationalpaperhangers.com |
+| `GEORGE_USER` | yes | `admin` (George basic auth) |
+| `GEORGE_PASS` | yes | George basic auth password |
+| `EMAIL_FROM` | yes | `info@nationalpaperhangers.com` |
+| `EMAIL_FROM_NAME` | yes | `National Paper Hangers` |
+| `GA4_MEASUREMENT_ID` | optional | GA4 G-XXXXXXX if analytics desired on prod |
+
+---
+
+## Step E — pm2 Start
+
+### E1. Start / reload via ecosystem file
+
+```bash
+ssh root@45.61.58.125 "
+ cd /root/Projects/NationalPaperHangers
+ if pm2 describe national-paper-hangers > /dev/null 2>&1; then
+ pm2 reload national-paper-hangers --update-env
+ echo 'Reloaded existing pm2 process.'
+ else
+ pm2 start /root/Projects/NationalPaperHangers/ecosystem.kamatera.config.js --env production
+ echo 'Started new pm2 process.'
+ fi
+"
+```
+
+Expected: `[PM2] Done` or `restarted` line, exit 0.
+
+Failure: `Error: SESSION_SECRET must be set to a strong value in production` — the .env file is
+missing or SESSION_SECRET is still the placeholder. Fix: update .env, then re-run `pm2 reload`.
+
+Failure: `Error: Cannot find module './routes/public'` — rsync didn't complete cleanly.
+Re-run Step C1.
+
+### E2. pm2 save on Kamatera (persists across reboots via systemd pm2-root.service)
+
+```bash
+ssh root@45.61.58.125 "pm2 save"
+```
+
+Expected: `[PM2] Saving current process list... [PM2] Successfully saved in /root/.pm2/dump.pm2`
+
+**IMPORTANT: Never run `pm2 save` on Mac2. Only run it on Kamatera.**
+
+### E3. Verify process is healthy
+
+```bash
+ssh root@45.61.58.125 "pm2 show national-paper-hangers"
+```
+
+Expected: `status: online`, `restart time: 0` (or low), `uptime: Xs`.
+
+```bash
+ssh root@45.61.58.125 "curl -sf http://127.0.0.1:9765/health || curl -sf http://127.0.0.1:9765/"
+```
+
+Expected: HTTP 200 with HTML body.
+
+### E4. nginx stale-config safety check
+
+Per the 2026-04-30 Site Factory cross-routing incident, check for stale files before loading nginx:
+
+```bash
+ssh root@45.61.58.125 "find /etc/nginx/sites-enabled/ -name '*.bak' -o -name '*.old' 2>/dev/null && echo 'WARNING: stale configs found' || echo 'OK: no stale configs'"
+```
+
+If stale files are found: `rm` each one, then proceed.
+
+---
+
+## Step F — nginx Server Block
+
+### F1. Write the config
+
+```bash
+ssh root@45.61.58.125 "cat > /etc/nginx/sites-available/nationalpaperhangers.com << 'NGINX_EOF'
+# National Paper Hangers — nginx reverse proxy
+# Proxy: 127.0.0.1:9765 (pm2 node process)
+# SSL: managed by certbot (added in Step G; placeholders here until then)
+# Real-IP: Cloudflare IP ranges so req.ip is the visitor, not CF's proxy.
+
+# HTTP → HTTPS redirect (activated after certbot adds SSL in Step G)
+server {
+ listen 80;
+ listen [::]:80;
+ server_name nationalpaperhangers.com www.nationalpaperhangers.com;
+
+ # Let certbot own /.well-known/acme-challenge/ for renewal.
+ location /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ }
+
+ # All other traffic: redirect to HTTPS after SSL is provisioned.
+ # During initial HTTP-only test, comment out the return line and add:
+ # proxy_pass http://127.0.0.1:9765;
+ return 301 https://\$host\$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name nationalpaperhangers.com www.nationalpaperhangers.com;
+
+ # SSL certs — certbot fills these in Step G.
+ # ssl_certificate /etc/letsencrypt/live/nationalpaperhangers.com/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/nationalpaperhangers.com/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # ── Real-IP from Cloudflare ──────────────────────────────────────────────
+ # Cloudflare publishes its IPv4 ranges. Update this list if CF adds new ranges.
+ # https://www.cloudflare.com/ips-v4
+ set_real_ip_from 103.21.244.0/22;
+ set_real_ip_from 103.22.200.0/22;
+ set_real_ip_from 103.31.4.0/22;
+ set_real_ip_from 104.16.0.0/13;
+ set_real_ip_from 104.24.0.0/14;
+ set_real_ip_from 108.162.192.0/18;
+ set_real_ip_from 131.0.72.0/22;
+ set_real_ip_from 141.101.64.0/18;
+ set_real_ip_from 162.158.0.0/15;
+ set_real_ip_from 172.64.0.0/13;
+ set_real_ip_from 173.245.48.0/20;
+ set_real_ip_from 188.114.96.0/20;
+ set_real_ip_from 190.93.240.0/20;
+ set_real_ip_from 197.234.240.0/22;
+ set_real_ip_from 198.41.128.0/17;
+ # IPv6 CF ranges
+ set_real_ip_from 2400:cb00::/32;
+ set_real_ip_from 2606:4700::/32;
+ set_real_ip_from 2803:f800::/32;
+ set_real_ip_from 2405:b500::/32;
+ set_real_ip_from 2405:8100::/32;
+ set_real_ip_from 2a06:98c0::/29;
+ set_real_ip_from 2c0f:f248::/32;
+ real_ip_header CF-Connecting-IP;
+
+ # ── Gzip ────────────────────────────────────────────────────────────────
+ gzip on;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 6;
+ gzip_types text/plain text/css application/json application/javascript
+ text/xml application/xml application/xml+rss text/javascript
+ image/svg+xml;
+
+ # ── Static assets (served by Express; nginx can short-circuit if needed) ─
+ # Express serves /public/ directly. Uncomment below for nginx-level caching
+ # if the Node process becomes a bottleneck.
+ # location /public/ {
+ # root /root/Projects/NationalPaperHangers;
+ # expires 30d;
+ # add_header Cache-Control "public, immutable";
+ # }
+
+ # ── Stripe webhook — raw body required ───────────────────────────────────
+ # express raw-body middleware handles this; no special nginx treatment needed.
+
+ # ── Main proxy ───────────────────────────────────────────────────────────
+ location / {
+ proxy_pass http://127.0.0.1:9765;
+ proxy_http_version 1.1;
+
+ # WebSocket support (booking slot polling uses SSE; future WS ready).
+ proxy_set_header Upgrade \$http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Pass real visitor IP + protocol to Express (app uses trust proxy 1).
+ proxy_set_header Host \$host;
+ proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \$scheme;
+
+ # Timeouts — booking slot fetches can take a few seconds on cold DB.
+ proxy_connect_timeout 10s;
+ proxy_read_timeout 60s;
+ proxy_send_timeout 60s;
+
+ proxy_buffering off;
+ proxy_cache_bypass \$http_upgrade;
+ }
+
+ # ── Security headers not covered by helmet ────────────────────────────────
+ # Helmet handles most (HSTS, CSP, X-Frame, etc.) — nginx adds proto header
+ # so Express can enforce HTTPS redirects internally.
+ add_header X-Forwarded-Proto \$scheme always;
+}
+NGINX_EOF
+echo 'nginx config written'"
+```
+
+### F2. Enable the site
+
+```bash
+ssh root@45.61.58.125 "
+ ln -sf /etc/nginx/sites-available/nationalpaperhangers.com \
+ /etc/nginx/sites-enabled/nationalpaperhangers.com
+ echo 'Symlink created.'
+"
+```
+
+### F3. Validate and reload nginx
+
+```bash
+ssh root@45.61.58.125 "nginx -t && systemctl reload nginx && echo 'nginx reloaded OK'"
+```
+
+Expected: `nginx: the configuration file /etc/nginx/nginx.conf syntax is ok` and
+`nginx: configuration file /etc/nginx/nginx.conf test is successful`, then `nginx reloaded OK`.
+
+Failure: `nginx: [emerg] "server" directive is not allowed here` — usually a bad symlink pointing
+to a non-config file. Check `ls -la /etc/nginx/sites-enabled/` for .bak/.old files and remove them.
+
+---
+
+## Step G — SSL (Let's Encrypt via certbot)
+
+Run this **after** DNS has propagated to Kamatera (Step H) or during HTTP-only validation with
+CF proxy temporarily disabled (orange cloud off).
+
+```bash
+ssh root@45.61.58.125 "
+ certbot --nginx \
+ -d nationalpaperhangers.com \
+ -d www.nationalpaperhangers.com \
+ --non-interactive \
+ --agree-tos \
+ -m info@nationalpaperhangers.com \
+ --redirect
+"
+```
+
+Expected: certbot edits the nginx config, adds the ssl_certificate lines, and enables the
+301 redirect block. `Congratulations! Your certificate and chain have been saved`.
+
+Failure: `Challenge failed for domain nationalpaperhangers.com` (ACME HTTP-01 challenge).
+This means DNS is not yet pointing at Kamatera. Complete Step H first, wait for propagation
+(`dig nationalpaperhangers.com A` should return 45.61.58.125), then re-run certbot.
+
+Failure: `too many certificates already issued` — Let's Encrypt rate limit. Use staging for
+retries: add `--staging` flag. Remove staging certs and re-run without `--staging` when ready.
+
+Auto-renew is handled by the certbot systemd timer already installed on Kamatera.
+
+---
+
+## Step H — DNS Cutover (GATED — requires new CF token)
+
+**This step is blocked until a new Cloudflare token is minted.**
+
+The active CF token is DNS-edit only (`Zone:DNS:Edit`) and **cannot create zones** (`Zone:Edit`
+permission is required). Do NOT use the active token to attempt zone creation — it will fail.
+
+### H1. Mint a new Cloudflare token (Steve must do this in CF dashboard)
+
+1. Go to: https://dash.cloudflare.com/profile/api-tokens
+2. Create token → "Edit zone DNS" template → Customize.
+3. Permissions: `Zone — Zone — Edit` AND `Zone — DNS — Edit`.
+4. Zone resources: Include — Specific zone — nationalpaperhangers.com (or "All zones" if preferred).
+5. Copy the token. Route it through the secrets-manager skill.
+
+### H2. Create the Cloudflare zone
+
+```bash
+curl -X POST "https://api.cloudflare.com/client/v4/zones" \
+ -H "Authorization: Bearer $CF_ZONE_CREATE_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data '{"name":"nationalpaperhangers.com","jump_start":false}' | jq .
+```
+
+Note the `id` field from the response — this is the zone ID.
+
+### H3. Add DNS records
+
+Replace `ZONE_ID` with the zone ID from H2.
+
+```bash
+# A record — proxied through Cloudflare (orange cloud)
+curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/dns_records" \
+ -H "Authorization: Bearer $CF_ZONE_CREATE_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data '{"type":"A","name":"nationalpaperhangers.com","content":"45.61.58.125","proxied":true}' | jq .
+
+# www CNAME — also proxied
+curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/dns_records" \
+ -H "Authorization: Bearer $CF_ZONE_CREATE_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data '{"type":"CNAME","name":"www","content":"nationalpaperhangers.com","proxied":true}' | jq .
+
+# MX record for info@nationalpaperhangers.com (George Gmail)
+# Set to match whatever MX George/Gmail uses — typically Google Workspace MX.
+# Example (replace with actual MX values from DNS provider for info@):
+# curl -X POST ... --data '{"type":"MX","name":"nationalpaperhangers.com","content":"aspmx.l.google.com","priority":1,"proxied":false}'
+```
+
+### H4. Get Cloudflare nameservers for this zone
+
+```bash
+curl -s "https://api.cloudflare.com/client/v4/zones/ZONE_ID" \
+ -H "Authorization: Bearer $CF_ZONE_CREATE_TOKEN" | jq '.result.name_servers'
+```
+
+Expected: two CF nameservers like `caden.ns.cloudflare.com` and `mia.ns.cloudflare.com`.
+
+### H5. Update GoDaddy NS records
+
+In the GoDaddy control panel for `nationalpaperhangers.com`:
+- Change NS from `ns63.domaincontrol.com` / `ns64.domaincontrol.com`
+- to the two Cloudflare nameservers from H4.
+
+Or via GoDaddy API:
+```bash
+# Requires GODADDY_API_KEY and GODADDY_API_SECRET
+curl -X PUT "https://api.godaddy.com/v1/domains/nationalpaperhangers.com/records/NS/@" \
+ -H "Authorization: sso-key $GODADDY_API_KEY:$GODADDY_API_SECRET" \
+ -H "Content-Type: application/json" \
+ --data '[{"data":"caden.ns.cloudflare.com"},{"data":"mia.ns.cloudflare.com"}]'
+```
+
+**PROTECTED DNS REMINDER: Never touch designerwallcoverings.com or studentdebtcrisis*.org
+nameservers or DNS records in any script.**
+
+### H6. Verify propagation
+
+```bash
+watch -n 10 "dig nationalpaperhangers.com NS +short && dig nationalpaperhangers.com A +short"
+```
+
+Wait until NS shows Cloudflare nameservers and A resolves to 45.61.58.125. Propagation is
+typically 5–30 minutes for GoDaddy NS changes. Full TTL-based propagation can take up to 48h,
+but CF's anycast means most users see it within minutes of GoDaddy acknowledging the NS change.
+
+---
+
+## Step I — Smoke Verification
+
+### I1. Curl health checks
+
+```bash
+# Basic HTTP response
+curl -I https://nationalpaperhangers.com/
+
+# Expected: HTTP/2 200
+# Check for: x-forwarded-proto: https (nginx header)
+# strict-transport-security (helmet HSTS)
+# content-type: text/html
+
+# Find page (core directory)
+curl -sf https://nationalpaperhangers.com/find | grep -i installer
+
+# www redirect
+curl -I https://www.nationalpaperhangers.com/
+# Expected: HTTP/2 301 or 200 depending on CF www handling
+```
+
+### I2. Run the smoke test suite against the live URL
+
+The smoke tests are wired to the local DB connection. For a prod smoke test, SSH to Kamatera
+and run against the Kamatera DB:
+
+```bash
+ssh root@45.61.58.125 "
+ cd /root/Projects/NationalPaperHangers
+ NODE_ENV=production node --test tests/smoke.test.js
+"
+```
+
+Expected: `10 passing` — all 10 tests green (DB ping, /find, /installer/:slug claimed + unclaimed,
+404 slug, /book renders, /book for inactive 404, slots logic, claim token flow, teardown).
+
+Failure on `DB: pool can query` — Postgres credentials in .env are wrong. Check PGHOST/PGUSER/PGPASSWORD.
+
+Failure on `/find returns 200` — nginx proxy_pass misconfigured or pm2 process is down.
+Check: `pm2 show national-paper-hangers` and `nginx -t`.
+
+### I3. Stripe webhook endpoint registration
+
+Register the production webhook endpoint in the Stripe dashboard:
+- URL: `https://nationalpaperhangers.com/webhooks/stripe`
+- Events: `customer.subscription.created`, `customer.subscription.updated`,
+ `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`
+- Copy the signing secret (`whsec_...`) into the `.env` `STRIPE_WEBHOOK_SECRET` field.
+- Reload pm2: `ssh root@45.61.58.125 "pm2 reload national-paper-hangers --update-env"`
+
+---
+
+## Step J — Rollback Plan
+
+If the deploy is broken and needs to be reverted:
+
+### J1. Stop the Kamatera pm2 process
+
+```bash
+ssh root@45.61.58.125 "pm2 stop national-paper-hangers && pm2 save"
+```
+
+This takes the app offline. Traffic from CF will start 522-erroring.
+
+### J2. Point Cloudflare A record back to Mac2
+
+If Mac2 is publicly reachable (it needs a static IP or CF Tunnel):
+
+Option A — Static IP: change the CF A record to Mac2's public IP.
+Option B — CF Tunnel: if a Cloudflare Tunnel was pre-configured on Mac2, toggle the DNS record
+to the tunnel's CNAME target.
+
+```bash
+curl -X PATCH "https://api.cloudflare.com/client/v4/zones/ZONE_ID/dns_records/A_RECORD_ID" \
+ -H "Authorization: Bearer $CF_ZONE_CREATE_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data '{"content":"MAC2_PUBLIC_IP","proxied":true}' | jq .result.content
+```
+
+### J3. Restart Mac2 pm2 process
+
+```bash
+# Run on Mac2 — NOT Kamatera
+pm2 restart national-paper-hangers
+pm2 show national-paper-hangers
+```
+
+Mac2 pm2 `national-paper-hangers` at :9765 should come back online.
+
+### J4. Verify rollback
+
+```bash
+curl -sf https://nationalpaperhangers.com/find | grep -i installer
+```
+
+### J5. Diagnose Kamatera failure
+
+```bash
+ssh root@45.61.58.125 "pm2 logs national-paper-hangers --lines 100"
+ssh root@45.61.58.125 "tail -50 /root/.pm2/logs/national-paper-hangers-error.log"
+```
+
+Common root causes:
+- Missing or malformed .env value (especially SESSION_SECRET or PGPASSWORD)
+- PG role permissions not granted (Step B5 skipped)
+- Port 9765 conflict with another process
+- npm ci used wrong Node version — check `node --version` on Kamatera
+
+---
+
+## Appendix — Automated Deploy Script
+
+`scripts/deploy-kamatera.sh` automates Steps B–E. It is DRY-RUN by default.
+
+```bash
+# Preview what it would do:
+bash /Users/stevestudio2/Projects/NationalPaperHangers/scripts/deploy-kamatera.sh
+
+# Execute for real (after Steve green-lights):
+bash /Users/stevestudio2/Projects/NationalPaperHangers/scripts/deploy-kamatera.sh --commit
+```
+
+The script:
+- Checks for protected-domain references before touching any nginx config
+- Dumps PG, sccp's to Kamatera, pg_restore's
+- rsync's source (excludes .env, node_modules, .git)
+- npm ci --omit=dev on Kamatera
+- pm2 reload (or start) on Kamatera
+- pm2 save on Kamatera only
+- Checks for stale .bak/.old nginx configs
+- Logs everything to /tmp/deploy-kamatera-nph-*.log
diff --git a/EMAIL_PROVIDER_DECISION.md b/EMAIL_PROVIDER_DECISION.md
new file mode 100644
index 0000000..d9e654a
--- /dev/null
+++ b/EMAIL_PROVIDER_DECISION.md
@@ -0,0 +1,191 @@
+# Email Provider Decision — info@nationalpaperhangers.com
+
+**Domain:** nationalpaperhangers.com
+**Required address:** info@nationalpaperhangers.com
+**Roles:** CAN-SPAM "From" header address, studio self-claim verification destination
+**Decision date:** 2026-05-05
+
+---
+
+## Constraints
+
+- Mac2 is residential — no public inbound MX routing to George agent on :9850.
+- Steve already has Google Workspace at designerwallcoverings.com ($7/user/mo is sunk cost there).
+- NPH is one of many sister sites; per-domain Workspace charges compound fast.
+- DMARC aggregate reports (`rua=`) route to `info@designerwallcoverings.com` (Steve already monitors that inbox).
+- DKIM signing is non-negotiable for CAN-SPAM/deliverability; unauthenticated forwarding alone is not sufficient.
+
+---
+
+## Option Analysis
+
+### A. Google Workspace — $7/user/month ($84/year)
+
+| Dimension | Assessment |
+|---|---|
+| Cost | $84/yr per domain, forever |
+| Setup complexity | Low — UI-driven, MX records are standard |
+| Deliverability | Gold standard; Gmail infrastructure |
+| Scales to N domains | No — cost multiplies linearly with domain count |
+| Time to first send | ~1 business day (MX propagation + DNS verify) |
+| George (Mac2) fit | Native — George bridges GW via Gmail OAuth/app-password |
+| DKIM | Provisioned automatically from Admin console |
+
+**Verdict:** Best deliverability and lowest friction, but becomes the most expensive option as the portfolio grows. At 10 sister sites this is $840/yr just for mailboxes.
+
+---
+
+### B. Migadu — Mini plan $90/year (unlimited domains, ~100 outbound/day)
+
+| Dimension | Assessment |
+|---|---|
+| Cost | $90/yr flat regardless of domain count |
+| Setup complexity | Medium — DKIM uses 3 CNAME delegates; webmail provisioning is manual but one-time |
+| Deliverability | Good; Migadu IPs are clean, DKIM+DMARC fully supported |
+| Scales to N domains | Yes — all sister sites fold into one $90/yr account |
+| Time to first send | ~24h (DNS + domain verification TXT + DKIM CNAMEs) |
+| George (Mac2) fit | Partial — George sends via SMTP; Migadu Mini outbound cap is 100/day, fine for transactional |
+| DKIM | Full 3-key CNAME delegation; verified per domain |
+
+**Verdict:** Best value once Steve has 2+ sister domains needing mailboxes. NPH alone already breaks even vs. Workspace in year 2 (Migadu Mini at $90/yr vs $84/yr for Workspace). With 5 domains on one Migadu account the savings are ~$330/yr vs Workspace.
+
+---
+
+### C. Fastmail — Business Standard $5/user/month ($60/year)
+
+| Dimension | Assessment |
+|---|---|
+| Cost | $60/yr (annual); custom domains require Standard tier |
+| Setup complexity | Low — clean admin UI, standard MX pair |
+| Deliverability | Excellent; established provider, good IP reputation |
+| Scales to N domains | No — per-user billing; additional custom domains cost extra |
+| Time to first send | ~1 business day |
+| George (Mac2) fit | Moderate — SMTP credentials work but George was built around Gmail OAuth flow |
+| DKIM | Supported; configured from dashboard |
+
+**Verdict:** Cheaper than Workspace per domain but doesn't solve the scaling problem. Not compelling vs Migadu once you account for more than one domain.
+
+---
+
+### D. ImprovMX — Free tier forwarding (receive-only; send via "Send mail as" workaround)
+
+| Dimension | Assessment |
+|---|---|
+| Cost | $0 free / $9/mo Premium for SMTP+DKIM |
+| Setup complexity | Low (receive); Medium (send-as workaround is fragile) |
+| Deliverability | **Critical gap:** free tier "Send mail as" bypasses DKIM; Yahoo/strict-DMARC receivers reject or spam-folder. Premium at $9/mo adds DKIM but costs $108/yr — more than Workspace |
+| Scales to N domains | Light plan ($50/yr, 5 domains) is plausible but SMTP cap is 25/day |
+| Time to first send | Fast for receive; send-as setup adds 30 min |
+| George (Mac2) fit | Poor — George sends via SMTP; free tier has no outbound SMTP at all |
+| DKIM | **Free: none. Premium: yes.** |
+
+**Verdict:** The free tier fails the CAN-SPAM "from" requirement because unauthenticated forwarded-send fails DMARC alignment on strict receivers. Premium at $108/yr is worse than Workspace. Eliminated.
+
+---
+
+### E. Self-hosted Postfix/Dovecot on Kamatera + OpenDKIM — $0 incremental
+
+| Dimension | Assessment |
+|---|---|
+| Cost | $0 marginal (Kamatera already running); one-time setup ~4h |
+| Setup complexity | High — Postfix + Dovecot + OpenDKIM + SPF milter + DMARC reporting; ongoing ops |
+| Deliverability | **High risk:** Kamatera is a cloud VPS; IP `45.61.58.125` is shared with production web traffic. New-sender reputation on VPS IPs is poor out of the box; warming required. No established ESP relationship for FBL/bounces |
+| Scales to N domains | Yes — each domain adds one DKIM key + DNS records |
+| Time to first send | 1–2 days setup + 2–4 weeks warm-up before bulk is safe |
+| George (Mac2) fit | Works — George would SMTP-relay through Kamatera MX |
+| DKIM | Fully controllable |
+
+**Verdict:** Viable long-term but Kamatera IP reputation risk is real for a new sender. The ops surface (bounce handling, blacklist monitoring, DMARC aggregate parsing) is non-trivial to maintain. Wrong tool for a mailbox that primarily sends verification emails at low volume.
+
+---
+
+## Recommendation: Migadu Mini — $90/year
+
+**Rationale:**
+
+1. **Flat pricing solves the fleet problem.** One Migadu account covers NPH plus every other sister site Steve adds. The $90/yr Mini plan supports unlimited domains and gives 100 outbound emails/day — sufficient for transactional verification mail and CAN-SPAM-compliant "from" headers at NPH's expected volume.
+
+2. **Full DKIM + DMARC.** Migadu provisions three DKIM CNAME delegates per domain. With DMARC `p=quarantine` after the 14-day monitoring window, deliverability is solid and CAN-SPAM-compliant.
+
+3. **Better economics than Workspace at scale.** At 2 domains, Migadu ($90/yr) already beats 2 × Workspace ($168/yr). Steve's portfolio makes this math increasingly favorable.
+
+4. **George compatibility.** George sends via SMTP. Migadu exposes standard SMTP on port 587 with TLS. George's `SMTP_HOST` / `SMTP_USER` / `SMTP_PASS` env vars just need to point at Migadu instead of Gmail's SMTP relay. No architecture change required.
+
+5. **Inbound routing works.** MX records point inbound mail to Migadu's servers (publicly routable). Replies to claim-verification emails land in the Migadu webmail or can be forwarded to Steve's existing DW inbox — solving the Mac2-not-publicly-routable problem cleanly.
+
+**Total annual cost:** $90/yr (covers all current and future sister domains on one account). Zero marginal cost per additional domain.
+
+---
+
+## DNS Records — Migadu Mini
+
+These records are ready to paste into Cloudflare once the zone is created. Replace `<DKIM-VERIFY-TOKEN>` with the value Migadu shows in its domain-verification step.
+
+### MX Records
+
+| Type | Name | Value | Priority | TTL |
+|---|---|---|---|---|
+| MX | `@` | `aspmx1.migadu.com.` | 10 | 3600 |
+| MX | `@` | `aspmx2.migadu.com.` | 20 | 3600 |
+
+### SPF Record
+
+| Type | Name | Value | TTL |
+|---|---|---|---|
+| TXT | `@` | `v=spf1 include:spf.migadu.com -all` | 3600 |
+
+Note: `-all` (hard fail) is correct for Migadu. Do not use `~all` (softfail) once DKIM is confirmed working.
+
+### DKIM Records (3 CNAME delegates — Migadu's pattern)
+
+| Type | Name | Value | TTL |
+|---|---|---|---|
+| CNAME | `key1._domainkey` | `key1.nationalpaperhangers.com._domainkey.migadu.com.` | 3600 |
+| CNAME | `key2._domainkey` | `key2.nationalpaperhangers.com._domainkey.migadu.com.` | 3600 |
+| CNAME | `key3._domainkey` | `key3.nationalpaperhangers.com._domainkey.migadu.com.` | 3600 |
+
+### Domain Verification TXT (required by Migadu to claim the domain)
+
+| Type | Name | Value | TTL |
+|---|---|---|---|
+| TXT | `@` | `hosted-email-verify=<DKIM-VERIFY-TOKEN>` | 3600 |
+
+Migadu shows this token in the domain setup wizard. It is domain-specific and generated on first claim.
+
+### DMARC Roll-out
+
+**Phase 1 — Days 1–14 (monitor only):**
+
+| Type | Name | Value | TTL |
+|---|---|---|---|
+| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:info@designerwallcoverings.com; ruf=mailto:info@designerwallcoverings.com; fo=1; adkim=r; aspf=r` | 3600 |
+
+**Phase 2 — Day 15+ (after reviewing aggregate reports):**
+
+Update `p=none` to `p=quarantine`. After another 14-day clean run, escalate to `p=reject` if desired.
+
+```
+v=DMARC1; p=quarantine; rua=mailto:info@designerwallcoverings.com; ruf=mailto:info@designerwallcoverings.com; fo=1; adkim=r; aspf=r
+```
+
+The `rua=` and `ruf=` addresses must accept reports from external domains. Migadu domains automatically satisfy this. Google Workspace (DW's host) accepts inbound DMARC reports by default.
+
+---
+
+## Post-Provision George Config Update
+
+Once Migadu SMTP credentials are in hand, update George on Mac2:
+
+```
+SMTP_HOST=smtp.migadu.com
+SMTP_PORT=587
+SMTP_USER=info@nationalpaperhangers.com
+SMTP_PASS=<migadu-app-password>
+SMTP_FROM=info@nationalpaperhangers.com
+```
+
+Route through secrets-manager: `node ~/Projects/secrets-manager/cli.js add MIGADU_SMTP_PASS_NPH <value>` then add `national-paper-hangers` to routes.json for this key.
+
+---
+
+*Sources consulted: Migadu pricing page (migadu.com/pricing, May 2026), ImprovMX pricing page (improvmx.com/pricing, May 2026), Fastmail pricing (fastmail.com/pricing/us, May 2026), ImprovMX Cloudflare guide (improvmx.com/guides/cloudflare), Migadu DNSControl gist (github.com/tennox), MXToolbox Migadu DKIM report.*
diff --git a/GO_LIVE.md b/GO_LIVE.md
new file mode 100644
index 0000000..b21a949
--- /dev/null
+++ b/GO_LIVE.md
@@ -0,0 +1,280 @@
+# NPH Go-Live Runbook
+
+End-to-end checklist for taking National Paper Hangers from local
+`http://localhost:9765` (Mac2) to `https://nationalpaperhangers.com`
+(public, Kamatera-hosted). Every step is reversible until §6 (Stripe webhook
+registration) and §3 (DNS swap) — those two are externally visible.
+
+DNS swap is **gated on Steve's explicit approval**. Everything else can
+ship in test mode without touching the public domain.
+
+---
+
+## 1. Pre-flight env-var checklist
+
+Run `npm run go-live-check` to print the live state of every env var the
+app reads. The script exits non-zero if any required-for-live var is absent
+while `NODE_ENV=production`.
+
+| Var | Req | Where | Notes |
+|---|---|---|---|
+| `NODE_ENV` | always | .env | `production` for live |
+| `PORT` | always | .env | `9765` (Mac2) / `9765` (Kamatera) |
+| `PUBLIC_URL` | live | .env | `https://nationalpaperhangers.com` |
+| `PGHOST` / `PGPORT` / `PGDATABASE` / `PGUSER` / `PGPASSWORD` | always | .env | Standalone PG, NOT `dw_unified` |
+| `SESSION_SECRET` | live | /secrets | 32+ random bytes; rotate quarterly |
+| `BOOKING_SIGNING_SECRET` | live | /secrets | HMAC for `/bookings/:uuid?t=…` |
+| `UNSUBSCRIBE_SIGNING_SECRET` | live | /secrets | One-click unsub HMAC |
+| `STRIPE_SECRET_KEY` | live | /secrets → routes.json[`stripe`] | `sk_live_*` |
+| `STRIPE_PUBLISHABLE_KEY` | live | /secrets | `pk_live_*` (exposed to browser) |
+| `STRIPE_WEBHOOK_SECRET` | live | /secrets | `whsec_*` for `/webhooks/stripe` sig verify |
+| `STRIPE_PRICE_PRO_MONTH` | live | /secrets | Stripe price ID |
+| `STRIPE_PRICE_PRO_YEAR` | live | /secrets | Stripe price ID |
+| `STRIPE_PRICE_SIGNATURE_MONTH` | live | /secrets | Stripe price ID |
+| `STRIPE_PRICE_SIGNATURE_YEAR` | live | /secrets | Stripe price ID |
+| `STRIPE_PRICE_ENTERPRISE_MONTH` | live | /secrets | Stripe price ID |
+| `STRIPE_DEV_ACCEPT_UNSIGNED` | optional | .env | Dev escape only — refused in prod |
+| `NPH_DEFAULT_DEPOSIT_CENTS` | optional | .env | Default `9900` ($99) |
+| `NPH_PLATFORM_FEE_BPS` | optional | .env | Default `1000` (10%) |
+| `NPH_PLATFORM_ADMIN_INSTALLER_IDS` | optional | .env | Comma list — staff see platform totals on /admin/billing |
+| `GEORGE_URL` | live | .env | `http://localhost:9850` (Mac) / `http://100.107.67.67:9850` (tailnet) |
+| `GEORGE_USER` / `GEORGE_PASS` | live | /secrets | George basic-auth |
+| `GEORGE_ACCOUNT` | optional | .env | `info` for multi-account |
+| `EMAIL_FROM` | live | .env | `info@nationalpaperhangers.com` |
+| `EMAIL_FROM_NAME` | optional | .env | `National Paper Hangers` |
+| `MAILING_ADDRESS` | live | .env | **CAN-SPAM gate refuses sends without this** |
+
+---
+
+## 2. Stripe live-mode steps
+
+### 2.1 Save secrets via the secrets-manager skill
+
+```
+/secrets add STRIPE_SECRET_KEY=sk_live_…
+/secrets add STRIPE_PUBLISHABLE_KEY=pk_live_…
+/secrets add STRIPE_WEBHOOK_SECRET=whsec_…
+/secrets add STRIPE_PRICE_PRO_MONTH=price_…
+/secrets add STRIPE_PRICE_PRO_YEAR=price_…
+/secrets add STRIPE_PRICE_SIGNATURE_MONTH=price_…
+/secrets add STRIPE_PRICE_SIGNATURE_YEAR=price_…
+/secrets add STRIPE_PRICE_ENTERPRISE_MONTH=price_…
+```
+
+The secrets skill fans these into:
+- master `~/Projects/secrets-manager/.env`
+- desktop `~/Desktop/site-factory.env`
+- this project's `.env` (registered route)
+
+### 2.2 Configure the webhook endpoint in Stripe Dashboard
+
+URL: `https://nationalpaperhangers.com/webhooks/stripe`
+
+Subscribe to events:
+- `checkout.session.completed`
+- `customer.subscription.created`
+- `customer.subscription.updated`
+- `customer.subscription.deleted`
+- `payment_intent.succeeded`
+- `payment_intent.payment_failed`
+- `payment_intent.canceled`
+- `account.updated` (Connect status mirror)
+
+### 2.3 Connect platform setup
+
+In Stripe Dashboard → Connect → Settings:
+- Platform name: `National Paper Hangers`
+- Branding: brand mark / palette
+- Onboarding type: **Express**
+- Allowed countries: US (extend later)
+- Capabilities: `card_payments`, `transfers`
+
+### 2.4 Subscription product/prices
+
+In Stripe Dashboard → Products, create:
+- **NPH Pro** — $39/mo, $399/yr (`STRIPE_PRICE_PRO_MONTH`, `_YEAR`)
+- **NPH Signature** — $149/mo, $1500/yr (`STRIPE_PRICE_SIGNATURE_MONTH`, `_YEAR`)
+- **NPH Enterprise** — $399/mo (`STRIPE_PRICE_ENTERPRISE_MONTH`)
+
+Match `lib/stripe.js#PRICE_TABLE`.
+
+### 2.5 Flip NODE_ENV
+
+`.env`: `NODE_ENV=production`. `npm run go-live-check` should now exit 0.
+
+---
+
+## 3. DNS swap plan — **PENDING STEVE APPROVAL, do NOT execute**
+
+Current: `nationalpaperhangers.com` → GoDaddy parking (AdSense-for-Domains).
+Target: Cloudflare zone → A-record → Kamatera (`45.61.58.125`) → nginx → pm2 `national-paper-hangers`.
+
+### 3.1 Pre-checks
+- ✅ Domain protected-list verified — `nationalpaperhangers.com` NOT in DNS do-not-touch (memory `feedback_dns_donotouch.md`).
+- ⚠️ Cloudflare token gotcha — current CF token is **DNS-edit only**. Zone-create needs a fresh token with `Zone:Edit` permission. Memory: `feedback_cf_token_leaked.md` (rotate + scrub if zone-create needed).
+
+### 3.2 Steps (when approved)
+```
+/domain-setup nationalpaperhangers.com
+```
+The skill drives: CF zone create → NS swap at GoDaddy → stale-record cleanup → certbot LE cert on Kamatera → CF proxy enable.
+
+### 3.3 Verisign verify
+After NS swap, confirm propagation via the source of truth:
+```
+whois -h whois.verisign-grs.com nationalpaperhangers.com
+```
+(MCP `check_availability` and GoDaddy FAST give false positives — memory `feedback_verify_domain_availability.md`.)
+
+---
+
+## 4. Email (`info@nationalpaperhangers.com`)
+
+Steve's standing rule: every site gets `info@<domain>` provisioned at build
+time and shown in footer + JSON-LD (memory `feedback_every_site_info_email.md`).
+
+### 4.1 MX provider
+Pick one:
+- **Purelymail** (preferred, cheap) — use the `purelymail` MCP / skill to add domain + create `info` user
+- **Google Workspace** — manual setup at admin.google.com
+
+### 4.2 SPF + DKIM + DMARC
+Use the `domain-suite` MCP:
+```
+mcp__domain-suite__setup_spf domain=nationalpaperhangers.com provider=purelymail
+mcp__domain-suite__setup_dkim domain=nationalpaperhangers.com provider=purelymail
+mcp__domain-suite__setup_dmarc domain=nationalpaperhangers.com policy=quarantine
+```
+
+### 4.3 George registration
+Add the new info account to George (Gmail relay), so booking-confirmation
+emails go out as `info@nationalpaperhangers.com` not `…@gmail.com`. Update
+`.env`: `EMAIL_FROM=info@nationalpaperhangers.com`, `GEORGE_ACCOUNT=info`.
+
+---
+
+## 5. Deploy
+
+`DEPLOY_KAMATERA.md` (already in repo, 777-line runbook) is the source of
+truth. Steps A–J. Headline gates:
+- `ecosystem.kamatera.config.js` — 400M cap, fork mode, env_production, distinct PM2 dump filename so Mac2's dump isn't disturbed
+- `scripts/deploy-kamatera.sh` — DRY-RUN by default; pass `--commit` to live-fire; refuses to touch any nginx config containing a protected-DNS host
+
+Steps A–G can complete and validate via `curl http://45.61.58.125:9765/`
+WITHOUT touching DNS — that gives Steve a chance to click around the live
+copy before the public NS swap.
+
+---
+
+## 6. Smoke-test commands
+
+After each phase, run:
+
+```bash
+# Public surface
+for r in / /find /map /healthz /sitemap.xml /installer/atelier-bond-nyc /installer/atelier-bond-nyc/book; do
+ printf "%-45s " "$r"
+ curl -s -o /dev/null -w "%{http_code}\n" https://nationalpaperhangers.com$r
+done
+
+# Map data
+curl -s https://nationalpaperhangers.com/api/installers.geo | python3 -c \
+ "import json,sys; d=json.load(sys.stdin); print('pinned:', d['count'])"
+# Expect ≥420 pinned (current local state).
+
+# Rate limit fires after 60 hits
+for i in $(seq 1 70); do
+ curl -s -o /dev/null -w "%{http_code} " https://nationalpaperhangers.com/api/installers.geo
+done | awk '{c=0; for(i=1;i<=NF;i++){if($i==429)c++}; printf "\n429 count: %d\n", c}'
+# Expect c >= 1.
+
+# Auth gate
+curl -s -o /dev/null -w "/admin → %{http_code}\n" https://nationalpaperhangers.com/admin
+# Expect 302 to /login.
+
+# Sitemap
+curl -s https://nationalpaperhangers.com/sitemap.xml | grep -c '<url>'
+# Expect ≥528 (5 static + 524 installer pages).
+
+# In-repo
+cd ~/Projects/NationalPaperHangers
+npm run go-live-check # 0 missing required-for-live
+npm test # 21/21 passing
+```
+
+---
+
+## 7. Post-launch checklist (T+0 → T+72h)
+
+- [ ] Open `/admin` as the first claimed installer, run `/admin/connect/onboard`, complete Stripe Express onboarding, confirm `account.updated` webhook arrives and flips `stripe_account_charges_enabled` to true.
+- [ ] Make a real test booking against a paid+active studio. Confirm:
+ - [ ] `/api/installers/:slug/book` returns `deposit.client_secret`
+ - [ ] Stripe Elements card field accepts `4242 4242 4242 4242`
+ - [ ] `payment_intent.succeeded` webhook fires
+ - [ ] `bookings.deposit_status` flips to `paid`, `status` to `confirmed`, `confirmed_at` set
+ - [ ] Stripe dashboard shows `application_fee_amount` retained on the platform side, balance transferred to installer's Connect account
+- [ ] Repeat for 5 additional installers spread across tier=pro / signature / enterprise.
+- [ ] Confirm GA4 events firing in real-time view (`G-1WZ49HYY39`):
+ - [ ] `booking_started` on /book page load
+ - [ ] `booking_confirmed` post-payment redirect
+ - [ ] `claim_completed` on claim flow finish
+- [ ] Verify booking-confirmation emails arrive at customer inbox (Gmail spam check) AND installer inbox (Pro+ tier).
+- [ ] Hit `/sitemap.xml` from search-console-style crawler; submit to Google Search Console + Bing Webmaster Tools.
+- [ ] Set up Stripe Connect dashboard alert: any failed `payment_intent` over $200.
+- [ ] Confirm CAN-SPAM `MAILING_ADDRESS` env is set BEFORE any list-send (the pre-flight gate refuses without it).
+
+---
+
+## 8. Rollback plan
+
+Severity tiers — pick the lightest action that works.
+
+### 8.1 App-only regression (UI bug, bad query, etc.)
+```bash
+ssh root@45.61.58.125
+pm2 restart national-paper-hangers --update-env
+pm2 logs national-paper-hangers --lines 100
+```
+If still broken:
+```bash
+cd /var/www/national-paper-hangers
+git log --oneline -5
+git checkout <last-known-good-sha>
+pm2 restart national-paper-hangers --update-env
+```
+
+### 8.2 Database migration breakage
+Migrations are forward-only. If an ALTER breaks reads, write a 011_revert
+SQL that reverts the offending change. **Do not** drop columns that have
+data in them — use `RENAME` to a `_dead_` suffix.
+
+### 8.3 nginx routing breakage
+```bash
+cd /etc/nginx/sites-available
+ls -la nationalpaperhangers.com.conf* # snapshots are timestamped via deploy script
+sudo cp nationalpaperhangers.com.conf.<timestamp>.bak nationalpaperhangers.com.conf
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+### 8.4 DNS rollback
+Cloudflare dashboard → Zone → DNS → revert A record OR pause CF proxy
+(orange cloud → grey cloud) so traffic bypasses CF and hits Kamatera direct.
+Last resort: NS swap back at GoDaddy (TTL ~24h to fully propagate).
+
+### 8.5 Stripe webhook disable
+Stripe Dashboard → Developers → Webhooks → toggle endpoint to disabled.
+The app fails-closed (503) on unsigned events when `STRIPE_DEV_ACCEPT_UNSIGNED`
+is unset, which is the desired posture in prod.
+
+---
+
+## 9. Status (as of 2026-05-06)
+
+- Mac2 :9765 — full marketplace MVP, 21/21 tests, mock-mode-safe.
+- 524 installers seeded; 420 geocoded + map-pinned.
+- 85 PD/CC0 images rolled across cards, map popups, installer hero.
+- Stripe Connect deposit flow + webhooks + admin dashboard wired.
+- Public DNS still parked on GoDaddy. **Awaiting Steve's go to swap.**
+
+When you're ready: `/domain-setup nationalpaperhangers.com` is the single
+command that starts the cutover. Everything else is ready.
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 0000000..a4b4ae9
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,139 @@
+# Handoff — NationalPaperHangers.com
+
+**Built:** 2026-05-05 (Opus 4.7 session, paused at 93% weekly cap)
+**Status:** scaffold complete. Boots, but has not yet been smoke-tested live.
+**Next operator:** local Ollama is fine for the smoke-test loop below.
+
+## What you're picking up
+
+A working luxury wallcovering installer marketplace + scheduling app at `~/Projects/NationalPaperHangers/`. Installers pay (Stripe subscription, mock-mode until the key lands) for a calendar that lets consumers self-book installs. Schema, server, routes, views, CSS, JS all written. Stripe runs in mock mode (toggles tier locally on checkout). Email runs through George with auto-fallback to console logs.
+
+## Smoke-test (run these in order)
+
+```bash
+cd ~/Projects/NationalPaperHangers
+cp .env.example .env
+
+# 1. Install
+npm install
+
+# 2. Database
+createdb national_paper_hangers
+psql national_paper_hangers < db/schema.sql
+psql national_paper_hangers < db/seed.sql
+
+# 3. (Optional) Regenerate the demo password hash. The seed file ships with
+# a placeholder bcrypt hash. If login fails on demo accounts, run:
+node -e "console.log(require('bcrypt').hashSync('demo1234', 10))"
+# then UPDATE installers SET password_hash='<new hash>' WHERE email LIKE 'demo+%';
+
+# 4. Boot
+npm run dev
+# → http://localhost:9765
+```
+
+## Manual test plan
+
+Public side:
+- [ ] `/` — homepage renders, featured grid populated from seed
+- [ ] `/find` — grid-search works (try `?material=hand_painted`, `?segment=hospitality`, `?state=CA`)
+- [ ] `/installer/atelier-bond-nyc` — profile + portfolio + sidebar render
+- [ ] `/installer/paperworks-collective-la/book` — calendar loads, slot click selects, form submit creates booking → redirects to `/bookings/:uuid`
+- [ ] Dark/light toggle in header (☼/☾) toggles + persists across reloads
+
+Auth:
+- [ ] `/signup` — creates new installer at tier=basic, status=pending
+- [ ] `/login` with seed `demo+bond@example.com` / `demo1234` (after fixing hash above)
+- [ ] `/admin` — dashboard shows stats + welcome callout
+
+Admin:
+- [ ] `/admin/calendar` — gated on paid tier; for seed Signature accounts (Bond, Paperworks) it loads
+- [ ] Add an availability window; reload; persists
+- [ ] Add a time-off block; reload; persists
+- [ ] `/admin/bookings` — confirmed seed booking shows for Paperworks
+- [ ] `/admin/profile` — edit form saves
+- [ ] `/admin/billing` — mock checkout flips tier locally
+
+Slot calculator:
+- [ ] After Bond's seeded time-off (days 14–17), `/api/installers/atelier-bond-nyc/slots?from=...&to=...` returns no slots in that window
+- [ ] After Paperworks' seeded confirmed booking (~7 days out, 10–11am PT), the matching slot is excluded
+
+## Bootstrap data (option C — scrape-lite + self-claim)
+
+Per `DATA_POLICY.md` v1.0:
+- Scraper at `scripts/scrape-wia.js` collects ONLY: business name, city/state/country, public website, public bio, accreditations, IG handle. **Never** phone/email/street address/owner names.
+- Records land with `claim_status='unclaimed'`, `status='pending'`. Visible on `/find` with an "Unclaimed listing" badge + "Claim this listing" CTA.
+- Studios self-claim via `/installer/:slug/claim` — must use email on the studio's own website domain. One-shot 24h token. Verified-email → `/claim/complete` → set password → `claim_status='claimed'`, redirect to `/admin`.
+- 3 demo unclaimed listings already in the DB to exercise the UI: bromfield-walls (Boston), marquee-finishes (Atlanta), ridgeway-paper (Seattle).
+
+**To populate real data:**
+1. Open `https://www.wallcoveringinstallers.org/` in a browser
+2. Use the public locator, copy WIA profile URLs into `scripts/wia-profile-urls.txt`
+3. Dry-run: `node scripts/scrape-wia.js`
+4. Commit: `node scripts/scrape-wia.js --commit --enrich-ig --max=50`
+5. Crawl is rate-limited (3s/req, 200/day cap, robots.txt honored, audited in `scrape_log` table)
+
+## Outstanding work (priority order)
+
+1. **DNS for nationalpaperhangers.com** — Cloudflare zone, A → Kamatera, LE SSL, info@ MX/SPF/DKIM/DMARC. Use the `domain-name-agent` subagent or `/domain-setup` skill (Phase A).
+2. **Stripe live keys** — paste into the conversation; the global standing rule will route them through `secrets-manager` to `~/Projects/NationalPaperHangers/.env`. Then create products/prices in Stripe and set `STRIPE_PRICE_PRO_MONTH` etc.
+3. **George email integration** — verify `info@nationalpaperhangers.com` is configured in George (account=info). Already wired in `lib/email.js`.
+4. **3-agent QA loop** — per `feedback_test_every_site_completion.md`: click-through QA agent + graphic-design agent + UX critic before declaring "done".
+5. **CAN-SPAM + DNC compliance gate** — before any installer or consumer email blast, run through `comms-compliance` subagent.
+6. **Image assets** — portfolio currently uses CSS placeholders (brand initials in serif). Replace with real photos OR stick with placeholders forever (no stock images per `feedback_no_stock_images.md`).
+7. **Verification workflow UI** — admin upload form for COI / W-9 / license docs. Schema ready (`insurance_on_file`, `insurance_expires`, `license_*`); UI not yet built.
+8. **pm2 + launchd** — `pm2 start ecosystem.config.js && pm2 save`. Add a launchd watchdog `com.steve.nph-hawk` mirroring the pd-hawk pattern.
+9. **Production .env** — set `NODE_ENV=production`, real `SESSION_SECRET` (32+ bytes), real `PUBLIC_URL=https://nationalpaperhangers.com`.
+10. **Reviews moderation queue** — schema exists (`installer_reviews.published`); admin UI to publish/reject is not built.
+
+## Architecture quick-reference
+
+| File | Role |
+|---|---|
+| `PROCESS.md` | Build plan + standing-rule compliance |
+| `db/schema.sql` | 10 tables incl. `installers`, `installer_availability`, `installer_time_off`, `bookings`, `installer_reviews`, `consumer_leads`, `lead_offers`, `subscription_events`, `installer_portfolio`, `session` |
+| `db/seed.sql` | 5 installers, weekly availability, 1 time-off block, 1 confirmed booking |
+| `server.js` | Express + sessions on Postgres + EJS + static |
+| `lib/db.js` | pg pool |
+| `lib/auth.js` | bcrypt + `requireInstaller` + `requirePaidTier` middleware |
+| `lib/slots.js` | Available-slot calculator (luxon, intersect availability − time-off − bookings) |
+| `lib/email.js` | George Gmail client + booking templates |
+| `lib/stripe.js` | Subscription helpers; mocks gracefully when key missing |
+| `routes/public.js` | / · /find · /installer/:slug · /installer/:slug/book · /bookings/:uuid · /about · /for-installers · /privacy · /terms |
+| `routes/auth.js` | /login · /signup · /logout |
+| `routes/admin.js` | /admin/* (gated) — dashboard, calendar, bookings, profile, billing |
+| `routes/api.js` | /api/installers/:slug/slots · /api/installers/:slug/book · /api/admin/availability · /api/admin/time-off · /api/admin/bookings.json |
+| `routes/webhooks.js` | /webhooks/stripe |
+| `views/` | EJS templates, organized by section, with `partials/head` `partials/header` `partials/footer` `admin/partials/admin-header` |
+| `public/css/theme.css` | CSS-vars, dark/light, type system |
+| `public/css/public.css` | Marketplace pages |
+| `public/css/admin.css` | Dashboard pages |
+| `public/js/theme-toggle.js` | Light/dark toggle |
+| `public/js/calendar-consumer.js` | Slot picker on /book |
+| `public/js/calendar-installer.js` | Availability + time-off + upcoming on /admin/calendar |
+
+## Standing-rule compliance status
+
+| Rule | Status |
+|---|---|
+| Local install (`~/Projects/`) | ✅ |
+| Process markdown first | ✅ `PROCESS.md` |
+| Dark/light toggle | ✅ |
+| Grid-search | ✅ `/find` |
+| Own info@ email | ⚠ Code wired; DNS + George config not yet provisioned |
+| Standalone PG | ✅ `national_paper_hangers` (NOT in dw_unified) |
+| Default .com | ✅ |
+| pm2 + launchd | ⚠ ecosystem.config.js written; not yet started |
+| No DNS touch on protected list | ✅ |
+| 3-agent QA loop before "done" | ⏳ pending phase 1 |
+| CAN-SPAM + DNC compliance | ⏳ pending phase 1 |
+| No stock images | ✅ Placeholder initials only |
+
+## Known gaps
+
+- **No tests written.** Add at least a slot-calculator unit test before launch.
+- **No rate-limiting on public booking API.** Add `express-rate-limit` for `/api/installers/:slug/book` before going live.
+- **Booking does not yet handle multi-day installs.** Schema supports it (`scheduled_start`/`scheduled_end` are timestamptz), but the consumer UI only lets you pick a single 60-min slot.
+- **No installer-side reschedule UI.** Confirm/decline/complete only.
+- **No consumer-side cancel link.** The booking detail page has copy directing them to email; build a self-serve cancel link before launch.
+- **No verification ops queue.** New signups land at status=pending and need a manual UPDATE to go active.
diff --git a/PROCESS.md b/PROCESS.md
new file mode 100644
index 0000000..b7e2513
--- /dev/null
+++ b/PROCESS.md
@@ -0,0 +1,121 @@
+# NationalPaperHangers.com — Build Process
+
+**Status:** scaffolding (2026-05-05)
+**Owner:** Steve Abrams
+**Domain:** nationalpaperhangers.com (DW already references it for non-LA installation referrals)
+**Local path:** `~/Projects/NationalPaperHangers/`
+**Port:** 9765 (main Express app — 9760 was already in use by smb-builder)
+**Database:** `national_paper_hangers` (standalone PG, NOT in dw_unified)
+**Email:** info@nationalpaperhangers.com via George (`http://localhost:9850`, account=info)
+
+## What this is
+
+A luxury wallcovering installer marketplace + scheduling platform. Two product surfaces:
+
+1. **Public marketplace** — find / vet / book a verified installer for a wallcovering project
+2. **Installer admin** — paid subscription dashboard where installers manage their profile, calendar, availability, and inbound bookings
+
+The scheduling system is the monetization wedge: installers PAY (recurring subscription) for the calendar tool that lets consumers self-book installs into open slots.
+
+## Architecture
+
+```
+Consumer → public site → installer profile → /book → calendar slot picker → booking confirmed
+ ↓
+ Installer admin (paid) ← email + dashboard
+```
+
+Stack:
+- Node.js + Express (consistent with DW family)
+- PostgreSQL (standalone DB, schema versioned in `db/schema.sql`)
+- EJS templates + vanilla JS (no React for MVP)
+- bcrypt + express-session for installer auth
+- Stripe Checkout for subscription billing (stub until key arrives)
+- George Gmail agent for transactional email (`/api/send` w/ `?account=info`)
+- pm2 process management (`national-paper-hangers`)
+- Domain-suite MCP for nationalpaperhangers.com DNS (Cloudflare proxy + LE SSL)
+
+## Build phases
+
+### Phase 0 — foundation (this session)
+- [x] mkdir
+- [x] PROCESS.md (this file)
+- [x] README.md, package.json, .env.example, .gitignore
+- [x] db/schema.sql — full schema
+- [x] server.js + lib/* + middleware
+- [x] Public routes (home, find, profile)
+- [x] Auth routes (signup, login, logout)
+- [x] Admin routes (dashboard, calendar, profile, bookings, billing)
+- [x] Booking API (slot calculator, create booking, cancel booking)
+- [x] Views (luxury editorial templates)
+- [x] CSS (black + ivory + oxblood + brass; dark/light toggle)
+- [x] JS (calendar UIs)
+
+### Phase 1 — wire up real services
+- [ ] Provision PG database, run schema
+- [ ] Stripe key in via `secrets` skill, wire live billing
+- [ ] George email integration (already running, just point env)
+- [ ] DNS for nationalpaperhangers.com (Cloudflare zone, LE SSL on Kamatera)
+- [ ] Seed 5–10 installer profiles (verified, real-data style)
+
+### Phase 2 — verification + trust
+- [ ] COI / insurance upload flow
+- [ ] Manual review queue for new installers
+- [ ] Verified badge system w/ expiration tracking
+- [ ] Code-of-conduct gate at signup
+
+### Phase 3 — concierge intake
+- [ ] Architect / hospitality brief wizard
+- [ ] Staff shortlist tool
+- [ ] Multi-installer quote comparison
+
+### Phase 4 — payments + escrow
+- [ ] Consumer deposit collection
+- [ ] Milestone invoicing
+- [ ] Payout splits to installers
+- [ ] Refund / dispute workflows
+
+### Phase 5 — growth
+- [ ] Programmatic city pages (long-tail SEO)
+- [ ] Material-specific landing pages
+- [ ] Manufacturer / showroom co-branded portals
+- [ ] Enterprise account roles (design firms, hospitality groups)
+
+## Pricing (initial)
+
+| Tier | Price | Includes |
+|---|---|---|
+| Basic listing | Free / invite-only | Profile only, no inbound leads, no calendar |
+| Pro installer | $39 / mo or $399 / yr | Full profile, calendar, lead receipt, messaging |
+| Signature installer | $149 / mo or $1,500 / yr | Pro + premium placement + concierge leads + portfolio features |
+| Enterprise installer | $399 / mo+ | Team seats, territory controls, API/CRM export |
+| Concierge match fee | 5–8 % of booked labor | Curated shortlist + staff oversight |
+
+## Standing-rule compliance checklist
+
+- [x] Local install → `~/Projects/NationalPaperHangers/` (rule: feedback_projects_home.md)
+- [x] Process markdown written first (rule: feedback_suggest_process_md.md)
+- [x] Dark/light toggle planned (rule: feedback_dark_light_toggle_standard.md)
+- [x] Grid-search on find page (rule: feedback_every_site_grid_search.md)
+- [x] Own info@ email (rule: feedback_every_site_info_email.md)
+- [x] Default .com (rule: feedback_default_tld_com.md)
+- [x] Standalone PG, not in dw_unified (NOT a DW micro-site, rule: feedback_dw_microsite_strict_scope.md)
+- [x] Default launchd / pm2, not cloud (rule: feedback_no_cloud_agents.md)
+- [x] No DNS touch on protected list (designerwallcoverings.com etc., rule: feedback_dns_donotouch.md)
+- [ ] 3-agent QA loop before declaring "done" (rule: feedback_test_every_site_completion.md) — phase 1 gate
+- [ ] CAN-SPAM + DNC compliance for outbound (rule: project_comms_compliance_agent.md) — phase 1 gate
+- [ ] info@nationalpaperhangers.com MX/SPF/DKIM/DMARC live before launch (rule: feedback_every_site_info_email.md) — phase 1 gate
+
+## Open decisions
+
+- Whether to surface a "find installers near you" map at launch or wait for real density (currently: search by zip + service-area radius, no map)
+- Whether to require COI upload at free-tier signup or only at paid-tier upgrade (currently: paid-tier only)
+- Whether to allow consumer self-booking for first contact, or force a brief-form intake first (currently: brief form, then installer-controlled slot offer)
+
+## Reference benchmarks (from research brief)
+
+- **wallpaperinstaller.com** — legacy directory, $25–45/yr, no transaction layer, big SEO footprint
+- **wallcoveringinstallers.org (WIA)** — $350/yr installer membership, accredited training, strong trust signal, no booking
+- **Designer Wallcoverings** — luxury demand engine, currently delegates non-LA installs to NPH. NPH must convert that trade demand into governed, scheduled installs.
+
+The competitive wedge: **NONE of them have a real calendar / booking system.** That is what we are building.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8c8b5b5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# NationalPaperHangers.com
+
+Luxury wallcovering installer marketplace + scheduling platform.
+
+## Quick start
+
+```bash
+cd ~/Projects/NationalPaperHangers
+cp .env.example .env # edit values
+npm install
+createdb national_paper_hangers
+psql national_paper_hangers < db/schema.sql
+psql national_paper_hangers < db/seed.sql
+npm run dev # nodemon on :9765
+# or
+pm2 start ecosystem.config.js
+```
+
+Visit http://localhost:9765
+
+## What it does
+
+**For consumers:**
+- Search for verified luxury wallcovering installers by zip / city / specialty
+- View rich installer profiles (portfolio, accreditations, materials handled, response SLA)
+- Book a consultation or install directly into the installer's calendar
+
+**For installers (paid):**
+- Manage profile + portfolio + service areas
+- Set weekly recurring availability + block time off
+- Receive bookings + manage them in a calendar dashboard
+- Subscribe via Stripe ($39/mo Pro, $149/mo Signature, $399/mo Enterprise)
+
+## File layout
+
+| Path | Purpose |
+|---|---|
+| `PROCESS.md` | Build plan + rule compliance |
+| `server.js` | Express entry point |
+| `db/schema.sql` | Full database schema (tables, indexes) |
+| `db/seed.sql` | Sample installer + bookings |
+| `lib/db.js` | pg pool |
+| `lib/auth.js` | Session / bcrypt helpers |
+| `lib/slots.js` | Available-slot calculator |
+| `lib/email.js` | George Gmail outbound |
+| `lib/stripe.js` | Subscription helpers |
+| `routes/public.js` | / · /find · /installer/:slug · /book |
+| `routes/auth.js` | /signup · /login · /logout |
+| `routes/admin.js` | /admin/* (gated) |
+| `routes/api.js` | JSON API for calendar / bookings |
+| `routes/webhooks.js` | Stripe webhook |
+| `views/` | EJS templates |
+| `public/` | Static CSS/JS |
+
+## Standing rules honored
+
+- Local-first (`~/Projects/`)
+- Standalone PG (NOT in `dw_unified`)
+- Dark/light toggle
+- Grid-search on `/find`
+- Own info@ email
+- pm2 + launchd watchdog (no cloud)
+- No DNS touch on protected list
+
+## Status
+
+Phase 0: scaffold complete. Phase 1 (wire DNS + Stripe + George + seed real installers) pending.
diff --git a/UX_CREATIVE_BACKLOG.md b/UX_CREATIVE_BACKLOG.md
new file mode 100644
index 0000000..b86dad3
--- /dev/null
+++ b/UX_CREATIVE_BACKLOG.md
@@ -0,0 +1,140 @@
+# Unique UX backlog — National Paper Hangers
+
+Brainstormed 2026-05-05 against the brief: ideas a luxury wallcovering trade
+audience would notice that NO generic installer directory (Houzz, Thumbtack,
+Angi, Yelp) has. Bias toward craft-specific signals.
+
+**Already shipped this session:**
+2 of these landed today — see "In flight / shipped" below.
+
+---
+
+## 1. "In the Seams" portfolio — detail-shot tabs
+
+**Problem:** Generic portfolios show hero shots. In wallcovering, the *seam,
+corner, and ceiling-line* are where craft is visible. A designer scanning a
+silk install judges the studio in 5 seconds by whether the seam ghost is
+controlled.
+
+**MVP:** Add `detail_seam_url`, `detail_corner_url`, `detail_ceiling_url`
+columns to `installer_portfolio`. On the public profile portfolio gallery,
+each card has 4 tabs: "Big picture / In the seams / In the corners / At the
+ceiling." Empty tabs hide gracefully. Admin profile gets matching upload
+fields.
+
+**Why no one has this:** Houzz/Angi optimize for hero-image scroll, not for
+trade-buyer scrutiny. This explicitly invites scrutiny — luxury trade rewards
+that.
+
+---
+
+## 2. Brand-Trained badges with verification dropdown
+
+**Problem:** "Verified" is meaningless to a designer specifying a $4K/roll
+de Gournay. They want to know: "has this installer been TRAINED by de Gournay?"
+
+**MVP:** Replace `accreditations TEXT[]` UI with a structured
+`installer_credentials(installer_id, brand, year_issued, year_expires,
+certificate_scan_url)` table. Each badge on the profile is clickable; opens a
+small modal with cert scan + dates. Inline upload in admin profile gates
+behind ops review for the "Brand-Trained" claim (high signal, high fraud
+incentive).
+
+**Why no one has this:** Generic directories handle accreditations as flat
+text. Making them structured + verifiable + clickable is a specifically
+luxury-trade move.
+
+---
+
+## 3. "This Paper" peer-installer commentary
+
+**Problem:** Wallcovering knowledge lives in the heads of 3,000 working
+installers globally. Most papers (de Gournay Earlham, Fromental Bois,
+Phillip Jeffries Manila Hemp) have install quirks (drop %, paste type,
+soak time, seam matching). That tribal knowledge is invisible online.
+
+**MVP:** New tables `paper_threads(slug, brand, paper_name, sku)` and
+`paper_comments(thread_id, user_id, body, helpful_count)`. Thread pages are
+public; comments are read-only for non-installers (verified
+`installer_members.role IN ('owner','member')`). On installer profiles, show
+a "12 contributions to paper threads" stat. Designers see which installers
+are knowledge contributors — strong selection signal.
+
+**Why no one has this:** Trade communities exist on private Slacks or IG
+DMs. Making it the public-facing infrastructure of the directory captures
+the entire surface.
+
+---
+
+## 4. Equipment Fleet card *(SHIPPED 2026-05-05)*
+
+**Problem:** Most directories ask "what kind of work do you do?" — wallcovering
+designers actually need to know "can you reach my 22ft entryway?" Equipment
+inventory is decisive for high-end residential and hospitality.
+
+**MVP:** `installers.equipment JSONB` with `max_reach_ft`, `lift_type`
+(extension_ladder | scaffold | scissor_lift | boom_lift), `paper_table`
+(none | folding | dedicated 60" | dedicated 72"+), `dust_extraction`,
+`vehicle` (van | truck | trailer-equipped). Public profile shows a structured
+"Studio Capacity" block. Admin profile has a structured editor.
+
+**Why no one has this:** Contractor directories optimize for "I have a
+truck." Wallcovering studios distinguish themselves by ladder height + table
+length; surfacing it is the differentiator.
+
+---
+
+## 5. Live COI download (designer-addressed PDF)
+
+**Problem:** In luxury commercial + hospitality, the design firm's primary
+insurance requires the installer to be named as additional-insured on a
+fresh COI for every job. Today this is a 3-day phone-tag exercise; the
+designer has to call the installer, who calls their broker, who emails a PDF.
+
+**MVP:** Installer uploads insurance metadata once (carrier, policy number,
+limits, expiry). Designers click "Request COI" on profile, fill name +
+address + project, and get a PDF generated on-demand with the right party
+named additional-insured. Either auto-generated from a stored template or
+auto-emailed to the broker for sign-off.
+
+**Why no one has this:** Insurance is treated as a yes/no signal everywhere
+else. Making the COI itself the deliverable is a category move.
+
+---
+
+## 6. Structured booking brief replacing the textarea *(parts in /book already)*
+
+**Problem:** /book asks "tell us about your project" — useless. The studio
+arrives without the right tools and bills hourly to figure it out.
+
+**MVP:** Replace the existing single textarea on /book with a structured
+brief: paper brand+SKU (autocomplete from a curated list), room dimensions,
+ceiling height, surface state (new plaster / painted / wallpaper-to-remove),
+known issues (electrical box near focal wall, plumbing knockout). The
+installer arrives prepped. **Partially implemented** — current /book has
+`material`, `square_feet`, `surfaces` text fields. Upgrade them to structured
+inputs and add the missing categories.
+
+**Why no one has this:** Generic directories optimize for low form friction.
+Luxury trade rewards depth — a 12-field structured brief signals competence
+on both sides.
+
+---
+
+## 7. "Acceptance rate" pride badge *(SHIPPED 2026-05-05)*
+
+**Problem:** A studio booked solid enough to be selective is the studio you
+want. Generic directories celebrate hustle ("Available now!"). Luxury trade
+celebrates selectivity.
+
+**MVP:** Compute over rolling 365d:
+`accepted = count(bookings WHERE status='confirmed' OR status='completed')`,
+`declined = count(bookings WHERE status='declined')`, `total = accepted +
+declined`, `acceptance_rate = accepted / total`. Display on profile as
+"Accepts X% of inquiries — Y bookings booked, Z declined" only when total
+≥ 10 (else hide for new studios). Becomes a positive selectivity signal,
+not a negative one.
+
+**Why no one has this:** Most platforms hide rejection rates because they
+read as bad. In luxury trade, a 60% acceptance rate is a status signal —
+the studio is busy and choosy.
diff --git a/data/geo-cache.json b/data/geo-cache.json
new file mode 100644
index 0000000..4a95195
--- /dev/null
+++ b/data/geo-cache.json
@@ -0,0 +1,1424 @@
+{
+ "New York|NY": {
+ "lat": 40.7127281,
+ "lng": -74.0060152
+ },
+ "Los Angeles|CA": {
+ "lat": 34.0536909,
+ "lng": -118.242766
+ },
+ "Miami|FL": {
+ "lat": 25.7741566,
+ "lng": -80.1935973
+ },
+ "Chicago|IL": {
+ "lat": 41.8755616,
+ "lng": -87.6244212
+ },
+ "Dallas|TX": {
+ "lat": 32.7762719,
+ "lng": -96.7968559
+ },
+ "Boston|MA": {
+ "lat": 42.3588336,
+ "lng": -71.0578303
+ },
+ "Atlanta|GA": {
+ "lat": 33.7544657,
+ "lng": -84.3898151
+ },
+ "Seattle|WA": {
+ "lat": 47.6038321,
+ "lng": -122.330062
+ },
+ "Belmont|NC": {
+ "lat": 35.2438272,
+ "lng": -81.0377322
+ },
+ "Eastvale|CA": {
+ "lat": 33.9747067,
+ "lng": -117.566541
+ },
+ "West Palm Beach|FL": {
+ "lat": 26.715364,
+ "lng": -80.0532942
+ },
+ "Keller|TX": {
+ "lat": 32.9299655,
+ "lng": -97.2271249
+ },
+ "Clifton Heights|PA": {
+ "lat": 39.9292791,
+ "lng": -75.2962972
+ },
+ "Providence|RI": {
+ "lat": 41.8239891,
+ "lng": -71.4128343
+ },
+ "Farmington|CT": {
+ "lat": 41.7198216,
+ "lng": -72.8320435
+ },
+ "Dedham|MA": {
+ "lat": 42.2489143,
+ "lng": -71.1755732
+ },
+ "Cary|NC": {
+ "lat": 35.7882893,
+ "lng": -78.7812081
+ },
+ "Arden|NC": {
+ "lat": 35.4661821,
+ "lng": -82.5164242
+ },
+ "Concord|OH": {
+ "lat": 39.0220092,
+ "lng": -84.0802113
+ },
+ "Pelham|NH": {
+ "lat": 42.7340023,
+ "lng": -71.3232374
+ },
+ "Pasadena|MD": {
+ "lat": 39.1361859,
+ "lng": -76.5490844
+ },
+ "Walpole|MA": {
+ "lat": 42.1459019,
+ "lng": -71.2538759
+ },
+ "Stratham|NH": {
+ "lat": 43.0244129,
+ "lng": -70.9122808
+ },
+ "Watkinsville|GA": {
+ "lat": 33.8628959,
+ "lng": -83.4087709
+ },
+ "Mount Airy|NC": {
+ "lat": 36.4993877,
+ "lng": -80.6072138
+ },
+ "Kingsville|MD": {
+ "lat": 39.4487918,
+ "lng": -76.417968
+ },
+ "Douglassville|PA": {
+ "lat": 40.2578714,
+ "lng": -75.7263008
+ },
+ "Statham|GA": {
+ "lat": 33.9654884,
+ "lng": -83.5964913
+ },
+ "Brooklyn|NY": {
+ "lat": 40.6526006,
+ "lng": -73.9497211
+ },
+ "Greensboro|NC": {
+ "lat": 36.0726355,
+ "lng": -79.7919754
+ },
+ "Lakewood|OH": {
+ "lat": 41.4819932,
+ "lng": -81.7981908
+ },
+ "Havertown|PA": {
+ "lat": 39.9809452,
+ "lng": -75.3085201
+ },
+ "Charlotte|NC": {
+ "lat": 35.2272086,
+ "lng": -80.8430827
+ },
+ "Lee|MA": {
+ "lat": 42.3073477,
+ "lng": -73.2504911
+ },
+ "Glenside|PA": {
+ "lat": 40.1001891,
+ "lng": -75.1539056
+ },
+ "Troutville|VA": {
+ "lat": 37.4181901,
+ "lng": -79.8747687
+ },
+ "West Chester|PA": {
+ "lat": 39.9597389,
+ "lng": -75.6044609
+ },
+ "New Orleans|LA": {
+ "lat": 29.9561422,
+ "lng": -90.0733934
+ },
+ "Newbury|MA": {
+ "lat": 42.7664975,
+ "lng": -70.8458022
+ },
+ "Smithsburg|MD": {
+ "lat": 39.6548186,
+ "lng": -77.5727681
+ },
+ "Buxton|ME": {
+ "lat": 43.612404,
+ "lng": -70.540878
+ },
+ "Pittsfield|MA": {
+ "lat": 42.4484778,
+ "lng": -73.2541069
+ },
+ "Virginia Beach|VA": {
+ "lat": 36.8496579,
+ "lng": -75.9760751
+ },
+ "Stamford|CT": {
+ "lat": 41.0534302,
+ "lng": -73.5387341
+ },
+ "Woodland Park|NJ": {
+ "lat": 40.8893288,
+ "lng": -74.1969657
+ },
+ "Spring City|PA": {
+ "lat": 40.176767,
+ "lng": -75.5476803
+ },
+ "Nashville|TN": {
+ "lat": 36.1622767,
+ "lng": -86.7742984
+ },
+ "Wyndmoor|PA": {
+ "lat": 40.0812219,
+ "lng": -75.1893436
+ },
+ "Hayes|VA": {
+ "lat": 37.2781992,
+ "lng": -76.5038395
+ },
+ "Melrose|MA": {
+ "lat": 42.4564323,
+ "lng": -71.064182
+ },
+ "Trumbull|CT": {
+ "lat": 41.2428742,
+ "lng": -73.2006687
+ },
+ "Gaithersburg|MD": {
+ "lat": 39.1399187,
+ "lng": -77.1929215
+ },
+ "Church Hill|TN": {
+ "lat": 36.5223207,
+ "lng": -82.7134921
+ },
+ "Middletown|MD": {
+ "lat": 39.4437567,
+ "lng": -77.5454038
+ },
+ "Philadelphia|PA": {
+ "lat": 39.9527237,
+ "lng": -75.1635262
+ },
+ "Bourne|MA": {
+ "lat": 41.7412166,
+ "lng": -70.5989196
+ },
+ "Wilmington|NC": {
+ "lat": 34.2352853,
+ "lng": -77.9487284
+ },
+ "Hamden|CT": {
+ "lat": 41.3836233,
+ "lng": -72.9020069
+ },
+ "Barnstable|MA": {
+ "lat": 41.6709814,
+ "lng": -70.3592265
+ },
+ "Baltimore|MD": {
+ "lat": 39.2908816,
+ "lng": -76.610759
+ },
+ "Fort Mill|SC": {
+ "lat": 35.0073697,
+ "lng": -80.9450759
+ },
+ "Newton Center|MA": {
+ "lat": 42.3300309,
+ "lng": -71.1948774
+ },
+ "Clarkston|MI": {
+ "lat": 42.735863,
+ "lng": -83.4188304
+ },
+ "Stoughton|MA": {
+ "lat": 42.1252079,
+ "lng": -71.1022015
+ },
+ "Huntersville|NC": {
+ "lat": 35.4108278,
+ "lng": -80.8429304
+ },
+ "Du Bois|PA": {
+ "miss": true
+ },
+ "Kennett Square|PA": {
+ "lat": 39.8467414,
+ "lng": -75.7116997
+ },
+ "Apex|NC": {
+ "lat": 35.7325352,
+ "lng": -78.8505516
+ },
+ "Frankford|DE": {
+ "lat": 38.518038,
+ "lng": -75.233764
+ },
+ "Abington|PA": {
+ "lat": 40.1145785,
+ "lng": -75.1218107
+ },
+ "Palmyra|VA": {
+ "lat": 37.8609747,
+ "lng": -78.2633373
+ },
+ "Purcellville|VA": {
+ "lat": 39.1367039,
+ "lng": -77.714845
+ },
+ "Martinsburg|WV": {
+ "lat": 39.4562528,
+ "lng": -77.9639604
+ },
+ "Chardon|OH": {
+ "lat": 41.5824944,
+ "lng": -81.2034066
+ },
+ "Richmond|VA": {
+ "lat": 37.5385087,
+ "lng": -77.43428
+ },
+ "Lowell|MA": {
+ "lat": 42.6414437,
+ "lng": -71.3085329
+ },
+ "Hoschton|GA": {
+ "lat": 34.096496,
+ "lng": -83.7612839
+ },
+ "Asheville|NC": {
+ "lat": 35.595363,
+ "lng": -82.5508407
+ },
+ "Windsor|PA": {
+ "lat": 39.9156527,
+ "lng": -76.5810226
+ },
+ "Monroe|CT": {
+ "lat": 41.3325962,
+ "lng": -73.2073358
+ },
+ "Leland|NC": {
+ "lat": 34.236434,
+ "lng": -78.0040029
+ },
+ "Manalapan|NJ": {
+ "lat": 40.2852895,
+ "lng": -74.333495
+ },
+ "Westerville|OH": {
+ "lat": 40.126139,
+ "lng": -82.9295287
+ },
+ "Plymouth|MI": {
+ "lat": 42.3712,
+ "lng": -83.4675021
+ },
+ "Hampstead|NC": {
+ "lat": 34.3676686,
+ "lng": -77.7105332
+ },
+ "Chestnut Hill|MA": {
+ "lat": 42.3234735,
+ "lng": -71.1639159
+ },
+ "Union|NJ": {
+ "lat": 40.6964878,
+ "lng": -74.2698106
+ },
+ "Chester|NH": {
+ "lat": 42.9567525,
+ "lng": -71.2572846
+ },
+ "Pineville|NC": {
+ "lat": 35.0855409,
+ "lng": -80.8871253
+ },
+ "Mastic Beach|NY": {
+ "lat": 40.765722,
+ "lng": -72.836722
+ },
+ "Plattsburgh|NY": {
+ "lat": 44.6960855,
+ "lng": -73.4542915
+ },
+ "Monkton|MD": {
+ "lat": 39.5787164,
+ "lng": -76.6155252
+ },
+ "Fairport|NY": {
+ "lat": 43.0993,
+ "lng": -77.443014
+ },
+ "Mechanicsville|VA": {
+ "lat": 37.6039694,
+ "lng": -77.3717321
+ },
+ "Croton on Hudson|NY": {
+ "lat": 41.2088215,
+ "lng": -73.8905175
+ },
+ "Coopersville|MI": {
+ "lat": 43.0639112,
+ "lng": -85.9347667
+ },
+ "Middleburg Heights|OH": {
+ "lat": 41.3614401,
+ "lng": -81.812912
+ },
+ "Windham|CT": {
+ "lat": 41.6993535,
+ "lng": -72.1578243
+ },
+ "Travelers Rest|SC": {
+ "lat": 34.9676167,
+ "lng": -82.4434548
+ },
+ "Adelphi|MD": {
+ "lat": 39.0030666,
+ "lng": -76.9721023
+ },
+ "Canton|MA": {
+ "lat": 42.1584324,
+ "lng": -71.1447732
+ },
+ "Siasconset|MA": {
+ "lat": 41.2626228,
+ "lng": -69.9661245
+ },
+ "Mount Pleasant|SC": {
+ "lat": 32.7940651,
+ "lng": -79.8625851
+ },
+ "Monroe|NC": {
+ "lat": 34.9854275,
+ "lng": -80.5495112
+ },
+ "Lexington|MA": {
+ "lat": 42.4473175,
+ "lng": -71.2245003
+ },
+ "Wallingford|CT": {
+ "lat": 41.4564233,
+ "lng": -72.8239356
+ },
+ "Niantic|CT": {
+ "lat": 41.3246138,
+ "lng": -72.1925952
+ },
+ "Willow Grove|PA": {
+ "lat": 40.1439985,
+ "lng": -75.1157286
+ },
+ "Springfield|MA": {
+ "lat": 42.1018764,
+ "lng": -72.5886727
+ },
+ "East Boston|MA": {
+ "lat": 42.3750973,
+ "lng": -71.0392173
+ },
+ "Cleveland Heights|OH": {
+ "lat": 41.5200518,
+ "lng": -81.556235
+ },
+ "North Charleston|SC": {
+ "lat": 32.8546197,
+ "lng": -79.9748103
+ },
+ "Sirling|NJ": {
+ "miss": true
+ },
+ "North Baldwin|NY": {
+ "lat": 42.1000743,
+ "lng": -76.691895
+ },
+ "Boiling Springs|SC": {
+ "lat": 35.0465081,
+ "lng": -81.9817727
+ },
+ "Rock Hill|SC": {
+ "lat": 34.9248667,
+ "lng": -81.0250784
+ },
+ "Denver|NC": {
+ "lat": 35.5312452,
+ "lng": -81.0297994
+ },
+ "Roslyn|NY": {
+ "lat": 40.7998227,
+ "lng": -73.6509621
+ },
+ "Rockville|MD": {
+ "lat": 39.0817985,
+ "lng": -77.1516844
+ },
+ "Dracut|MA": {
+ "lat": 42.6688173,
+ "lng": -71.3033665
+ },
+ "Rye|NY": {
+ "lat": 40.9821371,
+ "lng": -73.6840902
+ },
+ "Cherry Hill|NJ": {
+ "lat": 39.9348351,
+ "lng": -75.0307264
+ },
+ "Hope Valley|RI": {
+ "lat": 41.5075992,
+ "lng": -71.7161824
+ },
+ "Westbury|NY": {
+ "lat": 40.7556561,
+ "lng": -73.5876273
+ },
+ "South Woodstock|VT": {
+ "miss": true
+ },
+ "Columbia|SC": {
+ "lat": 34.000754,
+ "lng": -81.0352313
+ },
+ "Fair Haven|MI": {
+ "lat": 42.6791996,
+ "lng": -82.653803
+ },
+ "Bensalem|PA": {
+ "lat": 40.1045549,
+ "lng": -74.951279
+ },
+ "Peru|NY": {
+ "lat": 44.578486,
+ "lng": -73.527031
+ },
+ "Stratford|CT": {
+ "lat": 41.1923646,
+ "lng": -73.1304917
+ },
+ "Natick|MA": {
+ "lat": 42.2836393,
+ "lng": -71.346996
+ },
+ "North Arlington|NJ": {
+ "lat": 40.788434,
+ "lng": -74.1331988
+ },
+ "Whitelake|MI": {
+ "miss": true
+ },
+ "East Hampton|NY": {
+ "lat": 40.9633868,
+ "lng": -72.1847598
+ },
+ "Millville|NJ": {
+ "lat": 39.4020593,
+ "lng": -75.0393368
+ },
+ "Sanford|NC": {
+ "lat": 35.4798757,
+ "lng": -79.1802994
+ },
+ "Kingston|NY": {
+ "lat": 41.9287812,
+ "lng": -74.0023825
+ },
+ "Yorktown|VA": {
+ "lat": 37.2378875,
+ "lng": -76.5080633
+ },
+ "Seaford|NY": {
+ "lat": 40.6659344,
+ "lng": -73.4881809
+ },
+ "San Diego|CA": {
+ "lat": 32.7174202,
+ "lng": -117.162772
+ },
+ "Queens|NY": {
+ "lat": 40.7135078,
+ "lng": -73.8283132
+ },
+ "Wayne|PA": {
+ "lat": 40.0440149,
+ "lng": -75.3878616
+ },
+ "Greenville|SC": {
+ "lat": 34.851354,
+ "lng": -82.3984882
+ },
+ "Skaneateles|NY": {
+ "lat": 42.947011,
+ "lng": -76.4291017
+ },
+ "Arlington|MA": {
+ "lat": 42.4153739,
+ "lng": -71.1564428
+ },
+ "Henrico|VA": {
+ "lat": 37.6294756,
+ "lng": -77.5189032
+ },
+ "Waxhaw|NC": {
+ "lat": 34.9248125,
+ "lng": -80.7440174
+ },
+ "Feasterville-Trevose|PA": {
+ "lat": 40.1484058,
+ "lng": -74.9934733
+ },
+ "Havre de Grace|MD": {
+ "lat": 39.548827,
+ "lng": -76.0898259
+ },
+ "Newtown|CT": {
+ "lat": 41.4134764,
+ "lng": -73.3086445
+ },
+ "Kensington|CT": {
+ "lat": 41.6353769,
+ "lng": -72.7687083
+ },
+ "Mount Vernon|NY": {
+ "lat": 40.9125085,
+ "lng": -73.8386504
+ },
+ "Medford|MA": {
+ "lat": 42.4184296,
+ "lng": -71.1061639
+ },
+ "Howell|NJ": {
+ "lat": 40.1413032,
+ "lng": -74.2236618
+ },
+ "New Windsor|NY": {
+ "lat": 41.4767605,
+ "lng": -74.0237519
+ },
+ "Oceanside|NY": {
+ "lat": 40.6390936,
+ "lng": -73.6399765
+ },
+ "Smyrna|GA": {
+ "lat": 33.883887,
+ "lng": -84.5147454
+ },
+ "Wayzata|MN": {
+ "lat": 44.970759,
+ "lng": -93.511947
+ },
+ "Placerville|CA": {
+ "lat": 38.7296252,
+ "lng": -120.798546
+ },
+ "Burlingame|CA": {
+ "lat": 37.5780965,
+ "lng": -122.3473099
+ },
+ "Boulder|CO": {
+ "lat": 40.0149856,
+ "lng": -105.270545
+ },
+ "Canoga Park|CA": {
+ "lat": 34.2011078,
+ "lng": -118.5978087
+ },
+ "Tampa|FL": {
+ "lat": 27.9449854,
+ "lng": -82.4583107
+ },
+ "Elk Grove|CA": {
+ "lat": 38.4087993,
+ "lng": -121.3716178
+ },
+ "Novato|CA": {
+ "lat": 38.1061979,
+ "lng": -122.5681191
+ },
+ "Las Vegas|NV": {
+ "lat": 36.1674263,
+ "lng": -115.1484131
+ },
+ "Carnelian Bay|CA": {
+ "lat": 39.2270008,
+ "lng": -120.081236
+ },
+ "San Juan Capistrano|CA": {
+ "lat": 33.5016932,
+ "lng": -117.6625509
+ },
+ "Oakland|CA": {
+ "lat": 37.8044557,
+ "lng": -122.271356
+ },
+ "San Francisco|CA": {
+ "lat": 37.7879363,
+ "lng": -122.4075201
+ },
+ "Rohnert Park|CA": {
+ "lat": 38.3396367,
+ "lng": -122.701098
+ },
+ "Costa Mesa|CA": {
+ "lat": 33.6633386,
+ "lng": -117.903317
+ },
+ "San Rafael|CA": {
+ "lat": 37.9747795,
+ "lng": -122.5316686
+ },
+ "Desert Hot Springs|CA": {
+ "lat": 33.961124,
+ "lng": -116.5016784
+ },
+ "Phoenix|AZ": {
+ "lat": 33.4484367,
+ "lng": -112.074141
+ },
+ "Gilbert|AZ": {
+ "lat": 33.3527632,
+ "lng": -111.789037
+ },
+ "Escalon|CA": {
+ "lat": 37.7974273,
+ "lng": -120.9966033
+ },
+ "Corona|CA": {
+ "lat": 33.8752945,
+ "lng": -117.566444
+ },
+ "Murrieta|CA": {
+ "lat": 33.560832,
+ "lng": -117.210656
+ },
+ "Scottsdale|AZ": {
+ "lat": 33.4942189,
+ "lng": -111.926018
+ },
+ "Lafayette|CA": {
+ "lat": 37.8857582,
+ "lng": -122.1180201
+ },
+ "Inglewood|CA": {
+ "lat": 33.9562003,
+ "lng": -118.353132
+ },
+ "Poulsbo|WA": {
+ "lat": 47.7391366,
+ "lng": -122.63928
+ },
+ "Moorpark|CA": {
+ "lat": 34.285558,
+ "lng": -118.8820414
+ },
+ "Orange|CA": {
+ "lat": 33.7872568,
+ "lng": -117.850308
+ },
+ "Mountain House|CA": {
+ "lat": 37.7832614,
+ "lng": -121.542726
+ },
+ "Ashdown|AR": {
+ "lat": 33.67344,
+ "lng": -94.1299625
+ },
+ "Torrance|CA": {
+ "lat": 33.8371392,
+ "lng": -118.3413606
+ },
+ "Indio|CA": {
+ "lat": 33.7192808,
+ "lng": -116.2188054
+ },
+ "Olympic Valley|CA": {
+ "lat": 39.1984156,
+ "lng": -120.2298597
+ },
+ "Concord|CA": {
+ "lat": 37.9768525,
+ "lng": -122.0335624
+ },
+ "Vacaville|CA": {
+ "lat": 38.3565773,
+ "lng": -121.9877444
+ },
+ "Redondo Beach|CA": {
+ "lat": 33.8398289,
+ "lng": -118.3845942
+ },
+ "Millcreek|UT": {
+ "lat": 40.6992918,
+ "lng": -111.855583
+ },
+ "Waddell|AZ": {
+ "lat": 33.5643025,
+ "lng": -112.4447662
+ },
+ "Minneapolis|MN": {
+ "lat": 44.9772995,
+ "lng": -93.2654692
+ },
+ "South Holland|IL": {
+ "lat": 41.6008681,
+ "lng": -87.6069894
+ },
+ "Plainfield|IL": {
+ "lat": 41.6086711,
+ "lng": -88.2054345
+ },
+ "Louisville|KY": {
+ "lat": 38.2542376,
+ "lng": -85.759407
+ },
+ "Hanover Park|IL": {
+ "lat": 41.9994722,
+ "lng": -88.1450735
+ },
+ "Mount Prospect|IL": {
+ "lat": 42.0664167,
+ "lng": -87.9372908
+ },
+ "Elk River|MN": {
+ "lat": 45.3038538,
+ "lng": -93.5671825
+ },
+ "Hartsville|TN": {
+ "lat": 36.3908826,
+ "lng": -86.1672107
+ },
+ "Huntley|IL": {
+ "lat": 42.1722503,
+ "lng": -88.42692
+ },
+ "Milford|OH": {
+ "lat": 39.174625,
+ "lng": -84.2958988
+ },
+ "Edwardsville|IL": {
+ "lat": 38.8114364,
+ "lng": -89.953157
+ },
+ "Mundelein|IL": {
+ "lat": 42.263079,
+ "lng": -88.0039653
+ },
+ "Newburgh|IN": {
+ "lat": 37.9456824,
+ "lng": -87.4046571
+ },
+ "St. Peters|MO": {
+ "lat": 38.791612,
+ "lng": -90.5958468
+ },
+ "Knoxville|TN": {
+ "lat": 35.9603948,
+ "lng": -83.9210261
+ },
+ "Franklin|TN": {
+ "lat": 35.925206,
+ "lng": -86.8689419
+ },
+ "East Jordan|MI": {
+ "lat": 45.158063,
+ "lng": -85.124225
+ },
+ "Kildeer|IL": {
+ "lat": 42.1705807,
+ "lng": -88.0478532
+ },
+ "Evanston|IL": {
+ "lat": 42.0470043,
+ "lng": -87.6846053
+ },
+ "Hoffman Estates|IL": {
+ "lat": 42.0427256,
+ "lng": -88.0792782
+ },
+ "Greenbrier|TN": {
+ "lat": 36.4275477,
+ "lng": -86.8047199
+ },
+ "North Aurora|IL": {
+ "lat": 41.8061399,
+ "lng": -88.3272952
+ },
+ "Clinton|MS": {
+ "lat": 32.3410552,
+ "lng": -90.3215983
+ },
+ "Chattanooga|TN": {
+ "lat": 35.0457219,
+ "lng": -85.3094883
+ },
+ "Saint Paul|MN": {
+ "lat": 44.9497487,
+ "lng": -93.0931028
+ },
+ "Algonquin|IL": {
+ "lat": 42.1655801,
+ "lng": -88.2942493
+ },
+ "Brookfield|WI": {
+ "lat": 43.0615578,
+ "lng": -88.1260678
+ },
+ "Trussville|AL": {
+ "lat": 33.6196266,
+ "lng": -86.6084342
+ },
+ "Avon|IN": {
+ "lat": 39.7628227,
+ "lng": -86.3997168
+ },
+ "Lombard|IL": {
+ "lat": 41.8864687,
+ "lng": -88.0201536
+ },
+ "Indianapolis|IN": {
+ "lat": 39.7683331,
+ "lng": -86.1583502
+ },
+ "Rockford|IL": {
+ "lat": 42.2713945,
+ "lng": -89.093966
+ },
+ "Perrysburg|OH": {
+ "lat": 41.5571178,
+ "lng": -83.6279321
+ },
+ "Hickory Hills|IL": {
+ "lat": 41.7255879,
+ "lng": -87.825055
+ },
+ "Goodlettsville|TN": {
+ "lat": 36.3231067,
+ "lng": -86.7133302
+ },
+ "Waterville|OH": {
+ "lat": 41.5008859,
+ "lng": -83.7182701
+ },
+ "Greenwood|MN": {
+ "lat": 44.9149631,
+ "lng": -93.5532897
+ },
+ "Dayton|OH": {
+ "lat": 39.7589478,
+ "lng": -84.1916069
+ },
+ "Coal Valley|IL": {
+ "lat": 41.4430956,
+ "lng": -90.4613634
+ },
+ "Carmel|IN": {
+ "lat": 39.9784186,
+ "lng": -86.1283681
+ },
+ "Waunakee|WI": {
+ "lat": 43.1919623,
+ "lng": -89.4548402
+ },
+ "Cedar Rapids|IA": {
+ "lat": 41.9758872,
+ "lng": -91.6704053
+ },
+ "Ann Arbor|MI": {
+ "lat": 42.2813722,
+ "lng": -83.7484616
+ },
+ "Lincoln|NE": {
+ "lat": 40.8088861,
+ "lng": -96.7077751
+ },
+ "Xenia|OH": {
+ "lat": 39.6847822,
+ "lng": -83.9296526
+ },
+ "Ham Lake|MN": {
+ "lat": 45.2502429,
+ "lng": -93.2499508
+ },
+ "Highland|IN": {
+ "lat": 38.0450618,
+ "lng": -87.562929
+ },
+ "Poplar Bluff|MO": {
+ "lat": 36.7563166,
+ "lng": -90.3944869
+ },
+ "Decorah|IA": {
+ "lat": 43.3041609,
+ "lng": -91.7859098
+ },
+ "Murfreesboro|TN": {
+ "lat": 35.8460396,
+ "lng": -86.3921096
+ },
+ "Mexico|MO": {
+ "lat": 39.1697626,
+ "lng": -91.8829484
+ },
+ "Baldwin|WI": {
+ "lat": 44.9667546,
+ "lng": -92.3732461
+ },
+ "Lawrence|KS": {
+ "lat": 38.9719137,
+ "lng": -95.2359403
+ },
+ "Fairview|TN": {
+ "lat": 35.9820074,
+ "lng": -87.1213953
+ },
+ "Frisco|TX": {
+ "lat": 33.1505998,
+ "lng": -96.8238183
+ },
+ "Janesville|WI": {
+ "lat": 42.6829765,
+ "lng": -89.0226793
+ },
+ "Lexington|KY": {
+ "lat": 38.0464066,
+ "lng": -84.4970393
+ },
+ "Forest Park|IL": {
+ "lat": 41.8794989,
+ "lng": -87.8136997
+ },
+ "Crystal Lake|IL": {
+ "lat": 42.2411344,
+ "lng": -88.3161965
+ },
+ "Corcoran|MN": {
+ "lat": 45.0951069,
+ "lng": -93.5475651
+ },
+ "Madison|WI": {
+ "lat": 43.07469,
+ "lng": -89.3841663
+ },
+ "Independence|KY": {
+ "lat": 38.9431183,
+ "lng": -84.544109
+ },
+ "College Grove|TN": {
+ "lat": 35.788399,
+ "lng": -86.6744409
+ },
+ "Oak Park|IL": {
+ "lat": 41.8878145,
+ "lng": -87.7887615
+ },
+ "Vernon Hills|IL": {
+ "lat": 42.2373152,
+ "lng": -87.9649487
+ },
+ "Decatur|IL": {
+ "lat": 39.8454163,
+ "lng": -88.9524151
+ },
+ "Ponte Vedra Beach|FL": {
+ "lat": 30.2396865,
+ "lng": -81.3856384
+ },
+ "Delray Beach|FL": {
+ "lat": 26.4614625,
+ "lng": -80.0728201
+ },
+ "Charleston|SC": {
+ "lat": 32.7884363,
+ "lng": -79.9399309
+ },
+ "Ft. Myers|FL": {
+ "lat": 26.640628,
+ "lng": -81.8723084
+ },
+ "St. Petersburg|FL": {
+ "lat": 27.7712264,
+ "lng": -82.6340259
+ },
+ "Johns Island|SC": {
+ "lat": 32.69485,
+ "lng": -80.03425
+ },
+ "Pompano Beach|FL": {
+ "lat": 26.2378597,
+ "lng": -80.1247667
+ },
+ "Westlake|FL": {
+ "lat": 26.7435883,
+ "lng": -80.30649
+ },
+ "Pooler|GA": {
+ "lat": 32.1158983,
+ "lng": -81.2495131
+ },
+ "Jacksonville|FL": {
+ "lat": 30.3262247,
+ "lng": -81.6579179
+ },
+ "Naples|FL": {
+ "lat": 26.1421976,
+ "lng": -81.7942944
+ },
+ "Plant City|FL": {
+ "lat": 28.01633,
+ "lng": -82.1234803
+ },
+ "Melbourne|FL": {
+ "lat": 28.0785034,
+ "lng": -80.6077908
+ },
+ "Orlando|FL": {
+ "lat": 28.5421218,
+ "lng": -81.379045
+ },
+ "Homosassa|FL": {
+ "lat": 28.7813722,
+ "lng": -82.6151001
+ },
+ "Jupiter|FL": {
+ "lat": 26.9342246,
+ "lng": -80.0942087
+ },
+ "Middleburg|FL": {
+ "lat": 30.0688512,
+ "lng": -81.8603778
+ },
+ "Celebration|FL": {
+ "lat": 28.319057,
+ "lng": -81.5408702
+ },
+ "Boca Raton|FL": {
+ "lat": 26.3586885,
+ "lng": -80.0830984
+ },
+ "Gulf Breeze|FL": {
+ "lat": 30.3612885,
+ "lng": -87.1683428
+ },
+ "Mobile|AL": {
+ "lat": 30.6913462,
+ "lng": -88.0437509
+ },
+ "Ocala|FL": {
+ "lat": 29.1871986,
+ "lng": -82.1400923
+ },
+ "Ocoee|FL": {
+ "lat": 28.5694468,
+ "lng": -81.5441944
+ },
+ "Margate|FL": {
+ "lat": 26.2445263,
+ "lng": -80.206436
+ },
+ "Palm Beach Gardens|FL": {
+ "lat": 26.8233946,
+ "lng": -80.1386547
+ },
+ "Plantation|FL": {
+ "lat": 26.1275862,
+ "lng": -80.2331036
+ },
+ "Bluffton|SC": {
+ "lat": 32.2371465,
+ "lng": -80.8603868
+ },
+ "Bradenton|FL": {
+ "lat": 27.4989278,
+ "lng": -82.5748194
+ },
+ "Panama City|FL": {
+ "lat": 30.1586518,
+ "lng": -85.6602936
+ },
+ "Tamarac|FL": {
+ "lat": 26.2128609,
+ "lng": -80.2497707
+ },
+ "Columbus|GA": {
+ "lat": 32.4610708,
+ "lng": -84.9880449
+ },
+ "San Marcos|TX": {
+ "lat": 29.8826436,
+ "lng": -97.9405828
+ },
+ "Denver|CO": {
+ "lat": 39.7392364,
+ "lng": -104.984862
+ },
+ "Monroe|LA": {
+ "lat": 32.5025471,
+ "lng": -92.116219
+ },
+ "Northglenn|CO": {
+ "lat": 39.9100858,
+ "lng": -104.987552
+ },
+ "Humble|TX": {
+ "lat": 29.9988312,
+ "lng": -95.2621553
+ },
+ "Colorado Springs|CO": {
+ "lat": 38.8339578,
+ "lng": -104.825348
+ },
+ "Stafford|TX": {
+ "lat": 29.6160671,
+ "lng": -95.5577221
+ },
+ "Aledo|TX": {
+ "lat": 32.6979939,
+ "lng": -97.6039938
+ },
+ "Fort Worth|TX": {
+ "lat": 32.753177,
+ "lng": -97.3327459
+ },
+ "San Antonio|TX": {
+ "lat": 29.4246002,
+ "lng": -98.4951405
+ },
+ "AUSTIN|TX": {
+ "lat": 30.2711286,
+ "lng": -97.7436995
+ },
+ "Austin|TX": {
+ "lat": 30.2711286,
+ "lng": -97.7436995
+ },
+ "Lafayette|LA": {
+ "lat": 30.2262187,
+ "lng": -92.0178202
+ },
+ "Schertz|TX": {
+ "lat": 29.5641617,
+ "lng": -98.2695702
+ },
+ "Piedmont|OK": {
+ "lat": 35.6419952,
+ "lng": -97.7464345
+ },
+ "Madisonville|LA": {
+ "lat": 30.4043607,
+ "lng": -90.1570232
+ },
+ "Monument|CO": {
+ "lat": 39.0916586,
+ "lng": -104.872758
+ },
+ "Dripping Springs|TX": {
+ "lat": 30.1902067,
+ "lng": -98.0866781
+ },
+ "Bowie|TX": {
+ "lat": 33.558993,
+ "lng": -97.8484719
+ },
+ "Houston|TX": {
+ "lat": 29.7589382,
+ "lng": -95.3676974
+ },
+ "Ft Worth|TX": {
+ "lat": 32.753177,
+ "lng": -97.3327459
+ },
+ "Brandon|MS": {
+ "lat": 32.2731475,
+ "lng": -89.9868058
+ },
+ "Rowlett|TX": {
+ "lat": 32.9029017,
+ "lng": -96.56388
+ },
+ "Arvada|CO": {
+ "lat": 39.8005505,
+ "lng": -105.0811573
+ },
+ "Abita Springs|LA": {
+ "lat": 30.4785257,
+ "lng": -90.0375755
+ },
+ "Rosenberg|TX": {
+ "lat": 29.5580242,
+ "lng": -95.8068304
+ },
+ "Conroe|TX": {
+ "lat": 30.3118769,
+ "lng": -95.4560512
+ },
+ "Brownsville|TX": {
+ "lat": 25.9024289,
+ "lng": -97.4981698
+ },
+ "Commerce City|CO": {
+ "lat": 39.8083196,
+ "lng": -104.9338675
+ },
+ "Lafayette|CO": {
+ "lat": 39.9935959,
+ "lng": -105.089705
+ },
+ "Argyle|TX": {
+ "lat": 33.110156,
+ "lng": -97.1797576
+ },
+ "Fredericksburg|TX": {
+ "lat": 30.2752011,
+ "lng": -98.8719843
+ },
+ "Burnet|TX": {
+ "lat": 30.7608552,
+ "lng": -98.2239954
+ },
+ "Loveland|CO": {
+ "lat": 40.3977612,
+ "lng": -105.07498
+ },
+ "Manorville|NC": {
+ "miss": true
+ },
+ "Phoenix|OR": {
+ "lat": 42.2740364,
+ "lng": -122.8153283
+ },
+ "Medford|OR": {
+ "lat": 42.3264181,
+ "lng": -122.8718605
+ },
+ "Kalispell|MT": {
+ "lat": 48.202158,
+ "lng": -114.315321
+ },
+ "Portland|OR": {
+ "lat": 45.5202471,
+ "lng": -122.674194
+ },
+ "Ellensburg|WA": {
+ "lat": 46.9970635,
+ "lng": -120.545122
+ },
+ "Redmond|WA": {
+ "lat": 47.6694141,
+ "lng": -122.1238767
+ },
+ "Port Townsend|WA": {
+ "lat": 48.1179702,
+ "lng": -122.769544
+ },
+ "Tigard|OR": {
+ "lat": 45.4307473,
+ "lng": -122.771933
+ },
+ "Bellingham|WA": {
+ "lat": 48.7544012,
+ "lng": -122.478836
+ },
+ "Port Orchard|WA": {
+ "lat": 47.5315625,
+ "lng": -122.6384056
+ },
+ "Belfair|WA": {
+ "lat": 47.45226,
+ "lng": -122.8265391
+ },
+ "Gresham|OR": {
+ "lat": 45.4978623,
+ "lng": -122.4329883
+ },
+ "Spokane|WA": {
+ "lat": 47.6571934,
+ "lng": -117.42351
+ },
+ "Kirkland|WA": {
+ "lat": 47.6765382,
+ "lng": -122.2070775
+ },
+ "Lincoln|AL": {
+ "lat": 33.6131588,
+ "lng": -86.1183061
+ },
+ "Woodstock|GA": {
+ "lat": 34.1014112,
+ "lng": -84.5192192
+ },
+ "East Point|GA": {
+ "lat": 33.6795531,
+ "lng": -84.4393724
+ },
+ "Griffin|GA": {
+ "lat": 33.2467807,
+ "lng": -84.2640904
+ },
+ "Marietta|GA": {
+ "lat": 33.9528472,
+ "lng": -84.5496148
+ },
+ "Summerville|SC": {
+ "lat": 33.0186699,
+ "lng": -80.1762704
+ },
+ "Suwanee|GA": {
+ "lat": 34.0514898,
+ "lng": -84.0712997
+ },
+ "Pelham|AL": {
+ "lat": 33.285669,
+ "lng": -86.8099884
+ },
+ "Helena|AL": {
+ "lat": 33.296224,
+ "lng": -86.8436004
+ },
+ "Fort Collins|CO": {
+ "lat": 40.5871782,
+ "lng": -105.0770113
+ },
+ "Vail|CO": {
+ "lat": 39.6438028,
+ "lng": -106.3888231
+ },
+ "Mountain Village|CO": {
+ "lat": 37.9313827,
+ "lng": -107.856453
+ },
+ "Jackson|WY": {
+ "lat": 43.479965,
+ "lng": -110.761815
+ },
+ "Black Hawk|SD": {
+ "lat": 44.1491776,
+ "lng": -103.3079053
+ },
+ "Winterset|IA": {
+ "lat": 41.3347631,
+ "lng": -94.0135687
+ },
+ "Ladspm|SC": {
+ "miss": true
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/001_claim_columns.sql b/db/migrations/001_claim_columns.sql
new file mode 100644
index 0000000..6b807ea
--- /dev/null
+++ b/db/migrations/001_claim_columns.sql
@@ -0,0 +1,51 @@
+-- 001 · Claim columns + provenance + opt-out
+-- Adds the columns the option-C "scrape lite + claim flow" needs.
+-- Idempotent: re-runnable, all ADD COLUMN guarded by IF NOT EXISTS.
+
+BEGIN;
+
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS claim_status TEXT DEFAULT 'self', -- self | unclaimed | pending_claim | claimed
+ ADD COLUMN IF NOT EXISTS claim_token TEXT, -- one-shot verification token
+ ADD COLUMN IF NOT EXISTS claim_token_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS instagram_handle TEXT, -- e.g. "atelierbond" — no @, no URL
+ ADD COLUMN IF NOT EXISTS source_name TEXT, -- e.g. "wia" | "self_signup" | "manual"
+ ADD COLUMN IF NOT EXISTS source_url TEXT, -- exact URL we scraped from
+ ADD COLUMN IF NOT EXISTS source_scraped_at TIMESTAMPTZ;
+
+CREATE INDEX IF NOT EXISTS idx_installers_claim_status ON installers(claim_status);
+CREATE INDEX IF NOT EXISTS idx_installers_source_url ON installers(source_url);
+
+-- Self-signed-up installers default to 'self'
+-- (existing rows that came in via signup will get 'self' by default)
+UPDATE installers SET claim_status = 'self' WHERE claim_status IS NULL;
+
+-- ----------------------------------------------------------------------
+-- Opt-out registry: domains that have asked to be delisted and never
+-- re-scraped, no matter what source they show up on.
+-- ----------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS directory_optout (
+ id SERIAL PRIMARY KEY,
+ domain TEXT UNIQUE NOT NULL,
+ reason TEXT,
+ requested_by_email TEXT,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ----------------------------------------------------------------------
+-- Scrape audit: per-request log so we can prove crawl etiquette later.
+-- ----------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS scrape_log (
+ id SERIAL PRIMARY KEY,
+ source TEXT NOT NULL, -- e.g. 'wia', 'studio_site'
+ url TEXT NOT NULL,
+ http_status INTEGER,
+ bytes INTEGER,
+ duration_ms INTEGER,
+ error TEXT,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_scrape_log_source_created ON scrape_log(source, created_at DESC);
+
+COMMIT;
diff --git a/db/migrations/002_ad_signals.sql b/db/migrations/002_ad_signals.sql
new file mode 100644
index 0000000..c16f03c
--- /dev/null
+++ b/db/migrations/002_ad_signals.sql
@@ -0,0 +1,15 @@
+-- 002 · Ad / social signals JSONB per installer
+-- Stores tracking-pixel fingerprints mined from each studio's homepage.
+-- Idempotent.
+
+BEGIN;
+
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS ad_signals JSONB,
+ ADD COLUMN IF NOT EXISTS ad_signals_at TIMESTAMPTZ;
+
+CREATE INDEX IF NOT EXISTS idx_installers_ad_signals_paid
+ ON installers((ad_signals->>'paid_ads_count'))
+ WHERE ad_signals IS NOT NULL;
+
+COMMIT;
diff --git a/db/migrations/003_perf.sql b/db/migrations/003_perf.sql
new file mode 100644
index 0000000..c0dbeb4
--- /dev/null
+++ b/db/migrations/003_perf.sql
@@ -0,0 +1,74 @@
+-- 003 · Performance migrations from database-optimizer review (2026-05-05)
+--
+-- All CREATE INDEX statements use CONCURRENTLY where possible. Note: PG won't
+-- run CREATE INDEX CONCURRENTLY inside a transaction block, so this file is
+-- intentionally not wrapped in BEGIN/COMMIT. Apply with:
+--
+-- psql -d national_paper_hangers -f db/migrations/003_perf.sql
+--
+-- Idempotent via IF NOT EXISTS / ON CONFLICT guards.
+
+-- 1) Slot-calc hot-path index — partial composite covering only the statuses
+-- that count toward conflict checks. Replaces the broader idx_bookings_scheduled.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bookings_slot_hot
+ ON bookings (installer_id, scheduled_start, scheduled_end)
+ WHERE status IN ('pending', 'confirmed');
+
+DROP INDEX CONCURRENTLY IF EXISTS idx_bookings_scheduled;
+
+-- 2) Lead-list filter — unclaimed studios with a scrapeable website.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_installers_unclaimed_with_site
+ ON installers (state, city)
+ WHERE claim_status = 'unclaimed' AND website IS NOT NULL;
+
+-- 3) Time-off range scans — BRIN is tiny vs BTree for naturally time-ordered data.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_time_off_start_brin
+ ON installer_time_off USING BRIN (start_at, end_at)
+ WITH (pages_per_range = 32);
+
+-- 4) Denormalize avg_rating + review_count onto installers (avoids GROUP BY
+-- join on every directory listing render at scale).
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS avg_rating NUMERIC(3,2),
+ ADD COLUMN IF NOT EXISTS review_count INTEGER DEFAULT 0;
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_installers_rating
+ ON installers (avg_rating DESC NULLS LAST)
+ WHERE status = 'active';
+
+-- One-shot backfill for any existing reviews. Safe to re-run.
+UPDATE installers i
+ SET avg_rating = sub.avg,
+ review_count = sub.cnt
+ FROM (
+ SELECT installer_id,
+ ROUND(AVG(rating)::numeric, 2) AS avg,
+ COUNT(*)::int AS cnt
+ FROM installer_reviews
+ WHERE published = true
+ GROUP BY installer_id
+ ) sub
+ WHERE sub.installer_id = i.id;
+
+-- 5) GIN on ad_signals JSONB — supports `@>` containment + key-exists as the
+-- signals schema grows beyond paid_ads_count.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_installers_ad_signals_gin
+ ON installers USING GIN (ad_signals)
+ WHERE ad_signals IS NOT NULL;
+
+-- 6) Subscription-events orphan cleanup index. installer_id can become NULL
+-- via ON DELETE SET NULL — make those rows easy to find for retention sweeps.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sub_events_no_installer
+ ON subscription_events (created_at)
+ WHERE installer_id IS NULL;
+
+-- 7) Autovacuum tuning for high-churn tables.
+ALTER TABLE bookings SET (
+ autovacuum_vacuum_scale_factor = 0.01,
+ autovacuum_analyze_scale_factor = 0.005
+);
+
+ALTER TABLE scrape_log SET (
+ autovacuum_vacuum_scale_factor = 0.02,
+ autovacuum_vacuum_insert_scale_factor = 0.01
+);
diff --git a/db/migrations/004_comms_suppression.sql b/db/migrations/004_comms_suppression.sql
new file mode 100644
index 0000000..754eebb
--- /dev/null
+++ b/db/migrations/004_comms_suppression.sql
@@ -0,0 +1,64 @@
+-- 004 · Communications-compliance tables (CAN-SPAM / §17529.5 / CCPA / TCPA)
+--
+-- Mirrors the schema documented in Steve's `comms-compliance` skill so any
+-- send script can fail-closed if these tables are missing.
+--
+-- comms_suppression: any email/phone we must never contact again. Fed by:
+-- - studio opt-outs from /unsubscribe?token=
+-- - hard bounces from George
+-- - CCPA delete requests
+-- - manual ops additions
+--
+-- comms_send_audit: per-message log — recipient, channel, decision, reason.
+-- Required by Steve's standing rule: "fail-closed if list missing or stale,
+-- per-message audit to PG".
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS comms_suppression (
+ id SERIAL PRIMARY KEY,
+ channel TEXT NOT NULL, -- 'email' | 'sms' | 'voice'
+ identifier TEXT NOT NULL, -- lowercased email or E.164 phone
+ reason TEXT NOT NULL, -- 'unsubscribe' | 'bounce' | 'ccpa_delete' | 'manual' | 'optout_form'
+ source TEXT, -- where it came from (URL, scrape, ops note)
+ installer_id INTEGER REFERENCES installers(id) ON DELETE SET NULL,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (channel, identifier)
+);
+
+CREATE INDEX IF NOT EXISTS idx_comms_suppression_id ON comms_suppression (channel, identifier);
+
+CREATE TABLE IF NOT EXISTS comms_send_audit (
+ id BIGSERIAL PRIMARY KEY,
+ channel TEXT NOT NULL, -- 'email' | 'sms' | 'voice'
+ campaign TEXT NOT NULL, -- 'claim_invite' | 'booking_confirmation' | etc.
+ recipient TEXT NOT NULL, -- lowercased email or E.164
+ installer_id INTEGER REFERENCES installers(id) ON DELETE SET NULL,
+ decision TEXT NOT NULL, -- 'sent' | 'blocked_suppression' | 'blocked_compliance_gate' | 'blocked_dnc' | 'failed'
+ reason TEXT, -- detail for blocked/failed
+ subject TEXT,
+ message_id TEXT, -- George/SMTP message-id when sent
+ payload JSONB, -- full template snapshot for audit replay
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_comms_send_audit_recipient ON comms_send_audit (recipient, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_comms_send_audit_campaign ON comms_send_audit (campaign, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_comms_send_audit_installer ON comms_send_audit (installer_id, created_at DESC);
+
+-- Unsubscribe tokens — one-shot, signed; revoke by deleting the row.
+CREATE TABLE IF NOT EXISTS unsubscribe_tokens (
+ token TEXT PRIMARY KEY, -- HMAC-derived, opaque
+ channel TEXT NOT NULL,
+ identifier TEXT NOT NULL,
+ campaign TEXT,
+ installer_id INTEGER REFERENCES installers(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ used_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_unsubscribe_tokens_identifier
+ ON unsubscribe_tokens (channel, identifier);
+
+COMMIT;
diff --git a/db/migrations/005_users_roles.sql b/db/migrations/005_users_roles.sql
new file mode 100644
index 0000000..bb3862a
--- /dev/null
+++ b/db/migrations/005_users_roles.sql
@@ -0,0 +1,144 @@
+-- 005 · Users / Roles / Installer-Members split (Phase 1, additive only)
+--
+-- Architectural intent:
+-- The `installers` table is currently doing four jobs at once — identity
+-- (email + password_hash + last_login_at), directory entity (slug + bio +
+-- service area), subscription holder (tier + Stripe IDs), and session actor
+-- (req.session.installerId). RBAC for ops staff (verification queue, COI
+-- review, license review) has nowhere to live: ops staff aren't installers
+-- and shouldn't have a row in `installers`.
+--
+-- This migration introduces three tables:
+--
+-- users — pure identity (email, password, login bookkeeping)
+-- roles — N-to-N: which roles a user holds globally
+-- (admin / ops / installer_owner / installer_member)
+-- installer_members — N-to-N: which installer rows a user can act on,
+-- and in what capacity ('owner' or 'member')
+--
+-- Phase 1 (this file) is ADDITIVE ONLY:
+-- - Existing routes still read installers.email / installers.password_hash.
+-- - Existing sessions still set req.session.installerId.
+-- - New tables are populated via the backfill block at the bottom so the
+-- two worlds stay in sync.
+--
+-- Phase 2 (later) will:
+-- - Switch /login + /signup to write users/roles/installer_members.
+-- - Switch req.session to set userId (not installerId).
+-- - Drop installers.email + installers.password_hash + installers.last_login_at.
+--
+-- This file is idempotent — re-runnable. Apply manually:
+-- psql -d national_paper_hangers -f db/migrations/005_users_roles.sql
+
+BEGIN;
+
+-- =======================================================================
+-- USERS — pure identity
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ name TEXT,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ last_login_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+
+-- =======================================================================
+-- ROLES — global capabilities a user holds
+-- =======================================================================
+--
+-- 'admin' — full superuser (Steve)
+-- 'ops' — verification ops staff (COI / W-9 / license queue)
+-- 'installer_owner' — controls one or more installer profiles
+-- 'installer_member' — works under an installer profile (e.g. crew lead)
+
+CREATE TABLE IF NOT EXISTS roles (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL,
+ granted_at TIMESTAMPTZ DEFAULT now(),
+ granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ PRIMARY KEY (user_id, role),
+ CHECK (role IN ('admin','ops','installer_owner','installer_member'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_roles_role ON roles(role);
+
+-- =======================================================================
+-- INSTALLER_MEMBERS — N-to-N user ↔ installer
+-- =======================================================================
+--
+-- The `role` column here is local to the installer (NOT the global role
+-- list above). Values: 'owner' | 'member'. An owner can edit the installer
+-- profile, manage subscription, invite/remove members. A member can manage
+-- bookings and calendar but not billing.
+
+CREATE TABLE IF NOT EXISTS installer_members (
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL DEFAULT 'owner',
+ added_at TIMESTAMPTZ DEFAULT now(),
+ added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ PRIMARY KEY (installer_id, user_id),
+ CHECK (role IN ('owner','member'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_installer_members_user ON installer_members(user_id);
+CREATE INDEX IF NOT EXISTS idx_installer_members_installer ON installer_members(installer_id);
+
+-- Single-owner lock (v1 decision, 2026-05-05).
+--
+-- v1 NPH assumes one operator per installer studio — the claim flow is
+-- domain-restricted to a single email per studio website, billing assumes
+-- one Stripe customer per installer, and the dashboard has a single user
+-- view. Enforcing this at the DB layer prevents the entire class of bugs
+-- where two "owners" disagree about subscription state, profile edits,
+-- or who receives booking notifications.
+--
+-- If/when v2 needs co-owners (e.g. an agency operating 5 studios), drop
+-- this index and replace with a richer `delegations` table — easier than
+-- retrofitting consistency on top of dual-owner data later.
+CREATE UNIQUE INDEX IF NOT EXISTS uq_installer_members_one_owner
+ ON installer_members (installer_id)
+ WHERE role = 'owner';
+
+-- =======================================================================
+-- BACKFILL — copy current installers identity into the new tables
+-- =======================================================================
+--
+-- For every existing installers row that has email + password_hash, ensure:
+-- 1. A users row exists (matched on email).
+-- 2. That user holds the 'installer_owner' global role.
+-- 3. The user is linked to the installer via installer_members as 'owner'.
+--
+-- Idempotent: ON CONFLICT DO NOTHING on every insert. Safe to re-run after
+-- new self-signups during the dual-write window.
+
+INSERT INTO users (email, password_hash, name, created_at, last_login_at)
+SELECT i.email, i.password_hash, COALESCE(i.contact_name, i.business_name),
+ i.created_at, i.last_login_at
+ FROM installers i
+ WHERE i.email IS NOT NULL
+ AND i.password_hash IS NOT NULL
+ON CONFLICT (email) DO NOTHING;
+
+INSERT INTO roles (user_id, role)
+SELECT u.id, 'installer_owner'
+ FROM users u
+ JOIN installers i ON i.email = u.email
+ON CONFLICT (user_id, role) DO NOTHING;
+
+INSERT INTO installer_members (installer_id, user_id, role)
+SELECT i.id, u.id, 'owner'
+ FROM installers i
+ JOIN users u ON u.email = i.email
+ON CONFLICT (installer_id, user_id) DO NOTHING;
+
+COMMIT;
+
+-- NOTE: We are NOT dropping installers.email or installers.password_hash here.
+-- Phase 2 will, once routes/auth.js, lib/auth.js, and routes/admin.js have
+-- been switched to read from users + installer_members. See ARCHITECTURE_PHASE2.md.
diff --git a/db/migrations/006_equipment_and_metrics.sql b/db/migrations/006_equipment_and_metrics.sql
new file mode 100644
index 0000000..97010fa
--- /dev/null
+++ b/db/migrations/006_equipment_and_metrics.sql
@@ -0,0 +1,37 @@
+-- 006 · Equipment Fleet (UX idea #4) + Acceptance Rate cache (UX idea #7)
+--
+-- Two unique-UX adds for the luxury trade:
+-- - installers.equipment JSONB → ladder/scaffold/lift/table/dust capacity
+-- - acceptance-rate is computed from bookings table on read, no schema add,
+-- but partial index on status speeds up the rolling-365d aggregation.
+--
+-- Idempotent. Apply with:
+-- psql -d national_paper_hangers -f db/migrations/006_equipment_and_metrics.sql
+
+BEGIN;
+
+-- Equipment Fleet — structured studio-capacity disclosure for trade buyers.
+-- Shape (any field optional):
+-- {
+-- "max_reach_ft": 22,
+-- "lift_type": "scaffold" | "extension_ladder" | "scissor_lift" | "boom_lift",
+-- "paper_table": "none" | "folding" | "dedicated_60" | "dedicated_72_plus",
+-- "dust_extraction": true,
+-- "vehicle": "van" | "box_truck" | "trailer",
+-- "notes": "free text — e.g. 'Genie GS-1932 lift available on request'"
+-- }
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS equipment JSONB;
+
+-- Partial GIN for "filter by lift_type" or "max_reach >= N" trade-buyer queries.
+CREATE INDEX IF NOT EXISTS idx_installers_equipment
+ ON installers USING GIN (equipment)
+ WHERE equipment IS NOT NULL;
+
+-- Acceptance-rate aggregation index — rolling 365-day window per installer,
+-- only on the statuses that count toward the metric.
+CREATE INDEX IF NOT EXISTS idx_bookings_acceptance_metric
+ ON bookings (installer_id, created_at, status)
+ WHERE status IN ('confirmed', 'completed', 'declined');
+
+COMMIT;
diff --git a/db/migrations/007_portfolio_detail_shots.sql b/db/migrations/007_portfolio_detail_shots.sql
new file mode 100644
index 0000000..68bed68
--- /dev/null
+++ b/db/migrations/007_portfolio_detail_shots.sql
@@ -0,0 +1,16 @@
+-- 007 · "In the Seams" portfolio — detail-shot URLs (UX idea #1)
+--
+-- Generic directories show hero shots. In luxury wallcovering the seam,
+-- corner, and ceiling-line is where craft is visible. These columns let
+-- studios surface those detail shots in tabbed gallery viewers.
+--
+-- All optional — empty tabs hide gracefully.
+
+BEGIN;
+
+ALTER TABLE installer_portfolio
+ ADD COLUMN IF NOT EXISTS detail_seam_url TEXT,
+ ADD COLUMN IF NOT EXISTS detail_corner_url TEXT,
+ ADD COLUMN IF NOT EXISTS detail_ceiling_url TEXT;
+
+COMMIT;
diff --git a/db/migrations/008_structured_booking_brief.sql b/db/migrations/008_structured_booking_brief.sql
new file mode 100644
index 0000000..54c99c6
--- /dev/null
+++ b/db/migrations/008_structured_booking_brief.sql
@@ -0,0 +1,33 @@
+-- 008 · Structured booking brief — UX idea #6
+--
+-- Generic directories ask "what kind of project?" — useless for wallcovering.
+-- A studio arrives wrong-equipped because the brief was a textarea. These
+-- columns capture the specifics an installer needs to bid + arrive prepared.
+
+BEGIN;
+
+ALTER TABLE bookings
+ ADD COLUMN IF NOT EXISTS ceiling_height_ft NUMERIC(4,1),
+ ADD COLUMN IF NOT EXISTS surface_state TEXT,
+ ADD COLUMN IF NOT EXISTS access_constraints TEXT,
+ ADD COLUMN IF NOT EXISTS brand_sku TEXT,
+ ADD COLUMN IF NOT EXISTS roll_count_estimate INTEGER;
+
+-- Validate enums via CHECK rather than a separate lookup table — these are
+-- small fixed sets and we want the DB to refuse bad input directly.
+ALTER TABLE bookings
+ DROP CONSTRAINT IF EXISTS bookings_surface_state_check,
+ ADD CONSTRAINT bookings_surface_state_check
+ CHECK (surface_state IS NULL OR surface_state IN (
+ 'new_plaster', 'painted_drywall', 'wallpaper_to_remove',
+ 'brick', 'wood_panel', 'other'
+ ));
+
+ALTER TABLE bookings
+ DROP CONSTRAINT IF EXISTS bookings_access_constraints_check,
+ ADD CONSTRAINT bookings_access_constraints_check
+ CHECK (access_constraints IS NULL OR access_constraints IN (
+ 'ground_floor', 'second_floor', 'atrium_double_height', 'high_rise', 'restricted_hours'
+ ));
+
+COMMIT;
diff --git a/db/migrations/009_installer_geo.sql b/db/migrations/009_installer_geo.sql
new file mode 100644
index 0000000..5ea0b9e
--- /dev/null
+++ b/db/migrations/009_installer_geo.sql
@@ -0,0 +1,14 @@
+-- 009 — installer geo coordinates for /map view + radius search.
+-- latitude/longitude are nullable so existing rows don't break.
+-- geocoded_at lets the geocoder script skip already-resolved rows.
+-- accuracy gives us an honesty signal for the UI ("city-level" vs "address-level").
+
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS latitude numeric(8,5),
+ ADD COLUMN IF NOT EXISTS longitude numeric(9,5),
+ ADD COLUMN IF NOT EXISTS geo_accuracy text,
+ ADD COLUMN IF NOT EXISTS geocoded_at timestamptz;
+
+CREATE INDEX IF NOT EXISTS idx_installers_latlng
+ ON installers (latitude, longitude)
+ WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
diff --git a/db/migrations/010_installer_connect.sql b/db/migrations/010_installer_connect.sql
new file mode 100644
index 0000000..65ca8f8
--- /dev/null
+++ b/db/migrations/010_installer_connect.sql
@@ -0,0 +1,36 @@
+-- 010 — Stripe Connect Express account fields on installers.
+-- Required so each installer can receive booking-deposit payouts net of NPH's
+-- platform fee. Booking flow falls back to platform-balance hold when an
+-- installer has no Connect account yet (unclaimed studios, or claimed but
+-- onboarding incomplete) — those balances queue for manual transfer once
+-- the installer completes Connect onboarding.
+
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS stripe_account_id text,
+ ADD COLUMN IF NOT EXISTS stripe_account_charges_enabled boolean DEFAULT false,
+ ADD COLUMN IF NOT EXISTS stripe_account_payouts_enabled boolean DEFAULT false,
+ ADD COLUMN IF NOT EXISTS stripe_account_onboarded_at timestamptz;
+
+CREATE INDEX IF NOT EXISTS idx_installers_stripe_account
+ ON installers (stripe_account_id)
+ WHERE stripe_account_id IS NOT NULL;
+
+-- Audit table for payment_intent webhook events. Mirrors subscription_events
+-- pattern: stripe_event_id is UNIQUE so the webhook is idempotent under retry.
+CREATE TABLE IF NOT EXISTS payment_events (
+ id bigserial PRIMARY KEY,
+ stripe_event_id text UNIQUE NOT NULL,
+ event_type text NOT NULL,
+ payment_intent_id text,
+ booking_uuid uuid,
+ installer_id integer REFERENCES installers(id) ON DELETE SET NULL,
+ amount_cents integer,
+ application_fee_cents integer,
+ payload jsonb,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_payment_events_pi
+ ON payment_events (payment_intent_id);
+CREATE INDEX IF NOT EXISTS idx_payment_events_booking
+ ON payment_events (booking_uuid);
diff --git a/db/migrations/011_installer_credentials.sql b/db/migrations/011_installer_credentials.sql
new file mode 100644
index 0000000..f4a1099
--- /dev/null
+++ b/db/migrations/011_installer_credentials.sql
@@ -0,0 +1,63 @@
+-- 011 · Brand-trained credentials (UX idea #2)
+--
+-- "Verified" is meaningless to a designer specifying $4K/roll de Gournay.
+-- They want: "Has this installer been TRAINED by de Gournay?" We capture
+-- structured per-brand credentials with optional cert scan + dates.
+--
+-- Existing `installers.accreditations TEXT[]` stays as the lightweight
+-- catch-all (e.g. "WIA Certified Installer"). Brand-specific training lives
+-- in the new structured table so each badge can carry a verifiable cert.
+--
+-- Reversible: DROP TABLE installer_credentials;
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS installer_credentials (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ brand TEXT NOT NULL,
+ credential_type TEXT NOT NULL DEFAULT 'brand_trained',
+ -- brand_trained | brand_certified | brand_approved | manufacturer_partner | trade_member
+ year_issued INTEGER,
+ year_expires INTEGER,
+ certificate_url TEXT,
+ -- HTTPS URL of the cert scan. Validated http(s) only at write time.
+ notes TEXT,
+ ops_verified BOOLEAN NOT NULL DEFAULT false,
+ ops_verified_at TIMESTAMPTZ,
+ ops_verified_by TEXT,
+ display_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ CONSTRAINT installer_credentials_type_check
+ CHECK (credential_type IN ('brand_trained','brand_certified','brand_approved','manufacturer_partner','trade_member')),
+ CONSTRAINT installer_credentials_year_check
+ CHECK (year_issued IS NULL OR (year_issued >= 1900 AND year_issued <= 2100)),
+ CONSTRAINT installer_credentials_expiry_check
+ CHECK (year_expires IS NULL OR (year_expires >= 1900 AND year_expires <= 2200))
+);
+
+-- Per-installer view + ordering. Most installers will have 1-5 credentials.
+CREATE INDEX IF NOT EXISTS idx_installer_credentials_installer
+ ON installer_credentials (installer_id, display_order, year_issued DESC NULLS LAST);
+
+-- Verified-only filter for the lead-side directory ("show me only de Gournay-trained installers").
+CREATE INDEX IF NOT EXISTS idx_installer_credentials_brand
+ ON installer_credentials (LOWER(brand))
+ WHERE ops_verified = true;
+
+-- Updated-at touch trigger — uses the same pattern as bookings/installers.
+DROP TRIGGER IF EXISTS trg_installer_credentials_updated ON installer_credentials;
+CREATE OR REPLACE FUNCTION installer_credentials_touch_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+CREATE TRIGGER trg_installer_credentials_updated
+ BEFORE UPDATE ON installer_credentials
+ FOR EACH ROW EXECUTE FUNCTION installer_credentials_touch_updated_at();
+
+COMMIT;
diff --git a/db/migrations/012_installer_templates.sql b/db/migrations/012_installer_templates.sql
new file mode 100644
index 0000000..5c2a919
--- /dev/null
+++ b/db/migrations/012_installer_templates.sql
@@ -0,0 +1,14 @@
+-- Per-installer page template + per-template overrides.
+--
+-- template_slug : which of the 6 layouts renders /installer/:slug
+-- (editorial | trade-pro | concierge | studio | heritage | bilingue)
+-- template_settings : jsonb bag for accent_color, hero_choice, language toggles, etc.
+
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS template_slug TEXT NOT NULL DEFAULT 'editorial',
+ ADD COLUMN IF NOT EXISTS template_settings JSONB NOT NULL DEFAULT '{}'::jsonb;
+
+-- Defensive: if any pre-existing rows had NULL via prior partial migration.
+UPDATE installers SET template_slug = 'editorial' WHERE template_slug IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_installers_template_slug ON installers(template_slug);
diff --git a/db/migrations/013_consumer_accounts_and_brief.sql b/db/migrations/013_consumer_accounts_and_brief.sql
new file mode 100644
index 0000000..8438954
--- /dev/null
+++ b/db/migrations/013_consumer_accounts_and_brief.sql
@@ -0,0 +1,38 @@
+-- 013 · Consumer accounts (Google OAuth) + missing brief fields on bookings.
+--
+-- Buyer-side authentication didn't exist before — anyone could submit a
+-- booking with just a typed name + email. We now offer Google sign-in for
+-- any buyer (homeowner / designer / architect / contractor / property mgr)
+-- and persist their identity on the booking.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS consumer_accounts (
+ id SERIAL PRIMARY KEY,
+ google_sub TEXT UNIQUE, -- Google OpenID `sub` claim (stable per account)
+ email TEXT NOT NULL,
+ email_verified BOOLEAN NOT NULL DEFAULT FALSE,
+ name TEXT,
+ picture_url TEXT,
+ customer_role TEXT, -- homeowner | designer | architect | contractor | property_mgr | other
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ last_login_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_consumer_accounts_email ON consumer_accounts(LOWER(email));
+
+ALTER TABLE bookings
+ ADD COLUMN IF NOT EXISTS customer_role TEXT,
+ ADD COLUMN IF NOT EXISTS product_sourced BOOLEAN,
+ ADD COLUMN IF NOT EXISTS consumer_account_id INTEGER REFERENCES consumer_accounts(id) ON DELETE SET NULL;
+
+ALTER TABLE bookings
+ DROP CONSTRAINT IF EXISTS bookings_customer_role_check,
+ ADD CONSTRAINT bookings_customer_role_check
+ CHECK (customer_role IS NULL OR customer_role IN (
+ 'homeowner', 'designer', 'architect', 'contractor', 'property_mgr', 'other'
+ ));
+
+CREATE INDEX IF NOT EXISTS idx_bookings_consumer_account ON bookings(consumer_account_id);
+
+COMMIT;
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..b19967e
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,298 @@
+-- NationalPaperHangers.com schema
+-- Standalone PG database `national_paper_hangers` (NOT in dw_unified)
+
+BEGIN;
+
+-- =======================================================================
+-- INSTALLERS
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS installers (
+ id SERIAL PRIMARY KEY,
+ slug TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ business_name TEXT NOT NULL,
+ contact_name TEXT,
+ phone TEXT,
+ bio TEXT,
+ headline TEXT,
+ city TEXT,
+ state TEXT,
+ zip TEXT,
+ country TEXT DEFAULT 'US',
+ service_radius_miles INTEGER DEFAULT 50,
+ travel_available BOOLEAN DEFAULT false,
+ team_size INTEGER,
+ founded_year INTEGER,
+ website TEXT,
+ -- specialties / segments
+ market_segments TEXT[] DEFAULT '{}', -- e.g. {luxury_residential, hospitality, retail, museum}
+ materials TEXT[] DEFAULT '{}', -- e.g. {grasscloth, silk, hand_painted, mural, vinyl}
+ brands_handled TEXT[] DEFAULT '{}', -- e.g. {Maya Romanoff, Fromental, de Gournay}
+ accreditations TEXT[] DEFAULT '{}', -- WIA, manufacturer certs, etc.
+ -- trust / verification
+ verified BOOLEAN DEFAULT false,
+ verified_at TIMESTAMPTZ,
+ verified_by TEXT,
+ insurance_on_file BOOLEAN DEFAULT false,
+ insurance_expires DATE,
+ license_number TEXT,
+ license_state TEXT,
+ -- subscription
+ tier TEXT DEFAULT 'basic', -- basic | pro | signature | enterprise
+ subscription_status TEXT DEFAULT 'inactive', -- inactive | active | past_due | canceled
+ stripe_customer_id TEXT,
+ stripe_subscription_id TEXT,
+ current_period_end TIMESTAMPTZ,
+ -- meta
+ response_time_hours INTEGER DEFAULT 24,
+ status TEXT DEFAULT 'pending', -- pending | active | suspended | archived
+ profile_complete BOOLEAN DEFAULT false,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ last_login_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_installers_slug ON installers(slug);
+CREATE INDEX IF NOT EXISTS idx_installers_email ON installers(email);
+CREATE INDEX IF NOT EXISTS idx_installers_status ON installers(status);
+CREATE INDEX IF NOT EXISTS idx_installers_tier ON installers(tier);
+CREATE INDEX IF NOT EXISTS idx_installers_zip ON installers(zip);
+CREATE INDEX IF NOT EXISTS idx_installers_state ON installers(state);
+CREATE INDEX IF NOT EXISTS idx_installers_segments ON installers USING GIN(market_segments);
+CREATE INDEX IF NOT EXISTS idx_installers_materials ON installers USING GIN(materials);
+
+-- =======================================================================
+-- PORTFOLIO (project case studies per installer)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS installer_portfolio (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ description TEXT,
+ image_url TEXT NOT NULL,
+ caption TEXT,
+ market_segment TEXT,
+ material TEXT,
+ brand TEXT,
+ city TEXT,
+ state TEXT,
+ year INTEGER,
+ display_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_portfolio_installer ON installer_portfolio(installer_id);
+
+-- =======================================================================
+-- AVAILABILITY (recurring weekly schedule)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS installer_availability (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sun ... 6=Sat
+ start_time TIME NOT NULL,
+ end_time TIME NOT NULL,
+ timezone TEXT DEFAULT 'America/Los_Angeles',
+ active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ CHECK (end_time > start_time)
+);
+
+CREATE INDEX IF NOT EXISTS idx_availability_installer ON installer_availability(installer_id);
+CREATE INDEX IF NOT EXISTS idx_availability_day ON installer_availability(installer_id, day_of_week);
+
+-- =======================================================================
+-- TIME OFF (blocked dates / vacations / one-off blocks)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS installer_time_off (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ start_at TIMESTAMPTZ NOT NULL,
+ end_at TIMESTAMPTZ NOT NULL,
+ reason TEXT,
+ all_day BOOLEAN DEFAULT false,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ CHECK (end_at > start_at)
+);
+
+CREATE INDEX IF NOT EXISTS idx_time_off_installer ON installer_time_off(installer_id);
+CREATE INDEX IF NOT EXISTS idx_time_off_range ON installer_time_off(installer_id, start_at, end_at);
+
+-- =======================================================================
+-- BOOKINGS (consumer-scheduled installs / consultations)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS bookings (
+ id SERIAL PRIMARY KEY,
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE RESTRICT,
+ -- consumer identity
+ customer_name TEXT NOT NULL,
+ customer_email TEXT NOT NULL,
+ customer_phone TEXT,
+ -- project basics
+ project_type TEXT, -- consultation | install | site_visit | quote
+ market_segment TEXT, -- luxury_residential | hospitality | retail | etc.
+ material TEXT,
+ brand TEXT,
+ surfaces TEXT, -- free text: walls, ceiling, etc.
+ rooms TEXT,
+ square_feet INTEGER,
+ budget_band TEXT, -- under_5k | 5k_15k | 15k_50k | 50k_plus
+ -- location
+ address_line1 TEXT,
+ address_line2 TEXT,
+ city TEXT,
+ state TEXT,
+ zip TEXT,
+ -- schedule
+ scheduled_start TIMESTAMPTZ NOT NULL,
+ scheduled_end TIMESTAMPTZ NOT NULL,
+ timezone TEXT DEFAULT 'America/Los_Angeles',
+ -- status
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | confirmed | declined | completed | canceled | no_show
+ installer_notes TEXT,
+ customer_notes TEXT,
+ cancel_reason TEXT,
+ -- payment (deposits — phase 4)
+ deposit_amount_cents INTEGER,
+ deposit_status TEXT, -- none | requires_payment | paid | refunded
+ stripe_payment_intent_id TEXT,
+ -- meta
+ source TEXT DEFAULT 'web', -- web | concierge | partner
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ confirmed_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ canceled_at TIMESTAMPTZ,
+ CHECK (scheduled_end > scheduled_start)
+);
+
+CREATE INDEX IF NOT EXISTS idx_bookings_installer ON bookings(installer_id);
+CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
+CREATE INDEX IF NOT EXISTS idx_bookings_scheduled ON bookings(installer_id, scheduled_start, scheduled_end);
+CREATE INDEX IF NOT EXISTS idx_bookings_customer_email ON bookings(customer_email);
+CREATE INDEX IF NOT EXISTS idx_bookings_uuid ON bookings(uuid);
+
+-- =======================================================================
+-- REVIEWS (post-completion, gated on completed booking)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS installer_reviews (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ booking_id INTEGER REFERENCES bookings(id) ON DELETE SET NULL,
+ customer_name TEXT,
+ customer_email TEXT,
+ rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
+ title TEXT,
+ body TEXT,
+ verified BOOLEAN DEFAULT false, -- true if booking_id is non-null AND completed
+ published BOOLEAN DEFAULT false, -- moderation gate
+ moderation_note TEXT,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ published_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_reviews_installer ON installer_reviews(installer_id);
+CREATE INDEX IF NOT EXISTS idx_reviews_published ON installer_reviews(installer_id, published);
+
+-- =======================================================================
+-- CONSUMER LEADS (pre-booking briefs from concierge intake)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS consumer_leads (
+ id SERIAL PRIMARY KEY,
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
+ customer_name TEXT NOT NULL,
+ customer_email TEXT NOT NULL,
+ customer_phone TEXT,
+ customer_role TEXT, -- homeowner | designer | architect | hospitality | other
+ company TEXT,
+ project_name TEXT,
+ market_segment TEXT,
+ material TEXT,
+ brand TEXT,
+ surfaces TEXT,
+ rooms TEXT,
+ square_feet INTEGER,
+ budget_band TEXT,
+ timeline TEXT,
+ city TEXT,
+ state TEXT,
+ zip TEXT,
+ product_sourced BOOLEAN,
+ notes TEXT,
+ status TEXT DEFAULT 'new', -- new | shortlisted | matched | closed
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_leads_status ON consumer_leads(status);
+CREATE INDEX IF NOT EXISTS idx_leads_zip ON consumer_leads(zip);
+
+-- =======================================================================
+-- LEAD ↔ INSTALLER OFFERS (when a lead is routed to N installers)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS lead_offers (
+ id SERIAL PRIMARY KEY,
+ lead_id INTEGER NOT NULL REFERENCES consumer_leads(id) ON DELETE CASCADE,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ status TEXT DEFAULT 'pending', -- pending | accepted | declined | expired
+ expires_at TIMESTAMPTZ,
+ responded_at TIMESTAMPTZ,
+ installer_response TEXT,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ UNIQUE (lead_id, installer_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_offers_installer ON lead_offers(installer_id, status);
+
+-- =======================================================================
+-- SUBSCRIPTION EVENTS (audit trail from Stripe webhooks)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS subscription_events (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER REFERENCES installers(id) ON DELETE SET NULL,
+ stripe_event_id TEXT UNIQUE,
+ event_type TEXT NOT NULL,
+ payload JSONB,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_sub_events_installer ON subscription_events(installer_id);
+
+-- =======================================================================
+-- SESSIONS (express-session via connect-pg-simple)
+-- =======================================================================
+
+CREATE TABLE IF NOT EXISTS session (
+ sid VARCHAR NOT NULL COLLATE "default" PRIMARY KEY,
+ sess JSON NOT NULL,
+ expire TIMESTAMP(6) NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_session_expire ON session(expire);
+
+-- =======================================================================
+-- updated_at trigger
+-- =======================================================================
+
+CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_installers_updated ON installers;
+CREATE TRIGGER trg_installers_updated BEFORE UPDATE ON installers
+ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
+
+DROP TRIGGER IF EXISTS trg_bookings_updated ON bookings;
+CREATE TRIGGER trg_bookings_updated BEFORE UPDATE ON bookings
+ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
+
+COMMIT;
diff --git a/db/seed.sql b/db/seed.sql
new file mode 100644
index 0000000..de782fe
--- /dev/null
+++ b/db/seed.sql
@@ -0,0 +1,126 @@
+-- Sample installers + availability + a few bookings.
+-- All passwords are bcrypt('demo1234'); change before production.
+
+BEGIN;
+
+-- Demo password hash for 'demo1234'
+-- Generated with: node -e "console.log(require('bcrypt').hashSync('demo1234', 10))"
+-- Replace if you regenerate.
+DO $$
+DECLARE pw TEXT := '$2b$10$.2P40NWtgXQiylgmJWIprO6.cML8l.wHfMwzB29jAd01uVXyjofka';
+BEGIN
+
+INSERT INTO installers
+ (slug, email, password_hash, business_name, contact_name, phone, headline, bio,
+ city, state, zip, service_radius_miles, travel_available, team_size, founded_year, website,
+ market_segments, materials, brands_handled, accreditations,
+ verified, verified_at, verified_by, insurance_on_file, insurance_expires,
+ tier, subscription_status, response_time_hours, status, profile_complete)
+VALUES
+ ('atelier-bond-nyc', 'demo+bond@example.com', pw, 'Atelier Bond Wallcoverings',
+ 'Marcus Bond', '+1-212-555-0140',
+ 'Hand-painted murals & silk specialists, downtown Manhattan',
+ 'Three generations of paperhangers. We work primarily with hand-painted papers and metallic-leaf installations across luxury residential and boutique hospitality.',
+ 'New York', 'NY', '10013', 75, true, 6, 1992, 'https://example.com/bond',
+ ARRAY['luxury_residential','hospitality','retail']::text[],
+ ARRAY['silk','hand_painted','metallic_leaf','grasscloth','mural']::text[],
+ ARRAY['Fromental','de Gournay','Maya Romanoff','Phillip Jeffries']::text[],
+ ARRAY['WIA Certified Installer','Maya Romanoff Certified','Fromental Trained']::text[],
+ true, now() - interval '60 days', 'NPH staff', true, '2027-03-15',
+ 'signature', 'active', 6, 'active', true),
+
+ ('paperworks-collective-la', 'demo+paperworks@example.com', pw, 'Paperworks Collective',
+ 'Diana Reyes', '+1-323-555-0118',
+ 'Five-installer studio serving West LA luxury residential',
+ 'A West LA studio specializing in grasscloth, silk, and oversized murals. Frequent collaborators with the trade community across Beverly Hills, Bel Air, and Malibu.',
+ 'Los Angeles', 'CA', '90048', 60, false, 5, 2008, 'https://example.com/paperworks',
+ ARRAY['luxury_residential','retail']::text[],
+ ARRAY['grasscloth','silk','vinyl','mural']::text[],
+ ARRAY['Phillip Jeffries','Schumacher','Cole & Son','Designer Wallcoverings']::text[],
+ ARRAY['WIA Certified','OSHA-30']::text[],
+ true, now() - interval '30 days', 'NPH staff', true, '2026-12-01',
+ 'signature', 'active', 8, 'active', true),
+
+ ('rivera-installs-miami', 'demo+rivera@example.com', pw, 'Rivera Installs',
+ 'Luis Rivera', '+1-305-555-0173',
+ 'Hospitality-grade installation, Miami & South Florida',
+ 'Hospitality-focused crew with deep experience installing across hotels, restaurants, and yacht interiors. Five-person team, FL-licensed, fully insured.',
+ 'Miami', 'FL', '33131', 100, true, 5, 2014, 'https://example.com/rivera',
+ ARRAY['hospitality','luxury_residential','yacht']::text[],
+ ARRAY['vinyl','grasscloth','digital_print']::text[],
+ ARRAY['Wolf-Gordon','Maharam','Phillip Jeffries']::text[],
+ ARRAY['WIA Certified']::text[],
+ true, now() - interval '90 days', 'NPH staff', true, '2026-08-22',
+ 'pro', 'active', 12, 'active', true),
+
+ ('north-loop-paper-co', 'demo+northloop@example.com', pw, 'North Loop Paper Co.',
+ 'Eleanor Park', '+1-312-555-0192',
+ 'Chicago Loop & North Shore residential specialist',
+ 'Solo operator with 18 years experience. Take a small number of jobs per quarter; specialize in delicate hand-screened papers.',
+ 'Chicago', 'IL', '60601', 45, false, 1, 2007, 'https://example.com/northloop',
+ ARRAY['luxury_residential']::text[],
+ ARRAY['hand_screened','grasscloth','silk']::text[],
+ ARRAY['Cole & Son','Schumacher','Farrow & Ball']::text[],
+ ARRAY['WIA Certified']::text[],
+ true, now() - interval '14 days', 'NPH staff', true, '2027-01-10',
+ 'pro', 'active', 24, 'active', true),
+
+ ('oakwood-wallcoverings-tx', 'demo+oakwood@example.com', pw, 'Oakwood Wallcoverings',
+ 'Jack Oakley', '+1-214-555-0145',
+ 'Dallas–Fort Worth contract & residential',
+ 'DFW-based two-person crew. Mix of contract and luxury residential. Travel within Texas for the right project.',
+ 'Dallas', 'TX', '75201', 80, true, 2, 2015, 'https://example.com/oakwood',
+ ARRAY['luxury_residential','retail','hospitality']::text[],
+ ARRAY['vinyl','grasscloth','mural']::text[],
+ ARRAY['Wolf-Gordon','Designer Wallcoverings']::text[],
+ ARRAY[]::text[],
+ false, NULL, NULL, false, NULL,
+ 'basic', 'inactive', 24, 'pending', false);
+
+-- Recurring weekly availability for the first three installers
+INSERT INTO installer_availability (installer_id, day_of_week, start_time, end_time, timezone)
+SELECT i.id, dow, '09:00'::time, '17:00'::time, 'America/Los_Angeles'
+FROM installers i
+CROSS JOIN generate_series(1,5) AS dow -- Mon..Fri
+WHERE i.slug IN ('atelier-bond-nyc','paperworks-collective-la','rivera-installs-miami','north-loop-paper-co');
+
+-- A demo time-off block for Bond
+INSERT INTO installer_time_off (installer_id, start_at, end_at, reason, all_day)
+SELECT id, now() + interval '14 days', now() + interval '17 days', 'Industry trade show', true
+FROM installers WHERE slug='atelier-bond-nyc';
+
+-- Sample portfolio entries
+INSERT INTO installer_portfolio (installer_id, title, description, image_url, market_segment, material, brand, city, state, year, display_order)
+SELECT i.id, 'Tribeca penthouse — Fromental hand-painted dining room',
+ 'Custom-color Fromental panels installed across a 14-foot dining room with curved bay window detail.',
+ '/img/portfolio-placeholder-1.jpg',
+ 'luxury_residential', 'hand_painted', 'Fromental', 'New York', 'NY', 2024, 1
+FROM installers i WHERE i.slug='atelier-bond-nyc';
+
+INSERT INTO installer_portfolio (installer_id, title, description, image_url, market_segment, material, brand, city, state, year, display_order)
+SELECT i.id, 'Beverly Hills primary suite — Phillip Jeffries grasscloth',
+ 'Floor-to-ceiling grasscloth across a 22-foot primary suite. Seamless paper match across four corners.',
+ '/img/portfolio-placeholder-2.jpg',
+ 'luxury_residential', 'grasscloth', 'Phillip Jeffries', 'Beverly Hills', 'CA', 2025, 1
+FROM installers i WHERE i.slug='paperworks-collective-la';
+
+-- A demo booking
+INSERT INTO bookings
+ (installer_id, customer_name, customer_email, customer_phone, project_type, market_segment,
+ material, brand, surfaces, rooms, square_feet, budget_band,
+ address_line1, city, state, zip,
+ scheduled_start, scheduled_end, status, customer_notes, source)
+SELECT i.id, 'Eleanor Carlyle', 'eleanor@example.com', '+1-310-555-0166',
+ 'consultation', 'luxury_residential',
+ 'hand_painted', 'de Gournay', 'Dining room walls', 'Dining room', 280, '50k_plus',
+ '123 Beverly Glen Blvd', 'Los Angeles', 'CA', '90077',
+ now() + interval '7 days' + interval '10 hours',
+ now() + interval '7 days' + interval '11 hours',
+ 'confirmed',
+ 'Looking for a quote on de Gournay Earlham in custom blue colorway. Would like a site visit before committing.',
+ 'web'
+FROM installers i WHERE i.slug='paperworks-collective-la';
+
+END $$;
+
+COMMIT;
diff --git a/dns/apply-cloudflare-zone.sh b/dns/apply-cloudflare-zone.sh
new file mode 100755
index 0000000..99a2577
--- /dev/null
+++ b/dns/apply-cloudflare-zone.sh
@@ -0,0 +1,381 @@
+#!/usr/bin/env bash
+# apply-cloudflare-zone.sh
+# Creates a Cloudflare zone and DNS records for nationalpaperhangers.com.
+#
+# USAGE:
+# bash dns/apply-cloudflare-zone.sh # DRY-RUN (default — prints curl calls, executes nothing)
+# bash dns/apply-cloudflare-zone.sh --commit # EXECUTE API calls
+#
+# REQUIREMENTS:
+# - CF_API_TOKEN must be set in env with Zone:Edit + DNS:Edit permissions.
+# The script verifies token capabilities before proceeding.
+# - curl + python3 (for pretty-printing JSON) must be installed.
+#
+# The script is idempotent: it skips records that already exist.
+# ─────────────────────────────────────────────────────────────────────────────
+
+set -euo pipefail
+
+# ─── Config ───────────────────────────────────────────────────────────────────
+DOMAIN="nationalpaperhangers.com"
+KAMATERA_IP="45.61.58.125"
+CF_API="https://api.cloudflare.com/client/v4"
+
+# ─── Protected domains — never touch these ────────────────────────────────────
+PROTECTED_DOMAINS=(
+ "designerwallcoverings.com"
+ "studentdebtcrisis.org"
+ "studentdebtcrisiscenter.org"
+)
+
+# ─── Color helpers ────────────────────────────────────────────────────────────
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+log() { echo -e "${CYAN}[INFO]${NC} $*"; }
+ok() { echo -e "${GREEN}[OK]${NC} $*"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
+die() { echo -e "${RED}[FAIL]${NC} $*" >&2; exit 1; }
+
+# ─── Parse args ───────────────────────────────────────────────────────────────
+COMMIT=false
+for arg in "$@"; do
+ [[ "$arg" == "--commit" ]] && COMMIT=true
+done
+
+if [[ "$COMMIT" == "false" ]]; then
+ echo ""
+ echo -e "${YELLOW}═══════════════════════════════════════════════════════${NC}"
+ echo -e "${YELLOW} DRY-RUN MODE — no API calls will be executed${NC}"
+ echo -e "${YELLOW} Re-run with --commit to apply changes${NC}"
+ echo -e "${YELLOW}═══════════════════════════════════════════════════════${NC}"
+ echo ""
+fi
+
+# ─── Guard: protected domain check ────────────────────────────────────────────
+for protected in "${PROTECTED_DOMAINS[@]}"; do
+ if [[ "$DOMAIN" == "$protected" ]]; then
+ die "BLOCKED: $DOMAIN is on the DNS do-not-touch list. Exiting without changes."
+ fi
+done
+ok "Domain $DOMAIN is not on the protected list."
+
+# ─── Guard: CF_API_TOKEN must be set ──────────────────────────────────────────
+if [[ -z "${CF_API_TOKEN:-}" ]]; then
+ die "CF_API_TOKEN is not set. Export it before running:\n export CF_API_TOKEN=<your-token>"
+fi
+
+# ─── Token capability check — must have Zone:Edit ─────────────────────────────
+log "Verifying token capabilities via /user/tokens/verify ..."
+
+VERIFY_RESP=$(curl -sf -X GET "${CF_API}/user/tokens/verify" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json") || die "Token verification request failed. Check CF_API_TOKEN."
+
+TOKEN_STATUS=$(echo "$VERIFY_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('status','unknown'))" 2>/dev/null || echo "unknown")
+
+if [[ "$TOKEN_STATUS" != "active" ]]; then
+ die "Token is not active (status=$TOKEN_STATUS). Mint a new token and retry."
+fi
+
+# Check for Zone:Edit permission (policy effect = "allow", resource includes zone)
+# The /user/tokens/verify endpoint does not enumerate permissions granularly,
+# so we probe for zone-create capability by attempting a preflight list.
+# A DNS:Edit-only token returns HTTP 403 on /zones POST attempts.
+# We detect this by checking the token's permission list from /user/tokens/:<id>
+# — but token ID is opaque from the bearer alone. Instead we attempt a safe
+# /zones?name=<domain>&status=active GET, which succeeds on both DNS:Edit and
+# Zone:Edit tokens, and then attempt zone creation only on --commit.
+# The script therefore checks at commit time whether the create call is
+# authorized and fails-fast with a clear error if not.
+
+ZONES_CHECK=$(curl -sf -X GET "${CF_API}/zones?name=${DOMAIN}&status=active" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json") || die "Could not reach Cloudflare API. Check network and token."
+
+EXISTING_ZONE_ID=$(echo "$ZONES_CHECK" | python3 -c \
+ "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
+
+ok "Token is active and can query zones."
+
+if [[ -n "$EXISTING_ZONE_ID" ]]; then
+ ok "Zone already exists: $DOMAIN (id=$EXISTING_ZONE_ID)"
+ ZONE_ID="$EXISTING_ZONE_ID"
+ ZONE_CREATED=false
+else
+ log "Zone not found in Cloudflare — will create it."
+ ZONE_ID=""
+ ZONE_CREATED=false
+fi
+
+# ─── Helper: CF API call ──────────────────────────────────────────────────────
+# Usage: cf_api METHOD PATH BODY
+# In dry-run mode: prints the call. In commit mode: executes and returns response.
+cf_api() {
+ local method="$1"
+ local path="$2"
+ local body="${3:-}"
+
+ if [[ "$COMMIT" == "false" ]]; then
+ echo -e " ${CYAN}[DRY-RUN]${NC} curl -s -X ${method} '${CF_API}${path}' \\"
+ echo " -H 'Authorization: Bearer \$CF_API_TOKEN' \\"
+ echo " -H 'Content-Type: application/json' \\"
+ if [[ -n "$body" ]]; then
+ echo " --data '${body}'"
+ fi
+ echo ""
+ echo "DRYRUN_OK"
+ return 0
+ fi
+
+ local resp
+ resp=$(curl -sf -X "$method" "${CF_API}${path}" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ ${body:+--data "$body"}) || { echo "CURL_FAIL"; return 1; }
+
+ echo "$resp"
+}
+
+# ─── Step 1: Create zone (if needed) ──────────────────────────────────────────
+if [[ -z "$ZONE_ID" ]]; then
+ log "Creating zone: $DOMAIN"
+
+ # Read account ID from YAML comment block (expect user to fill it in),
+ # or fall back to auto-detect from token's accessible accounts.
+ ACCOUNT_RESP=$(curl -sf -X GET "${CF_API}/accounts" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json") || die "Could not list accounts. Token may lack Zone:Edit."
+
+ ACCOUNT_ID=$(echo "$ACCOUNT_RESP" | python3 -c \
+ "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
+
+ if [[ -z "$ACCOUNT_ID" ]]; then
+ die "Could not determine Cloudflare account ID.\nThis usually means the token lacks Zone:Edit permission.\nMint a new token with Zone:Edit + DNS:Edit and retry."
+ fi
+
+ log "Account ID resolved: $ACCOUNT_ID"
+
+ ZONE_BODY="{\"name\":\"${DOMAIN}\",\"account\":{\"id\":\"${ACCOUNT_ID}\"},\"jump_start\":false}"
+ ZONE_RESP=$(cf_api POST "/zones" "$ZONE_BODY")
+
+ if [[ "$ZONE_RESP" == "DRYRUN_OK" ]]; then
+ ZONE_ID="DRY_RUN_ZONE_ID"
+ ZONE_CREATED=true
+ else
+ # Check for permission error
+ ZONE_SUCCESS=$(echo "$ZONE_RESP" | python3 -c \
+ "import sys,json; d=json.load(sys.stdin); print(d.get('success','false'))" 2>/dev/null || echo "false")
+
+ if [[ "$ZONE_SUCCESS" != "True" && "$ZONE_SUCCESS" != "true" ]]; then
+ ERRMSG=$(echo "$ZONE_RESP" | python3 -c \
+ "import sys,json; d=json.load(sys.stdin); print(d.get('errors',[])[0].get('message','unknown'))" 2>/dev/null || echo "unknown")
+ # Specific guidance for the most common failure
+ if echo "$ERRMSG" | grep -qi "permission\|not allowed\|forbidden"; then
+ die "Zone creation failed: needs Zone:Edit perm\nError: $ERRMSG\nMint a new token with Zone:Edit permission (see cloudflare-zone.yaml for click-path)."
+ fi
+ die "Zone creation failed: $ERRMSG"
+ fi
+
+ ZONE_ID=$(echo "$ZONE_RESP" | python3 -c \
+ "import sys,json; print(json.load(sys.stdin)['result']['id'])" 2>/dev/null || echo "")
+ ZONE_CREATED=true
+ ok "Zone created: $ZONE_ID"
+
+ # After zone creation, Cloudflare assigns nameservers.
+ # Print them so Steve knows what to set in GoDaddy.
+ NS1=$(echo "$ZONE_RESP" | python3 -c \
+ "import sys,json; ns=json.load(sys.stdin)['result'].get('name_servers',[]); print(ns[0] if ns else 'see CF dashboard')" 2>/dev/null || echo "see CF dashboard")
+ NS2=$(echo "$ZONE_RESP" | python3 -c \
+ "import sys,json; ns=json.load(sys.stdin)['result'].get('name_servers',[]); print(ns[1] if len(ns)>1 else 'see CF dashboard')" 2>/dev/null || echo "see CF dashboard")
+
+ echo ""
+ echo -e "${YELLOW}ACTION REQUIRED — Update GoDaddy Nameservers:${NC}"
+ echo " Log in to GoDaddy → My Products → nationalpaperhangers.com"
+ echo " DNS → Nameservers → I'll use my own nameservers"
+ echo " NS1: $NS1"
+ echo " NS2: $NS2"
+ echo " Save. Propagation: up to 48h."
+ echo ""
+ fi
+fi
+
+# ─── Helper: check if a DNS record already exists ─────────────────────────────
+# Returns "EXISTS" if a record with matching type+name+content is found.
+record_exists() {
+ local zone_id="$1"
+ local type="$2"
+ local name="$3"
+ local content="$4"
+
+ [[ "$zone_id" == "DRY_RUN_ZONE_ID" ]] && echo "SKIP" && return
+
+ local resp
+ resp=$(curl -sf -X GET \
+ "${CF_API}/zones/${zone_id}/dns_records?type=${type}&name=${name}.${DOMAIN}" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json") || { echo "ERROR"; return; }
+
+ local found
+ found=$(echo "$resp" | python3 -c "
+import sys, json
+data = json.load(sys.stdin)
+records = data.get('result', [])
+content_check = '${content}'.lower()
+for r in records:
+ if r.get('content','').lower() == content_check:
+ print('EXISTS')
+ sys.exit(0)
+print('NOT_FOUND')
+" 2>/dev/null || echo "NOT_FOUND")
+
+ echo "$found"
+}
+
+# ─── Helper: create a DNS record ──────────────────────────────────────────────
+create_record() {
+ local type="$1"
+ local name="$2"
+ local content="$3"
+ local proxied="${4:-false}"
+ local ttl="${5:-3600}"
+ local priority="${6:-}"
+ local comment="${7:-}"
+
+ local label="${type} ${name} → ${content}"
+
+ # Idempotency check
+ local exists
+ exists=$(record_exists "$ZONE_ID" "$type" "$name" "$content")
+ if [[ "$exists" == "EXISTS" ]]; then
+ warn "SKIP (already exists): $label"
+ return
+ fi
+
+ log "Creating record: $label"
+
+ local body
+ if [[ -n "$priority" ]]; then
+ body=$(python3 -c "import json; print(json.dumps({
+ 'type':'${type}','name':'${name}','content':'${content}',
+ 'ttl':${ttl},'proxied':$(echo $proxied | tr '[:upper:]' '[:lower:]'),
+ 'priority':${priority},'comment':'${comment}'
+ }))")
+ else
+ body=$(python3 -c "import json; print(json.dumps({
+ 'type':'${type}','name':'${name}','content':'${content}',
+ 'ttl':${ttl},'proxied':$(echo $proxied | tr '[:upper:]' '[:lower:]'),
+ 'comment':'${comment}'
+ }))")
+ fi
+
+ local resp
+ resp=$(cf_api POST "/zones/${ZONE_ID}/dns_records" "$body")
+
+ if [[ "$resp" == "DRYRUN_OK" ]]; then
+ return
+ fi
+
+ local success
+ success=$(echo "$resp" | python3 -c \
+ "import sys,json; print(json.load(sys.stdin).get('success',False))" 2>/dev/null || echo "false")
+
+ if [[ "$success" == "True" || "$success" == "true" ]]; then
+ ok "Created: $label"
+ else
+ local err
+ err=$(echo "$resp" | python3 -c \
+ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0].get('message','unknown') if errs else 'unknown')" 2>/dev/null || echo "unknown")
+ warn "FAILED to create $label: $err"
+ fi
+}
+
+# ─── Step 2: Apply DNS records ────────────────────────────────────────────────
+log "Applying DNS records to zone: $DOMAIN"
+
+# A records — web (proxied)
+create_record "A" "@" "$KAMATERA_IP" "true" "1" "" "Apex to Kamatera production server (proxied)"
+create_record "A" "www" "$KAMATERA_IP" "true" "1" "" "www to Kamatera production server (proxied)"
+
+# MX records — Migadu (not proxied)
+create_record "MX" "@" "aspmx1.migadu.com" "false" "3600" "10" "Migadu primary MX"
+create_record "MX" "@" "aspmx2.migadu.com" "false" "3600" "20" "Migadu secondary MX"
+
+# SPF record
+create_record "TXT" "@" "v=spf1 include:spf.migadu.com -all" "false" "3600" "" "SPF - Migadu authorized sender"
+
+# DKIM CNAME delegates
+create_record "CNAME" "key1._domainkey" "key1.nationalpaperhangers.com._domainkey.migadu.com" "false" "3600" "" "DKIM key1 - Migadu delegate"
+create_record "CNAME" "key2._domainkey" "key2.nationalpaperhangers.com._domainkey.migadu.com" "false" "3600" "" "DKIM key2 - Migadu delegate"
+create_record "CNAME" "key3._domainkey" "key3.nationalpaperhangers.com._domainkey.migadu.com" "false" "3600" "" "DKIM key3 - Migadu delegate"
+
+# Migadu domain verification TXT
+# IMPORTANT: replace the token value with what Migadu shows in its domain wizard
+MIGADU_VERIFY_TOKEN="${MIGADU_VERIFY_TOKEN:-REPLACE_WITH_MIGADU_VERIFY_TOKEN}"
+if [[ "$MIGADU_VERIFY_TOKEN" == "REPLACE_WITH_MIGADU_VERIFY_TOKEN" ]]; then
+ warn "MIGADU_VERIFY_TOKEN is not set. Skipping domain verification TXT record."
+ warn "Set it: export MIGADU_VERIFY_TOKEN=<token-from-migadu-admin> then re-run."
+else
+ create_record "TXT" "@" "hosted-email-verify=${MIGADU_VERIFY_TOKEN}" "false" "3600" "" "Migadu domain ownership verification"
+fi
+
+# DMARC — Phase 1 (p=none, monitor only)
+create_record "TXT" "_dmarc" \
+ "v=DMARC1; p=none; rua=mailto:info@designerwallcoverings.com; ruf=mailto:info@designerwallcoverings.com; fo=1; adkim=r; aspf=r" \
+ "false" "3600" "" "DMARC Phase 1 - monitor only. Update p=none to p=quarantine after 14 days."
+
+# ─── Step 3: Zone-level settings ─────────────────────────────────────────────
+if [[ "$COMMIT" == "true" && -n "$ZONE_ID" && "$ZONE_ID" != "DRY_RUN_ZONE_ID" ]]; then
+ log "Applying zone settings (HTTPS, min TLS) ..."
+
+ # Always use HTTPS
+ curl -sf -X PATCH "${CF_API}/zones/${ZONE_ID}/settings/always_use_https" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data '{"value":"on"}' > /dev/null && ok "Always Use HTTPS: on" || warn "Could not set Always Use HTTPS"
+
+ # Minimum TLS 1.2
+ curl -sf -X PATCH "${CF_API}/zones/${ZONE_ID}/settings/min_tls_version" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data '{"value":"1.2"}' > /dev/null && ok "Min TLS version: 1.2" || warn "Could not set min TLS"
+
+ # SSL mode = full (strict requires origin cert — certbot must run first;
+ # start with "full" and upgrade to "strict" after LE cert is confirmed)
+ curl -sf -X PATCH "${CF_API}/zones/${ZONE_ID}/settings/ssl" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data '{"value":"full"}' > /dev/null && ok "SSL mode: full (upgrade to full_strict after LE cert)" || warn "Could not set SSL mode"
+
+else
+ log "[DRY-RUN] Would apply zone settings: always_use_https=on, min_tls=1.2, ssl=full"
+fi
+
+# ─── Summary ─────────────────────────────────────────────────────────────────
+echo ""
+echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
+if [[ "$COMMIT" == "true" ]]; then
+ echo -e "${GREEN} DONE. Records applied to $DOMAIN${NC}"
+ echo ""
+ echo " Next steps:"
+ echo " 1. If zone was newly created: update GoDaddy NS to Cloudflare NS"
+ echo " (nameservers printed above; GoDaddy propagation up to 48h)"
+ echo " 2. Set MIGADU_VERIFY_TOKEN env var and re-run --commit to add"
+ echo " the Migadu domain verification TXT (if skipped above)"
+ echo " 3. In Migadu admin: add nationalpaperhangers.com, create"
+ echo " info@nationalpaperhangers.com mailbox"
+ echo " 4. After LE cert lands on Kamatera: upgrade CF SSL to Full (Strict)"
+ echo " curl -X PATCH ...zones/\$ZONE_ID/settings/ssl --data '{\"value\":\"full_strict\"}'"
+ echo " 5. Day 14: update DMARC from p=none to p=quarantine"
+ echo " (check aggregate reports at info@designerwallcoverings.com first)"
+ echo " 6. Update George env: SMTP_HOST=smtp.migadu.com SMTP_PORT=587"
+ echo " SMTP_USER=info@nationalpaperhangers.com SMTP_PASS=<migadu-app-pw>"
+else
+ echo -e "${YELLOW} DRY-RUN complete. No changes made.${NC}"
+ echo -e " Re-run with ${CYAN}--commit${NC} to apply."
+fi
+echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
+echo ""
diff --git a/dns/cloudflare-zone.yaml b/dns/cloudflare-zone.yaml
new file mode 100644
index 0000000..5a4a3a1
--- /dev/null
+++ b/dns/cloudflare-zone.yaml
@@ -0,0 +1,139 @@
+# cloudflare-zone.yaml
+# Declarative Cloudflare zone configuration for nationalpaperhangers.com
+#
+# BEFORE YOU RUN apply-cloudflare-zone.sh:
+#
+# 1. Mint a NEW Cloudflare API token with Zone:Edit permission.
+# The current token in secrets-manager is Zone:DNS:Edit only —
+# it cannot add the domain to Cloudflare (zone creation requires Zone:Edit).
+#
+# EXACT CLICK-PATH (8 clicks from logged-in state):
+# ─────────────────────────────────────────────────
+# 1. dash.cloudflare.com → top-right avatar → "My Profile"
+# 2. Left sidebar → "API Tokens"
+# 3. Button: "Create Token"
+# 4. Select: "Custom token" (bottom of template list) → "Get started"
+# 5. Token name: "nationalpaperhangers-zone-edit"
+# 6. Permissions row 1: Zone | Zone | Edit
+# (click "+ Add more" to add a second permission row)
+# Permissions row 2: Zone | DNS | Edit
+# 7. Zone Resources: Include | Specific zone | nationalpaperhangers.com
+# (If the domain is not yet in CF, set to "All zones" for this one run,
+# then rotate to a scoped token afterward.)
+# 8. (Optional but recommended) Client IP Address Filtering:
+# Add 45.61.58.125 (Kamatera) and your Mac2 egress IP.
+# 9. Click "Continue to summary" → "Create Token"
+# 10. Copy token immediately — shown ONCE.
+# Store via: node ~/Projects/secrets-manager/cli.js add CF_ZONE_EDIT_TOKEN_NPH <value>
+#
+# 2. Set CF_API_TOKEN in your shell:
+# export CF_API_TOKEN=$(node ~/Projects/secrets-manager/cli.js get CF_ZONE_EDIT_TOKEN_NPH)
+#
+# 3. Run: bash dns/apply-cloudflare-zone.sh (dry-run, prints API calls)
+# bash dns/apply-cloudflare-zone.sh --commit (executes)
+#
+# ─────────────────────────────────────────────────────────────────────────────
+# Email provider: Migadu Mini ($90/yr, unlimited domains)
+# DKIM-VERIFY-TOKEN: obtain from Migadu domain setup wizard and replace below
+# ─────────────────────────────────────────────────────────────────────────────
+
+zone:
+ name: nationalpaperhangers.com
+ # Cloudflare account ID — find at dash.cloudflare.com (right sidebar on overview)
+ account_id: "REPLACE_WITH_YOUR_CF_ACCOUNT_ID"
+ # SSL mode applied once origin cert (Let's Encrypt via certbot-dns-cloudflare) is live
+ ssl_mode: full_strict
+ # Always redirect HTTP → HTTPS (Cloudflare edge setting)
+ always_use_https: true
+ # Minimum TLS version
+ min_tls_version: "1.2"
+
+records:
+ # ─── WEB — apex + www → Kamatera (PROXIED) ────────────────────────────────
+ - type: A
+ name: "@"
+ content: "45.61.58.125"
+ proxied: true
+ ttl: 1 # 1 = "Auto" in CF UI (required when proxied: true)
+ comment: "Apex → Kamatera production server (Cloudflare proxied)"
+
+ - type: A
+ name: "www"
+ content: "45.61.58.125"
+ proxied: true
+ ttl: 1
+ comment: "www → Kamatera production server (Cloudflare proxied)"
+
+ # ─── MX — Migadu inbound mail servers ─────────────────────────────────────
+ # MX records must NOT be proxied (Cloudflare does not proxy mail traffic)
+ - type: MX
+ name: "@"
+ content: "aspmx1.migadu.com"
+ priority: 10
+ proxied: false
+ ttl: 3600
+ comment: "Migadu primary MX"
+
+ - type: MX
+ name: "@"
+ content: "aspmx2.migadu.com"
+ priority: 20
+ proxied: false
+ ttl: 3600
+ comment: "Migadu secondary MX"
+
+ # ─── SPF — authorize Migadu to send on behalf of this domain ──────────────
+ # -all = hard fail any sender not listed (correct for Migadu; switch from ~all
+ # only after confirming DKIM is working and no legitimate mail is failing SPF)
+ - type: TXT
+ name: "@"
+ content: "v=spf1 include:spf.migadu.com -all"
+ proxied: false
+ ttl: 3600
+ comment: "SPF — Migadu authorized sender; hard fail all others"
+
+ # ─── DKIM — 3-key CNAME delegation (Migadu pattern) ──────────────────────
+ # These CNAMEs are static; Migadu manages the underlying key rotation.
+ # CF MUST NOT proxy CNAME records used for DKIM (set proxied: false).
+ - type: CNAME
+ name: "key1._domainkey"
+ content: "key1.nationalpaperhangers.com._domainkey.migadu.com"
+ proxied: false
+ ttl: 3600
+ comment: "DKIM key1 delegate → Migadu"
+
+ - type: CNAME
+ name: "key2._domainkey"
+ content: "key2.nationalpaperhangers.com._domainkey.migadu.com"
+ proxied: false
+ ttl: 3600
+ comment: "DKIM key2 delegate → Migadu"
+
+ - type: CNAME
+ name: "key3._domainkey"
+ content: "key3.nationalpaperhangers.com._domainkey.migadu.com"
+ proxied: false
+ ttl: 3600
+ comment: "DKIM key3 delegate → Migadu"
+
+ # ─── Migadu domain verification (required to claim domain in Migadu admin) ─
+ # Replace the token value with what Migadu shows in its domain wizard.
+ # This TXT record can coexist with SPF because CF allows multiple TXT on @.
+ - type: TXT
+ name: "@"
+ content: "hosted-email-verify=REPLACE_WITH_MIGADU_VERIFY_TOKEN"
+ proxied: false
+ ttl: 3600
+ comment: "Migadu domain ownership verification — replace token value"
+
+ # ─── DMARC roll-out ───────────────────────────────────────────────────────
+ # Phase 1 (Days 1–14): p=none (monitor only, no mail rejected)
+ # Aggregate reports (rua) go to info@designerwallcoverings.com (Steve monitors)
+ # After 14 days with clean reports: change p=none → p=quarantine
+ # After another 14 clean days: change to p=reject (optional, most strict)
+ - type: TXT
+ name: "_dmarc"
+ content: "v=DMARC1; p=none; rua=mailto:info@designerwallcoverings.com; ruf=mailto:info@designerwallcoverings.com; fo=1; adkim=r; aspf=r"
+ proxied: false
+ ttl: 3600
+ comment: "DMARC Phase 1 (monitor). Update p=none→p=quarantine after 14 days of clean reports."
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..31f7a63
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+ apps: [
+ {
+ name: 'national-paper-hangers',
+ script: 'server.js',
+ cwd: __dirname,
+ instances: 1,
+ exec_mode: 'fork',
+ autorestart: true,
+ max_memory_restart: '512M',
+ env: { NODE_ENV: 'development' },
+ env_production: { NODE_ENV: 'production' }
+ }
+ ]
+};
diff --git a/ecosystem.kamatera.config.js b/ecosystem.kamatera.config.js
new file mode 100644
index 0000000..6e0dc68
--- /dev/null
+++ b/ecosystem.kamatera.config.js
@@ -0,0 +1,56 @@
+// pm2 ecosystem file for Kamatera VPS deployment.
+// Distinct filename (ecosystem.kamatera.config.js) so it never collides with
+// the Mac2 ecosystem.config.js used by the local pm2 fleet.
+//
+// Usage on Kamatera:
+// pm2 start /root/Projects/NationalPaperHangers/ecosystem.kamatera.config.js --env production
+// pm2 save
+
+'use strict';
+
+module.exports = {
+ apps: [
+ {
+ name: 'national-paper-hangers',
+
+ // Entry point relative to cwd below.
+ script: 'server.js',
+
+ // Absolute path on Kamatera — never /root/public-projects/ (internal app).
+ cwd: '/root/Projects/NationalPaperHangers',
+
+ // Single process, fork mode. The app is stateless-session via PG so
+ // horizontal scale can be added later by bumping instances + switching
+ // to cluster mode, but start simple.
+ instances: 1,
+ exec_mode: 'fork',
+
+ // Restart automatically if the process exits unexpectedly.
+ autorestart: true,
+
+ // Conservative memory cap for a shared VPS. Raise to 512M if
+ // portfolo image processing is added server-side.
+ max_memory_restart: '400M',
+
+ // Delay between crash restarts: 1s → 2s → 4s … up to 10s.
+ exp_backoff_restart_delay: 100,
+ min_uptime: '10s',
+
+ // Do NOT inherit the shell environment — all secrets via env_production.
+ merge_logs: true,
+ log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
+
+ // ── Environment: production ──────────────────────────────────────────
+ // Fill in every value in /root/Projects/NationalPaperHangers/.env
+ // on Kamatera. The ecosystem file provides the NODE_ENV flag only;
+ // sensitive vars come from the .env file loaded by dotenv at boot.
+ env_production: {
+ NODE_ENV: 'production',
+ PORT: '9765'
+ // All secrets (SESSION_SECRET, STRIPE_*, PGPASSWORD, GEORGE_PASS, etc.)
+ // must be present in .env on Kamatera. They are NOT hard-coded here
+ // to avoid committing secrets to the repo.
+ }
+ }
+ ]
+};
diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..5cdf7ee
--- /dev/null
+++ b/lib/auth.js
@@ -0,0 +1,79 @@
+const bcrypt = require('bcrypt');
+const db = require('./db');
+
+const SALT_ROUNDS = 12;
+
+async function hashPassword(plain) {
+ return bcrypt.hash(plain, SALT_ROUNDS);
+}
+
+async function verifyPassword(plain, hash) {
+ if (!hash) return false;
+ return bcrypt.compare(plain, hash);
+}
+
+// Explicit projection — NEVER include password_hash, claim_token, or
+// claim_token_at in the row that gets attached to req.installer. If a view
+// ever JSON.stringifies installer for debugging, this is the safety net.
+const INSTALLER_SAFE_COLUMNS = `
+ id, slug, email, business_name, contact_name, phone, bio, headline,
+ city, state, zip, country, service_radius_miles, travel_available,
+ team_size, founded_year, website, instagram_handle,
+ market_segments, materials, brands_handled, accreditations,
+ verified, verified_at, verified_by, insurance_on_file, insurance_expires,
+ license_number, license_state,
+ status, claim_status, claimed_at,
+ source_name, source_url, source_scraped_at,
+ tier, subscription_status, stripe_customer_id, stripe_subscription_id,
+ current_period_end, response_time_hours, profile_complete,
+ created_at, updated_at, last_login_at,
+ ad_signals, ad_signals_at, avg_rating, review_count,
+ equipment,
+ latitude, longitude, geo_accuracy, geocoded_at,
+ stripe_account_id, stripe_account_charges_enabled,
+ stripe_account_payouts_enabled, stripe_account_onboarded_at
+`;
+
+async function loadInstaller(id) {
+ if (!id) return null;
+ return db.one(`SELECT ${INSTALLER_SAFE_COLUMNS} FROM installers WHERE id = $1`, [id]);
+}
+
+function requireInstaller(req, res, next) {
+ if (!req.session || !req.session.installerId) {
+ if (req.accepts('html')) return res.redirect('/login?next=' + encodeURIComponent(req.originalUrl));
+ return res.status(401).json({ error: 'auth_required' });
+ }
+ next();
+}
+
+async function attachInstaller(req, res, next) {
+ if (req.session && req.session.installerId) {
+ try {
+ req.installer = await loadInstaller(req.session.installerId);
+ } catch (err) {
+ console.error('[attachInstaller]', err.message);
+ }
+ }
+ res.locals.installer = req.installer || null;
+ next();
+}
+
+function requirePaidTier(req, res, next) {
+ const tier = req.installer && req.installer.tier;
+ if (!req.installer) return res.redirect('/login');
+ if (tier === 'pro' || tier === 'signature' || tier === 'enterprise') return next();
+ if (req.accepts('html')) {
+ return res.redirect('/admin/billing?upgrade=1');
+ }
+ return res.status(402).json({ error: 'upgrade_required' });
+}
+
+module.exports = {
+ hashPassword,
+ verifyPassword,
+ loadInstaller,
+ requireInstaller,
+ attachInstaller,
+ requirePaidTier
+};
diff --git a/lib/auth/policies.js b/lib/auth/policies.js
new file mode 100644
index 0000000..805cb84
--- /dev/null
+++ b/lib/auth/policies.js
@@ -0,0 +1,89 @@
+// lib/auth/policies.js
+//
+// Role-based access middleware factories. Reads req.session.userId (which
+// Phase 2 will start setting; Phase 1 still uses installerId).
+//
+// Phase 1: NOT yet imported from server.js or any route. Exported for
+// Phase 2 cutover. lib/auth.js (legacy installer-only auth) is left
+// completely untouched.
+//
+// Design notes:
+// - Middleware factories only — no module-level DB calls.
+// - Each request hits `roles` once per call. Acceptable for Phase 2;
+// if it becomes hot we can stash roles on req.user during attachUser.
+// - JSON vs HTML response chosen via req.accepts('html'), matching the
+// existing requireInstaller pattern in lib/auth.js.
+
+'use strict';
+
+const usersService = require('../services/users');
+
+// ─────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────
+
+function rejectAuth(req, res) {
+ if (req.accepts && req.accepts('html')) {
+ return res.redirect('/login?next=' + encodeURIComponent(req.originalUrl || '/'));
+ }
+ return res.status(401).json({ error: 'auth_required' });
+}
+
+function rejectForbidden(req, res, role) {
+ if (req.accepts && req.accepts('html')) {
+ return res.status(403).render('public/error', {
+ title: 'Forbidden',
+ message: `This page requires the ${role} role.`
+ });
+ }
+ return res.status(403).json({ error: 'forbidden', required_role: role });
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// requireRole(role | [roles])
+//
+// Allows the request through if the session user holds ANY of the listed
+// roles. Decorates req.user with { id, roles[] } on success.
+// ─────────────────────────────────────────────────────────────────────────
+
+function requireRole(roleOrRoles) {
+ const wanted = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
+
+ return async function policyMiddleware(req, res, next) {
+ try {
+ const userId = req.session && req.session.userId;
+ if (!userId) return rejectAuth(req, res);
+
+ const roles = await usersService.userRoles(userId);
+ const allowed = wanted.some(r => roles.includes(r));
+ if (!allowed) return rejectForbidden(req, res, wanted.join('|'));
+
+ // Decorate request for downstream handlers — cheap, scoped to one req.
+ req.user = req.user || { id: userId };
+ req.user.roles = roles;
+ return next();
+ } catch (e) {
+ return next(e);
+ }
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Convenience policies
+// ─────────────────────────────────────────────────────────────────────────
+
+const requireAdmin = requireRole('admin');
+const requireOps = requireRole(['admin', 'ops']);
+const requireInstallerOwner = requireRole(['admin', 'installer_owner']);
+
+// requireInstallerMember — owner OR member counts; useful for /admin/bookings
+// where any seat on the installer team should be allowed.
+const requireInstallerMember = requireRole(['admin', 'installer_owner', 'installer_member']);
+
+module.exports = {
+ requireRole,
+ requireAdmin,
+ requireOps,
+ requireInstallerOwner,
+ requireInstallerMember
+};
diff --git a/lib/booking-token.js b/lib/booking-token.js
new file mode 100644
index 0000000..c4108e1
--- /dev/null
+++ b/lib/booking-token.js
@@ -0,0 +1,33 @@
+// HMAC-signed booking-view token. Embedded in confirmation-email URLs so that
+// the booking detail page (/bookings/:uuid?t=...) cannot be enumerated by
+// guessing UUIDs. The signing key is BOOKING_SIGNING_SECRET, falling back to
+// SESSION_SECRET (which throws in production if unset).
+
+const crypto = require('crypto');
+
+function getKey() {
+ const k = process.env.BOOKING_SIGNING_SECRET || process.env.SESSION_SECRET;
+ if (!k || k === 'dev-secret-change-me') {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('BOOKING_SIGNING_SECRET (or SESSION_SECRET) must be set in production');
+ }
+ }
+ return k || 'dev-only-not-for-prod';
+}
+
+function sign(uuid) {
+ return crypto.createHmac('sha256', getKey()).update(String(uuid)).digest('base64url').slice(0, 24);
+}
+
+function verify(uuid, supplied) {
+ if (!uuid || !supplied) return false;
+ const expected = sign(uuid);
+ if (expected.length !== supplied.length) return false;
+ try {
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));
+ } catch {
+ return false;
+ }
+}
+
+module.exports = { sign, verify };
diff --git a/lib/compliance.js b/lib/compliance.js
new file mode 100644
index 0000000..7349b30
--- /dev/null
+++ b/lib/compliance.js
@@ -0,0 +1,176 @@
+// Communications-compliance gate. Fail-closed pre-flight + per-recipient
+// scrub utilities. Every outbound send script MUST call assertSendCompliance()
+// before its loop and isSuppressed()/recordAudit() per recipient.
+//
+// Standing rules enforced (from Steve's comms-compliance agent + MEMORY.md):
+// - CAN-SPAM §7(a)(5): physical postal address must appear in every commercial email body.
+// - CAN-SPAM functional opt-out: unsubscribe link must be present + the suppression scrub must run.
+// - CA §17529.5: subject + From accurate, no deceptive headers.
+// - CCPA: opt-outs and deletes must be honored (suppression list).
+// - Steve's rule: per-message audit to PG (`comms_send_audit`); fail-closed if list missing or stale.
+
+const crypto = require('crypto');
+const db = require('./db');
+
+const IS_PROD = process.env.NODE_ENV === 'production';
+
+class ComplianceError extends Error {
+ constructor(code, msg) { super(msg); this.code = code; }
+}
+
+function hasMailingAddress() {
+ const a = (process.env.MAILING_ADDRESS || '').trim();
+ if (!a) return false;
+ if (/TBD|placeholder|TODO|FIXME|localhost/i.test(a)) return false;
+ // Crude shape check: at least 3 comma-separated parts (street, city, state-zip).
+ return a.split(',').filter(Boolean).length >= 3;
+}
+
+function publicUrlOk() {
+ const u = (process.env.PUBLIC_URL || '').trim();
+ if (!u) return false;
+ if (u.includes('localhost') || u.includes('127.0.0.1')) return false;
+ if (IS_PROD && !u.startsWith('https://')) return false;
+ return true;
+}
+
+async function tablesExist() {
+ const r = await db.query(
+ `SELECT
+ to_regclass('public.comms_suppression') IS NOT NULL AS sup,
+ to_regclass('public.comms_send_audit') IS NOT NULL AS aud,
+ to_regclass('public.unsubscribe_tokens') IS NOT NULL AS tok`
+ );
+ const row = r.rows[0] || {};
+ return !!(row.sup && row.aud && row.tok);
+}
+
+// Pre-flight gate. Throws ComplianceError if any prerequisite is missing.
+// Call BEFORE entering any send loop. Per-recipient scrub still required after.
+async function assertSendCompliance({ campaign }) {
+ if (!campaign) throw new ComplianceError('no_campaign', 'campaign label is required');
+
+ if (!hasMailingAddress()) {
+ throw new ComplianceError(
+ 'no_mailing_address',
+ 'MAILING_ADDRESS env var is missing or placeholder. CAN-SPAM §7(a)(5) requires a real, deliverable street address in every outbound footer. Set it in .env (format: "Studio Name, 123 Real Street, City, ST 12345"), then retry.'
+ );
+ }
+ if (!publicUrlOk()) {
+ throw new ComplianceError(
+ 'public_url_invalid',
+ 'PUBLIC_URL must be a real https URL in production (or at minimum non-localhost in dev) so unsubscribe + claim links resolve. Currently: ' + (process.env.PUBLIC_URL || '<unset>')
+ );
+ }
+ if (IS_PROD && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'dev-secret-change-me')) {
+ throw new ComplianceError('no_session_secret', 'SESSION_SECRET must be set in production');
+ }
+ if (!process.env.UNSUBSCRIBE_SIGNING_SECRET && !process.env.SESSION_SECRET) {
+ throw new ComplianceError('no_unsub_key', 'UNSUBSCRIBE_SIGNING_SECRET (or SESSION_SECRET fallback) must be set');
+ }
+ const ok = await tablesExist();
+ if (!ok) {
+ throw new ComplianceError(
+ 'no_compliance_tables',
+ 'comms_suppression / comms_send_audit / unsubscribe_tokens tables are missing in `national_paper_hangers`. Apply db/migrations/004_comms_suppression.sql before sending.'
+ );
+ }
+}
+
+// Per-recipient scrub. Returns true if the address is suppressed.
+async function isSuppressed({ channel, identifier }) {
+ if (!channel || !identifier) return true; // fail-closed on bad input
+ const id = String(identifier).trim().toLowerCase();
+ const r = await db.query(
+ `SELECT 1 FROM comms_suppression WHERE channel=$1 AND identifier=$2 LIMIT 1`,
+ [channel, id]
+ );
+ return r.rowCount > 0;
+}
+
+async function addSuppression({ channel, identifier, reason, source, installerId, notes }) {
+ await db.query(
+ `INSERT INTO comms_suppression (channel, identifier, reason, source, installer_id, notes)
+ VALUES ($1, lower($2), $3, $4, $5, $6)
+ ON CONFLICT (channel, identifier) DO NOTHING`,
+ [channel, identifier, reason, source || null, installerId || null, notes || null]
+ );
+}
+
+async function recordAudit({ channel, campaign, recipient, installerId, decision, reason, subject, messageId, payload }) {
+ await db.query(
+ `INSERT INTO comms_send_audit
+ (channel, campaign, recipient, installer_id, decision, reason, subject, message_id, payload)
+ VALUES ($1,$2,lower($3),$4,$5,$6,$7,$8,$9)`,
+ [channel, campaign, recipient, installerId || null, decision, reason || null, subject || null, messageId || null, payload ? JSON.stringify(payload) : null]
+ );
+}
+
+// Unsubscribe-token helpers. The token is opaque; the row carries the channel,
+// identifier, and campaign so the /unsubscribe handler can act without trust
+// in URL params.
+function unsubKey() {
+ return process.env.UNSUBSCRIBE_SIGNING_SECRET || process.env.SESSION_SECRET || 'dev-only-not-for-prod';
+}
+
+async function mintUnsubscribeToken({ channel, identifier, campaign, installerId }) {
+ const raw = `${channel}:${String(identifier).toLowerCase()}:${campaign}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
+ const token = crypto.createHmac('sha256', unsubKey()).update(raw).digest('base64url').slice(0, 32);
+ await db.query(
+ `INSERT INTO unsubscribe_tokens (token, channel, identifier, campaign, installer_id)
+ VALUES ($1,$2,lower($3),$4,$5) ON CONFLICT (token) DO NOTHING`,
+ [token, channel, identifier, campaign || null, installerId || null]
+ );
+ return token;
+}
+
+async function consumeUnsubscribeToken(token) {
+ if (!token || typeof token !== 'string' || token.length < 16) return null;
+ const r = await db.query(
+ `UPDATE unsubscribe_tokens
+ SET used_at = COALESCE(used_at, now())
+ WHERE token = $1
+ RETURNING channel, identifier, campaign, installer_id, used_at`,
+ [token]
+ );
+ return r.rows[0] || null;
+}
+
+function complianceFooter({ campaign, unsubscribeUrl }) {
+ // Plain HTML footer to be appended to every commercial email body.
+ // Address comes from MAILING_ADDRESS at runtime — assertSendCompliance() has
+ // already verified it's set when this runs.
+ const addr = (process.env.MAILING_ADDRESS || '').trim();
+ return `<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
+<p style="font-size:11px;color:#888;line-height:1.5">
+ This email was sent by National Paper Hangers, ${escapeHtml(addr)}.
+ ${unsubscribeUrl ? `<br><a href="${unsubscribeUrl}" style="color:#888">Unsubscribe</a> from ${escapeHtml(campaign || 'these emails')}.` : ''}
+</p>`;
+}
+
+function listUnsubscribeHeader(unsubscribeUrl) {
+ // RFC 2369 + RFC 8058 headers — Gmail / Outlook surface a 1-click button when present.
+ if (!unsubscribeUrl) return null;
+ return {
+ 'List-Unsubscribe': `<${unsubscribeUrl}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
+ };
+}
+
+function escapeHtml(s) {
+ return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+}
+
+module.exports = {
+ ComplianceError,
+ assertSendCompliance,
+ isSuppressed,
+ addSuppression,
+ recordAudit,
+ mintUnsubscribeToken,
+ consumeUnsubscribeToken,
+ complianceFooter,
+ listUnsubscribeHeader,
+ hasMailingAddress,
+ publicUrlOk
+};
diff --git a/lib/csrf.js b/lib/csrf.js
new file mode 100644
index 0000000..cfd59e3
--- /dev/null
+++ b/lib/csrf.js
@@ -0,0 +1,56 @@
+// Minimal CSRF protection. Generates a per-session token, exposes it via
+// res.locals.csrfToken for forms, and validates state-changing requests via
+// either the `_csrf` form field or the `X-CSRF-Token` header.
+//
+// Skips: GET/HEAD/OPTIONS, /webhooks/* (Stripe sig handles auth), and any
+// request explicitly marked exempt via `req.skipCsrf = true` upstream.
+
+const crypto = require('crypto');
+
+function ensureToken(req) {
+ if (!req.session) return null;
+ if (!req.session.csrfToken) {
+ req.session.csrfToken = crypto.randomBytes(24).toString('base64url');
+ }
+ return req.session.csrfToken;
+}
+
+function csrfMiddleware(req, res, next) {
+ const token = ensureToken(req);
+ res.locals.csrfToken = token;
+
+ const safe = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
+ if (safe) return next();
+
+ // Webhook routes are mounted before this middleware in server.js, so this
+ // is belt-and-suspenders. Stripe signature handles their auth.
+ if (req.path.startsWith('/webhooks/')) return next();
+
+ if (req.skipCsrf) return next();
+
+ const supplied = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
+ if (!token || !supplied) return reject(req, res);
+
+ let a, b;
+ try {
+ a = Buffer.from(token);
+ b = Buffer.from(supplied);
+ } catch {
+ return reject(req, res);
+ }
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return reject(req, res);
+
+ next();
+}
+
+function reject(req, res) {
+ if (req.accepts('json') && !req.accepts('html')) {
+ return res.status(403).json({ error: 'csrf_failed' });
+ }
+ return res.status(403).render('public/error', {
+ title: 'Request blocked',
+ message: 'Form expired or session lost — please reload the page and try again.'
+ });
+}
+
+module.exports = { csrfMiddleware, ensureToken };
diff --git a/lib/db.js b/lib/db.js
new file mode 100644
index 0000000..f799072
--- /dev/null
+++ b/lib/db.js
@@ -0,0 +1,38 @@
+const { Pool } = require('pg');
+
+// node-postgres falls back to process.env.PGPASSWORD when `password` is omitted.
+// An empty-string PGPASSWORD still triggers SASL auth → fails. Strip it.
+if (process.env.PGPASSWORD === '') delete process.env.PGPASSWORD;
+
+// node-postgres requires `password` to be either a non-empty string or omitted
+// entirely. Passing `''` triggers SASL auth and fails with
+// "client password must be a string".
+const pgConfig = {
+ host: process.env.PGHOST || 'localhost',
+ port: parseInt(process.env.PGPORT || '5432', 10),
+ database: process.env.PGDATABASE || 'national_paper_hangers',
+ user: process.env.PGUSER || process.env.USER,
+ max: 10,
+ idleTimeoutMillis: 30000
+};
+if (process.env.PGPASSWORD && process.env.PGPASSWORD.length > 0) {
+ pgConfig.password = process.env.PGPASSWORD;
+}
+const pool = new Pool(pgConfig);
+
+pool.on('error', (err) => {
+ console.error('[pg pool error]', err.message);
+});
+
+module.exports = {
+ pool,
+ query: (text, params) => pool.query(text, params),
+ one: async (text, params) => {
+ const r = await pool.query(text, params);
+ return r.rows[0] || null;
+ },
+ many: async (text, params) => {
+ const r = await pool.query(text, params);
+ return r.rows;
+ }
+};
diff --git a/lib/email.js b/lib/email.js
new file mode 100644
index 0000000..3a9f46d
--- /dev/null
+++ b/lib/email.js
@@ -0,0 +1,101 @@
+// George Gmail agent client.
+// Per Steve's standing rules: every site has its own info@ — this one uses
+// info@nationalpaperhangers.com via the local George agent (account=info).
+// In dev, falls back to console logging if George isn't reachable.
+
+const GEORGE_URL = process.env.GEORGE_URL || 'http://localhost:9850';
+const GEORGE_ACCOUNT = process.env.GEORGE_ACCOUNT || 'info';
+const GEORGE_USER = process.env.GEORGE_USER || '';
+const GEORGE_PASS = process.env.GEORGE_PASS || '';
+const FROM = process.env.EMAIL_FROM || 'info@nationalpaperhangers.com';
+const FROM_NAME = process.env.EMAIL_FROM_NAME || 'National Paper Hangers';
+
+async function sendEmail({ to, subject, html, text, extraHeaders }) {
+ const url = `${GEORGE_URL}/api/send?account=${encodeURIComponent(GEORGE_ACCOUNT)}`;
+ const body = {
+ to,
+ subject,
+ body: html || text,
+ from: FROM,
+ fromName: FROM_NAME
+ };
+ // Pass through RFC 2369 / RFC 8058 List-Unsubscribe + List-Unsubscribe-Post
+ // headers when supplied. George must propagate these to SMTP.
+ if (extraHeaders && typeof extraHeaders === 'object') {
+ body.headers = extraHeaders;
+ }
+ const headers = { 'content-type': 'application/json' };
+ if (GEORGE_USER && GEORGE_PASS) {
+ headers['authorization'] = 'Basic ' + Buffer.from(`${GEORGE_USER}:${GEORGE_PASS}`).toString('base64');
+ }
+ try {
+ const r = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(8000)
+ });
+ if (!r.ok) {
+ const txt = await r.text();
+ console.warn('[email] george non-2xx', r.status, txt.slice(0, 200));
+ return { ok: false, status: r.status, error: txt };
+ }
+ const j = await r.json().catch(() => ({}));
+ return { ok: true, ...j };
+ } catch (err) {
+ console.warn('[email] george unreachable, logging instead:', err.message);
+ console.log('--- EMAIL ---\nto:', to, '\nsubject:', subject, '\nbody:\n', (html || text || '').slice(0, 500), '\n-------------');
+ return { ok: false, mocked: true, error: err.message };
+ }
+}
+
+function bookingConfirmationCustomer({ booking, installer, publicUrl }) {
+ const when = new Date(booking.scheduled_start).toLocaleString('en-US', {
+ weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit'
+ });
+ return {
+ subject: `Booking confirmed with ${installer.business_name}`,
+ html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <h1 style="font-size:24px;margin:0 0 16px;letter-spacing:0.02em">Your booking is confirmed</h1>
+ <p style="font-size:15px;line-height:1.6">Hello ${escapeHtml(booking.customer_name)},</p>
+ <p style="font-size:15px;line-height:1.6">${escapeHtml(installer.business_name)} has accepted your request.</p>
+ <table style="width:100%;border-collapse:collapse;margin:24px 0">
+ <tr><td style="padding:8px 0;color:#666;width:140px">When</td><td style="padding:8px 0">${when}</td></tr>
+ <tr><td style="padding:8px 0;color:#666">Type</td><td style="padding:8px 0">${escapeHtml(booking.project_type || 'consultation')}</td></tr>
+ <tr><td style="padding:8px 0;color:#666">Installer</td><td style="padding:8px 0">${escapeHtml(installer.business_name)}</td></tr>
+ ${installer.phone ? `<tr><td style="padding:8px 0;color:#666">Phone</td><td style="padding:8px 0">${escapeHtml(installer.phone)}</td></tr>` : ''}
+ </table>
+ <p style="font-size:13px;color:#666;line-height:1.6">If you need to reschedule or cancel, reply to this email or visit ${publicUrl}/bookings/${booking.uuid}.</p>
+ <hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
+ <p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
+ </div>`
+ };
+}
+
+function bookingNotificationInstaller({ booking, installer, publicUrl }) {
+ const when = new Date(booking.scheduled_start).toLocaleString('en-US', {
+ weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit'
+ });
+ return {
+ subject: `New booking — ${booking.customer_name} · ${when}`,
+ html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <h1 style="font-size:22px;margin:0 0 16px">New booking on your calendar</h1>
+ <table style="width:100%;border-collapse:collapse;margin:16px 0">
+ <tr><td style="padding:6px 0;color:#666;width:140px">Customer</td><td style="padding:6px 0">${escapeHtml(booking.customer_name)} · ${escapeHtml(booking.customer_email)}${booking.customer_phone ? ' · ' + escapeHtml(booking.customer_phone) : ''}</td></tr>
+ <tr><td style="padding:6px 0;color:#666">When</td><td style="padding:6px 0">${when}</td></tr>
+ <tr><td style="padding:6px 0;color:#666">Type</td><td style="padding:6px 0">${escapeHtml(booking.project_type || 'consultation')}</td></tr>
+ <tr><td style="padding:6px 0;color:#666">Address</td><td style="padding:6px 0">${[booking.address_line1, booking.city, booking.state, booking.zip].filter(Boolean).map(escapeHtml).join(', ') || '—'}</td></tr>
+ <tr><td style="padding:6px 0;color:#666">Notes</td><td style="padding:6px 0">${escapeHtml(booking.customer_notes || '—')}</td></tr>
+ </table>
+ <p><a href="${publicUrl}/admin/bookings" style="display:inline-block;padding:12px 20px;background:#0e0e0e;color:#fff;text-decoration:none;border-radius:2px">Open dashboard</a></p>
+ </div>`
+ };
+}
+
+const { escapeHtml } = require('./utils');
+
+module.exports = {
+ sendEmail,
+ bookingConfirmationCustomer,
+ bookingNotificationInstaller
+};
diff --git a/lib/segment-image.js b/lib/segment-image.js
new file mode 100644
index 0000000..57b3826
--- /dev/null
+++ b/lib/segment-image.js
@@ -0,0 +1,89 @@
+// Per-installer deterministic public-domain image picker.
+// Strategy: pick the installer's primary market_segment, fall through to
+// related segments, and finally to 'generic'. Index = installer.id mod
+// pool_size so the same studio always gets the same photo across renders.
+// PD-only — every entry in manifest.json is Wikimedia Commons public-domain
+// or CC0 (the fetcher rejects anything else).
+
+const path = require('path');
+const fs = require('fs');
+
+let _manifest = null;
+let _byPath = null;
+const FALLBACK = {
+ silk: ['hand_painted', 'mural', 'luxury_residential', 'generic'],
+ hospitality: ['luxury_residential', 'museum', 'generic'],
+ museum: ['luxury_residential', 'hand_painted', 'generic'],
+ hand_painted: ['mural', 'luxury_residential', 'generic'],
+ grasscloth: ['hand_painted', 'luxury_residential', 'generic'],
+ mural: ['hand_painted', 'luxury_residential', 'generic'],
+ luxury_residential: ['hand_painted', 'mural', 'museum', 'generic'],
+ retail: ['hospitality', 'luxury_residential', 'generic'],
+ yacht: ['luxury_residential', 'hospitality', 'generic'],
+ generic: []
+};
+
+function loadManifest() {
+ if (_manifest) return _manifest;
+ const p = path.join(__dirname, '..', 'public', 'img', 'segments', 'manifest.json');
+ try {
+ const txt = fs.readFileSync(p, 'utf8');
+ _manifest = JSON.parse(txt);
+ _byPath = {};
+ for (const it of (_manifest.items || [])) _byPath[it.file] = it;
+ } catch {
+ _manifest = { items: [] };
+ _byPath = {};
+ }
+ return _manifest;
+}
+
+function imagesIn(segment) {
+ const m = loadManifest();
+ return (m.items || []).filter(it => it.segment === segment);
+}
+
+function pickSegmentImage(installer) {
+ if (!installer) return null;
+ const segs = Array.isArray(installer.market_segments) ? installer.market_segments : [];
+ const candidates = [];
+ if (segs.length) candidates.push(segs[0]);
+ if (segs[0] && FALLBACK[segs[0]]) candidates.push(...FALLBACK[segs[0]]);
+ if (!candidates.length) candidates.push('generic');
+ // Always end with 'generic' as final fallback.
+ if (!candidates.includes('generic')) candidates.push('generic');
+
+ for (const seg of candidates) {
+ const pool = imagesIn(seg);
+ if (pool.length) {
+ const idx = Math.abs(Number(installer.id) || 0) % pool.length;
+ return pool[idx];
+ }
+ }
+ return null;
+}
+
+// Public-facing license/attribution string for a manifest item.
+// Cooper Hewitt + Met + Brooklyn Museum entries usually carry a creator;
+// generic site dumps may not. We render this minimally: "Photo: <creator> ·
+// <license> · Wikimedia Commons" — link to source_url for users who want
+// to verify.
+function attributionFor(file) {
+ const m = loadManifest();
+ if (!_byPath) return null;
+ const it = _byPath[file];
+ if (!it) return null;
+ return {
+ creator: it.creator || null,
+ license: it.license || 'Public domain',
+ source_url: it.source_url || null,
+ source: 'Wikimedia Commons'
+ };
+}
+
+module.exports = {
+ loadManifest,
+ imagesIn,
+ pickSegmentImage,
+ attributionFor
+};
diff --git a/lib/services/bookings.js b/lib/services/bookings.js
new file mode 100644
index 0000000..b5d75bb
--- /dev/null
+++ b/lib/services/bookings.js
@@ -0,0 +1,206 @@
+// lib/services/bookings.js
+//
+// Pure booking service. Extracts the slot-conflict-safe transaction pattern
+// out of routes/api.js so it can be reused (admin-side reschedule, partner
+// API, batch backfills) without going through Express.
+//
+// Phase 1: NOT yet imported from routes/api.js. Available for Phase 2 cutover.
+//
+// Errors are thrown as plain Error objects with `.code` set to one of:
+// not_found · installer_not_on_calendar · missing_field · invalid_email
+// invalid_range · past_slot · slot_taken
+// The route layer maps these to HTTP statuses.
+
+'use strict';
+
+const db = require('../db');
+const bookingToken = require('../booking-token');
+
+const REQUIRED = ['customer_name', 'customer_email', 'scheduled_start', 'scheduled_end'];
+const PAID_TIERS = new Set(['pro', 'signature', 'enterprise']);
+
+function err(code, status = 400) {
+ const e = new Error(code);
+ e.code = code;
+ e.status = status;
+ return e;
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Lookup
+// ─────────────────────────────────────────────────────────────────────────
+
+async function getByUuid(uuid) {
+ if (!uuid) return null;
+ return db.one(
+ `SELECT b.*, i.slug AS installer_slug, i.business_name AS installer_business_name
+ FROM bookings b
+ JOIN installers i ON i.id = b.installer_id
+ WHERE b.uuid = $1`,
+ [uuid]
+ );
+}
+
+async function listForInstaller(installerId, { from, to, limit = 200 } = {}) {
+ if (!installerId) return [];
+ const fromTs = from || new Date().toISOString();
+ const toTs = to || new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString();
+ return db.many(
+ `SELECT id, uuid, customer_name, customer_email, project_type, status,
+ scheduled_start, scheduled_end, city, state
+ FROM bookings
+ WHERE installer_id = $1
+ AND scheduled_end >= $2
+ AND scheduled_start <= $3
+ ORDER BY scheduled_start
+ LIMIT $4`,
+ [installerId, fromTs, toTs, limit]
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Create — race-safe via SELECT … FOR UPDATE on the installer row.
+//
+// Returns: { id, uuid, view_token, installer }
+// Caller is responsible for sending confirmation emails.
+// ─────────────────────────────────────────────────────────────────────────
+
+async function createBooking(slug, fields) {
+ // 1. Validate installer + tier (no transaction yet).
+ const installer = await db.one(
+ `SELECT id, slug, business_name, email, tier
+ FROM installers WHERE slug = $1 AND status = 'active'`,
+ [slug]
+ );
+ if (!installer) throw err('not_found', 404);
+ if (!PAID_TIERS.has(installer.tier)) {
+ throw err('installer_not_on_calendar', 402);
+ }
+
+ const f = fields || {};
+ for (const k of REQUIRED) {
+ if (!f[k]) throw err('missing_' + k, 400);
+ }
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(f.customer_email))) {
+ throw err('invalid_customer_email', 400);
+ }
+
+ const start = new Date(f.scheduled_start);
+ const end = new Date(f.scheduled_end);
+ if (!(end > start)) throw err('invalid_range', 400);
+ if (start < new Date()) throw err('past_slot', 400);
+
+ // 2. Race-safe insert.
+ const client = await db.pool.connect();
+ let booked;
+ try {
+ await client.query('BEGIN');
+ await client.query('SELECT id FROM installers WHERE id = $1 FOR UPDATE', [installer.id]);
+
+ const conflict = await client.query(
+ `SELECT 1 FROM bookings
+ WHERE installer_id = $1
+ AND status IN ('pending','confirmed')
+ AND tstzrange(scheduled_start, scheduled_end) && tstzrange($2::timestamptz, $3::timestamptz)
+ LIMIT 1`,
+ [installer.id, start.toISOString(), end.toISOString()]
+ );
+ if (conflict.rowCount > 0) {
+ await client.query('ROLLBACK');
+ throw err('slot_taken', 409);
+ }
+
+ // Per-buyer Google sign-in identity, if attached. Pulled from the express
+ // session by the route caller and forwarded as f.consumer_account_id.
+ var customerRole = ['homeowner','designer','architect','contractor','property_mgr','other']
+ .includes(String(f.customer_role || '').toLowerCase()) ? f.customer_role : null;
+ var productSourced = f.product_sourced === true || f.product_sourced === 'true' ? true
+ : f.product_sourced === false || f.product_sourced === 'false' ? false
+ : null;
+
+ const ins = await client.query(
+ `INSERT INTO bookings
+ (installer_id, customer_name, customer_email, customer_phone,
+ project_type, market_segment, material, brand, brand_sku,
+ surfaces, rooms, square_feet, roll_count_estimate, ceiling_height_ft,
+ surface_state, access_constraints, budget_band,
+ address_line1, address_line2, city, state, zip,
+ customer_role, product_sourced, consumer_account_id,
+ scheduled_start, scheduled_end, customer_notes, status, source)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,'pending','web')
+ RETURNING id, uuid`,
+ [
+ installer.id,
+ f.customer_name, f.customer_email, f.customer_phone || null,
+ f.project_type || 'consultation', f.market_segment || null, f.material || null, f.brand || null, f.brand_sku || null,
+ f.surfaces || null, f.rooms || null,
+ f.square_feet ? parseInt(f.square_feet, 10) : null,
+ f.roll_count_estimate ? parseInt(f.roll_count_estimate, 10) : null,
+ f.ceiling_height_ft ? parseFloat(f.ceiling_height_ft) : null,
+ f.surface_state || null, f.access_constraints || null, f.budget_band || null,
+ f.address_line1 || null, f.address_line2 || null, f.city || null,
+ (f.state || '').toUpperCase() || null, f.zip || null,
+ customerRole, productSourced, f.consumer_account_id || null,
+ start.toISOString(), end.toISOString(), f.customer_notes || null
+ ]
+ );
+
+ await client.query('COMMIT');
+ booked = ins.rows[0];
+ } catch (e) {
+ try { await client.query('ROLLBACK'); } catch {}
+ throw e;
+ } finally {
+ client.release();
+ }
+
+ return {
+ id: booked.id,
+ uuid: booked.uuid,
+ view_token: bookingToken.sign(booked.uuid),
+ installer
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// State transitions (installer-scoped — caller must verify ownership)
+// ─────────────────────────────────────────────────────────────────────────
+
+async function confirmBooking(bookingId, installerId) {
+ const r = await db.query(
+ `UPDATE bookings
+ SET status = 'confirmed', confirmed_at = now()
+ WHERE id = $1 AND installer_id = $2 AND status = 'pending'`,
+ [bookingId, installerId]
+ );
+ return r.rowCount > 0;
+}
+
+async function declineBooking(bookingId, installerId, reason = null) {
+ const r = await db.query(
+ `UPDATE bookings
+ SET status = 'declined', canceled_at = now(), cancel_reason = $3
+ WHERE id = $1 AND installer_id = $2 AND status IN ('pending','confirmed')`,
+ [bookingId, installerId, reason]
+ );
+ return r.rowCount > 0;
+}
+
+async function completeBooking(bookingId, installerId) {
+ const r = await db.query(
+ `UPDATE bookings
+ SET status = 'completed', completed_at = now()
+ WHERE id = $1 AND installer_id = $2 AND status = 'confirmed'`,
+ [bookingId, installerId]
+ );
+ return r.rowCount > 0;
+}
+
+module.exports = {
+ getByUuid,
+ listForInstaller,
+ createBooking,
+ confirmBooking,
+ declineBooking,
+ completeBooking
+};
diff --git a/lib/services/installers.js b/lib/services/installers.js
new file mode 100644
index 0000000..15ccd43
--- /dev/null
+++ b/lib/services/installers.js
@@ -0,0 +1,202 @@
+// lib/services/installers.js
+//
+// Pure installer-directory service. Reads/writes the `installers` and the
+// new `installer_members` table.
+//
+// Phase 1: NOT yet imported from any runtime route. Available for Phase 2
+// cutover (replacing inline UPDATE calls in routes/admin.js).
+//
+// Sanitization helpers (sanitizeWebsite, clampLen) are duplicated from
+// routes/admin.js intentionally — Phase 2 will remove the originals when
+// the route delegates to this service.
+
+'use strict';
+
+const slugify = require('slugify');
+const db = require('../db');
+
+// ─────────────────────────────────────────────────────────────────────────
+// Sanitization helpers (mirrors routes/admin.js)
+// ─────────────────────────────────────────────────────────────────────────
+
+function sanitizeWebsite(input) {
+ // Returns a safe http(s) URL string, or null. Strips javascript:/data:/etc.
+ // Length-capped to 500 to bound DB write size.
+ const v = String(input || '').trim();
+ if (!v) return null;
+ if (v.length > 500) return null;
+ let u;
+ try { u = new URL(v); } catch { return null; }
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
+ return u.toString();
+}
+
+function clampLen(v, max) {
+ const s = String(v == null ? '' : v);
+ return s.length > max ? s.slice(0, max) : s;
+}
+
+function arrField(s) {
+ return String(s || '')
+ .split(',')
+ .map(x => x.trim())
+ .filter(Boolean)
+ .slice(0, 50);
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Lookup
+// ─────────────────────────────────────────────────────────────────────────
+
+async function findBySlug(slug) {
+ if (!slug) return null;
+ return db.one(`SELECT * FROM installers WHERE slug = $1`, [slug]);
+}
+
+async function findById(id) {
+ if (!id) return null;
+ return db.one(`SELECT * FROM installers WHERE id = $1`, [id]);
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Create — slug uniqueness handled by appending -2, -3, …
+// ─────────────────────────────────────────────────────────────────────────
+
+async function uniqueSlug(baseName) {
+ const baseSlug = slugify(baseName || '', { lower: true, strict: true }).slice(0, 60) || 'installer';
+ let slug = baseSlug;
+ let n = 2;
+ // Loop is bounded by how many duplicates we've ever taken; safe for Phase 1 traffic.
+ // eslint-disable-next-line no-await-in-loop
+ while (await db.one('SELECT id FROM installers WHERE slug = $1', [slug])) {
+ slug = `${baseSlug}-${n++}`;
+ }
+ return slug;
+}
+
+async function create({ businessName, contactName, city, state, zip, email = null, passwordHash = null }) {
+ // Phase 2 will accept (ownerUserId) and stop writing email/password_hash here.
+ // Phase 1 still writes them into installers because routes/auth.js + lib/auth.js
+ // are still the source of truth.
+ if (!businessName) throw new Error('business_name_required');
+ const slug = await uniqueSlug(businessName);
+
+ const row = await db.one(
+ `INSERT INTO installers
+ (slug, email, password_hash, business_name, contact_name, city, state, zip,
+ status, tier, subscription_status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending','basic','inactive')
+ RETURNING id, slug`,
+ [
+ slug,
+ email,
+ passwordHash,
+ clampLen(businessName, 200),
+ clampLen(contactName, 120),
+ clampLen(city, 80),
+ clampLen((state || '').toUpperCase(), 2),
+ clampLen(zip, 20)
+ ]
+ );
+ return row;
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Profile update — mirrors routes/admin.js POST /admin/profile
+// ─────────────────────────────────────────────────────────────────────────
+
+async function updateProfile(installerId, fields) {
+ if (!installerId) throw new Error('installer_id_required');
+ const f = fields || {};
+
+ const website = sanitizeWebsite(f.website);
+ if (f.website && !website) {
+ // Caller decides how to surface this — return a structured error.
+ const err = new Error('invalid_website');
+ err.code = 'invalid_website';
+ throw err;
+ }
+
+ await db.query(
+ `UPDATE installers SET
+ business_name = $2, contact_name = $3, phone = $4, headline = $5, bio = $6,
+ city = $7, state = $8, zip = $9, service_radius_miles = $10, travel_available = $11,
+ team_size = $12, founded_year = $13, website = $14,
+ market_segments = $15, materials = $16, brands_handled = $17, accreditations = $18,
+ response_time_hours = $19,
+ profile_complete = (LENGTH(COALESCE($6,'')) > 40 AND LENGTH(COALESCE($5,'')) > 0)
+ WHERE id = $1`,
+ [
+ installerId,
+ clampLen(f.business_name, 200),
+ clampLen(f.contact_name, 120),
+ clampLen(f.phone, 40),
+ clampLen(f.headline, 200),
+ clampLen(f.bio, 4000),
+ clampLen(f.city, 80),
+ clampLen((f.state || '').toUpperCase(), 2),
+ clampLen(f.zip, 20),
+ parseInt(f.service_radius_miles || '50', 10),
+ f.travel_available === 'on' || f.travel_available === true,
+ f.team_size ? parseInt(f.team_size, 10) : null,
+ f.founded_year ? parseInt(f.founded_year, 10) : null,
+ website,
+ arrField(f.market_segments),
+ arrField(f.materials),
+ arrField(f.brands_handled),
+ arrField(f.accreditations),
+ parseInt(f.response_time_hours || '24', 10)
+ ]
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Membership management
+// ─────────────────────────────────────────────────────────────────────────
+
+async function addMember(installerId, userId, role = 'member', addedBy = null) {
+ if (!installerId || !userId) throw new Error('ids_required');
+ if (role !== 'owner' && role !== 'member') {
+ throw new Error(`invalid_membership_role:${role}`);
+ }
+ await db.query(
+ `INSERT INTO installer_members (installer_id, user_id, role, added_by)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (installer_id, user_id) DO UPDATE
+ SET role = EXCLUDED.role`,
+ [installerId, userId, role, addedBy]
+ );
+}
+
+async function removeMember(installerId, userId) {
+ await db.query(
+ `DELETE FROM installer_members WHERE installer_id = $1 AND user_id = $2`,
+ [installerId, userId]
+ );
+}
+
+async function membersOf(installerId) {
+ if (!installerId) return [];
+ return db.many(
+ `SELECT u.id AS user_id, u.email, u.name, im.role AS membership_role, im.added_at
+ FROM installer_members im
+ JOIN users u ON u.id = im.user_id
+ WHERE im.installer_id = $1
+ ORDER BY im.added_at`,
+ [installerId]
+ );
+}
+
+module.exports = {
+ // helpers exposed for tests / future re-use
+ sanitizeWebsite,
+ clampLen,
+ // public API
+ findBySlug,
+ findById,
+ create,
+ updateProfile,
+ addMember,
+ removeMember,
+ membersOf
+};
diff --git a/lib/services/marketplace.js b/lib/services/marketplace.js
new file mode 100644
index 0000000..cc7a7a6
--- /dev/null
+++ b/lib/services/marketplace.js
@@ -0,0 +1,103 @@
+// Marketplace totals for admin dashboards. Pure read functions over
+// `bookings` + `payment_events`. Two scopes:
+// - per-installer (what one studio sees in its admin)
+// - platform-wide (what NPH staff sees in /admin/billing)
+// Defensive against empty tables / NULL sums; always returns numeric zeros.
+
+const db = require('../db');
+
+function num(v) { return v == null ? 0 : Number(v); }
+
+async function getInstallerMarketplaceTotals(installerId) {
+ // Deposits paid: bookings where deposit_status='paid' for this installer.
+ // Platform fee: sum of application_fee_cents from payment_events for this
+ // installer's PI succeeded events.
+ // Pending payouts (platform_hold): bookings where deposit_status='paid' AND
+ // the installer had no Connect account at charge-time → metadata flagged
+ // the payout for manual transfer. We approximate by counting paid bookings
+ // where the installer's stripe_account_id is NULL right now.
+ const summary = await db.one(
+ `SELECT
+ COALESCE(SUM(b.deposit_amount_cents) FILTER (WHERE b.deposit_status='paid'), 0)::bigint AS deposits_paid_cents,
+ COUNT(*) FILTER (WHERE b.deposit_status='paid')::int AS count_paid,
+ COUNT(*) FILTER (WHERE b.deposit_status='failed')::int AS count_failed,
+ COUNT(*) FILTER (WHERE b.deposit_status='requires_payment')::int AS count_pending
+ FROM bookings b
+ WHERE b.installer_id = $1`,
+ [installerId]
+ ) || {};
+
+ const fees = await db.one(
+ `SELECT COALESCE(SUM(pe.application_fee_cents), 0)::bigint AS platform_fee_cents
+ FROM payment_events pe
+ WHERE pe.installer_id = $1
+ AND pe.event_type = 'payment_intent.succeeded'`,
+ [installerId]
+ ) || {};
+
+ const installer = await db.one(
+ `SELECT stripe_account_id, stripe_account_charges_enabled FROM installers WHERE id = $1`,
+ [installerId]
+ ) || {};
+
+ // If no Connect account or charges not enabled, all paid deposits are still
+ // sitting in the platform balance pending manual transfer at onboarding.
+ const onConnect = !!installer.stripe_account_id && !!installer.stripe_account_charges_enabled;
+ const payoutsPending = onConnect ? 0 : (num(summary.deposits_paid_cents) - num(fees.platform_fee_cents));
+
+ return {
+ deposits_paid_cents: num(summary.deposits_paid_cents),
+ platform_fee_cents: num(fees.platform_fee_cents),
+ payouts_pending_cents: Math.max(0, payoutsPending),
+ count_paid: num(summary.count_paid),
+ count_failed: num(summary.count_failed),
+ count_pending: num(summary.count_pending),
+ on_connect: onConnect
+ };
+}
+
+async function getPlatformMarketplaceTotals() {
+ const summary = await db.one(
+ `SELECT
+ COALESCE(SUM(deposit_amount_cents) FILTER (WHERE deposit_status='paid'), 0)::bigint AS deposits_paid_cents,
+ COUNT(*) FILTER (WHERE deposit_status='paid')::int AS count_paid,
+ COUNT(*) FILTER (WHERE deposit_status='failed')::int AS count_failed,
+ COUNT(*) FILTER (WHERE deposit_status='requires_payment')::int AS count_pending,
+ COUNT(DISTINCT installer_id) FILTER (WHERE deposit_status='paid')::int AS active_installers
+ FROM bookings`
+ ) || {};
+
+ const fees = await db.one(
+ `SELECT COALESCE(SUM(application_fee_cents), 0)::bigint AS platform_fee_cents
+ FROM payment_events
+ WHERE event_type = 'payment_intent.succeeded'`
+ ) || {};
+
+ // Platform-hold sum: paid bookings where the installer is NOT on Connect.
+ const hold = await db.one(
+ `SELECT COALESCE(SUM(b.deposit_amount_cents), 0)::bigint AS held_cents
+ FROM bookings b
+ JOIN installers i ON i.id = b.installer_id
+ WHERE b.deposit_status = 'paid'
+ AND (i.stripe_account_id IS NULL OR i.stripe_account_charges_enabled = false)`
+ ) || {};
+
+ // Subtract platform fees retained from the held total (rough approx).
+ const heldNet = Math.max(0, num(hold.held_cents) - num(fees.platform_fee_cents));
+
+ return {
+ deposits_paid_cents: num(summary.deposits_paid_cents),
+ platform_fee_cents: num(fees.platform_fee_cents),
+ transferred_cents: Math.max(0, num(summary.deposits_paid_cents) - num(fees.platform_fee_cents) - heldNet),
+ payouts_pending_cents: heldNet,
+ count_paid: num(summary.count_paid),
+ count_failed: num(summary.count_failed),
+ count_pending: num(summary.count_pending),
+ active_installers: num(summary.active_installers)
+ };
+}
+
+module.exports = {
+ getInstallerMarketplaceTotals,
+ getPlatformMarketplaceTotals
+};
diff --git a/lib/services/subscriptions.js b/lib/services/subscriptions.js
new file mode 100644
index 0000000..6a7855b
--- /dev/null
+++ b/lib/services/subscriptions.js
@@ -0,0 +1,129 @@
+// lib/services/subscriptions.js
+//
+// Pure subscription service. Extracts the per-event handlers from
+// routes/webhooks.js so they can be unit-tested without spinning up Express
+// or replaying real Stripe-signed payloads.
+//
+// Phase 1: NOT yet imported from routes/webhooks.js. Available for Phase 2
+// cutover. The Stripe-signature verification + idempotency-via-audit-log
+// stays in the route handler — only the per-event side-effects move here.
+//
+// Each function takes a node-pg client (so the caller can keep the audit
+// insert + the side-effect inside the same transaction) plus the
+// already-constructed Stripe `event` object. When called from a service-
+// only context (no transaction), pass db.pool — every call uses the same
+// .query interface.
+
+'use strict';
+
+const stripe = require('../stripe');
+
+// ─────────────────────────────────────────────────────────────────────────
+// checkout.session.completed
+// ─────────────────────────────────────────────────────────────────────────
+
+async function applyCheckoutSession(client, event) {
+ const obj = event.data && event.data.object;
+ if (!obj) return { handled: false, reason: 'no_object' };
+
+ const meta = obj.metadata || {};
+ const installerId = meta.installer_id ? parseInt(meta.installer_id, 10) : null;
+ if (!installerId || !obj.subscription) {
+ return { handled: false, reason: 'missing_installer_or_subscription' };
+ }
+
+ // Tier from metadata only on first checkout — subsequent updates derive
+ // from price.id (see applySubscriptionChanged).
+ const tier = meta.tier || 'pro';
+ await client.query(
+ `UPDATE installers
+ SET stripe_customer_id = $2,
+ stripe_subscription_id = $3,
+ subscription_status = 'active',
+ tier = $4
+ WHERE id = $1`,
+ [installerId, obj.customer, obj.subscription, tier]
+ );
+ return { handled: true, installerId, tier };
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// customer.subscription.created / customer.subscription.updated
+// ─────────────────────────────────────────────────────────────────────────
+
+async function applySubscriptionChanged(client, event, sub) {
+ const subscription = sub || (event.data && event.data.object);
+ if (!subscription || !subscription.customer) {
+ return { handled: false, reason: 'no_subscription' };
+ }
+
+ const status = subscription.status || 'unknown';
+ const periodEnd = subscription.current_period_end
+ ? new Date(subscription.current_period_end * 1000)
+ : null;
+
+ // Derive tier from the price ID on the subscription's first item.
+ // subscription.updated events don't carry our metadata, so price ID is
+ // the only canonical signal.
+ let tier = null;
+ try {
+ const priceId =
+ subscription.items &&
+ subscription.items.data &&
+ subscription.items.data[0] &&
+ subscription.items.data[0].price &&
+ subscription.items.data[0].price.id;
+ const m = stripe.tierFromPriceId(priceId);
+ if (m) tier = m.tier;
+ } catch (e) {
+ // fall through with tier = null
+ }
+
+ if (tier) {
+ await client.query(
+ `UPDATE installers
+ SET subscription_status = $2,
+ current_period_end = $3,
+ tier = $4
+ WHERE stripe_customer_id = $1`,
+ [subscription.customer, status, periodEnd, tier]
+ );
+ return { handled: true, customer: subscription.customer, tier, status };
+ }
+
+ // Couldn't map price → tier (env not loaded for that price). Update
+ // status but don't blindly flip the tier.
+ await client.query(
+ `UPDATE installers
+ SET subscription_status = $2,
+ current_period_end = $3
+ WHERE stripe_customer_id = $1`,
+ [subscription.customer, status, periodEnd]
+ );
+ return { handled: true, customer: subscription.customer, tier: null, status, warning: 'no_tier_match' };
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// customer.subscription.deleted
+// ─────────────────────────────────────────────────────────────────────────
+
+async function applySubscriptionDeleted(client, event, sub) {
+ const subscription = sub || (event.data && event.data.object);
+ if (!subscription || !subscription.customer) {
+ return { handled: false, reason: 'no_subscription' };
+ }
+ await client.query(
+ `UPDATE installers
+ SET subscription_status = 'canceled',
+ tier = 'basic'
+ WHERE stripe_customer_id = $1`,
+ [subscription.customer]
+ );
+ return { handled: true, customer: subscription.customer };
+}
+
+module.exports = {
+ applyCheckoutSession,
+ applySubscriptionChanged,
+ applySubscriptionDeleted
+};
diff --git a/lib/services/users.js b/lib/services/users.js
new file mode 100644
index 0000000..a3e5c69
--- /dev/null
+++ b/lib/services/users.js
@@ -0,0 +1,170 @@
+// lib/services/users.js
+//
+// Pure user/identity service. Reads/writes the new `users`, `roles`, and
+// `installer_members` tables introduced in db/migrations/005_users_roles.sql.
+//
+// Phase 1: NOT yet imported from any runtime route. Available for Phase 2
+// cutover (rewriting /login, /signup, attachInstaller → attachUser).
+//
+// No Express dependency. No req/res. Pure functions over lib/db. Errors
+// bubble up to the caller. bcrypt is the only outside-world dep.
+
+'use strict';
+
+const bcrypt = require('bcrypt');
+const db = require('../db');
+
+const SALT_ROUNDS = 12;
+
+// Constant-time bcrypt verify against this dummy hash on miss, so login
+// timing doesn't leak whether an email exists. Same pattern as routes/auth.js.
+const DUMMY_HASH = '$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidinv';
+
+const VALID_GLOBAL_ROLES = new Set([
+ 'admin',
+ 'ops',
+ 'installer_owner',
+ 'installer_member'
+]);
+
+// ─────────────────────────────────────────────────────────────────────────
+// Lookup
+// ─────────────────────────────────────────────────────────────────────────
+
+async function findByEmail(email) {
+ if (!email) return null;
+ const e = String(email).toLowerCase().trim();
+ return db.one(
+ `SELECT id, email, password_hash, name, created_at, last_login_at
+ FROM users
+ WHERE email = $1`,
+ [e]
+ );
+}
+
+async function findById(id) {
+ if (!id) return null;
+ return db.one(
+ `SELECT id, email, name, created_at, last_login_at
+ FROM users
+ WHERE id = $1`,
+ [id]
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Create
+// ─────────────────────────────────────────────────────────────────────────
+
+async function createUser({ email, password, name }) {
+ if (!email || !password) {
+ throw new Error('email_and_password_required');
+ }
+ if (password.length < 8) {
+ throw new Error('password_too_short');
+ }
+ const e = String(email).toLowerCase().trim();
+ const hash = await bcrypt.hash(password, SALT_ROUNDS);
+ const row = await db.one(
+ `INSERT INTO users (email, password_hash, name)
+ VALUES ($1, $2, $3)
+ RETURNING id, email, name, created_at`,
+ [e, hash, name || null]
+ );
+ return row;
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Authenticate — constant-time even on email miss
+// ─────────────────────────────────────────────────────────────────────────
+
+async function authenticate(email, plainPw) {
+ const user = await findByEmail(email);
+ const hash = (user && user.password_hash) ? user.password_hash : DUMMY_HASH;
+ const ok = await bcrypt.compare(plainPw || '', hash);
+ if (!user || !ok) return null;
+
+ // Touch last_login_at. Best-effort; auth still succeeds if this fails.
+ try {
+ await db.query('UPDATE users SET last_login_at = now() WHERE id = $1', [user.id]);
+ } catch (e) {
+ // Swallow — login should still succeed.
+ }
+
+ // Strip password_hash before returning.
+ const { password_hash, ...safe } = user;
+ return safe;
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Roles (global capabilities)
+// ─────────────────────────────────────────────────────────────────────────
+
+async function grantRole(userId, role, grantedBy = null) {
+ if (!VALID_GLOBAL_ROLES.has(role)) {
+ throw new Error(`invalid_role:${role}`);
+ }
+ await db.query(
+ `INSERT INTO roles (user_id, role, granted_by)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (user_id, role) DO NOTHING`,
+ [userId, role, grantedBy]
+ );
+}
+
+async function revokeRole(userId, role) {
+ if (!VALID_GLOBAL_ROLES.has(role)) {
+ throw new Error(`invalid_role:${role}`);
+ }
+ await db.query(
+ `DELETE FROM roles WHERE user_id = $1 AND role = $2`,
+ [userId, role]
+ );
+}
+
+async function userRoles(userId) {
+ if (!userId) return [];
+ const rows = await db.many(
+ `SELECT role FROM roles WHERE user_id = $1 ORDER BY role`,
+ [userId]
+ );
+ return rows.map(r => r.role);
+}
+
+async function hasRole(userId, role) {
+ const r = await db.one(
+ `SELECT 1 FROM roles WHERE user_id = $1 AND role = $2 LIMIT 1`,
+ [userId, role]
+ );
+ return !!r;
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Installer membership lookup
+// ─────────────────────────────────────────────────────────────────────────
+
+async function userInstallers(userId) {
+ if (!userId) return [];
+ return db.many(
+ `SELECT i.id, i.slug, i.business_name, i.tier, i.subscription_status,
+ i.status, im.role AS membership_role
+ FROM installer_members im
+ JOIN installers i ON i.id = im.installer_id
+ WHERE im.user_id = $1
+ ORDER BY i.business_name`,
+ [userId]
+ );
+}
+
+module.exports = {
+ findByEmail,
+ findById,
+ createUser,
+ authenticate,
+ grantRole,
+ revokeRole,
+ userRoles,
+ hasRole,
+ userInstallers,
+ VALID_GLOBAL_ROLES
+};
diff --git a/lib/slots.js b/lib/slots.js
new file mode 100644
index 0000000..d4c8196
--- /dev/null
+++ b/lib/slots.js
@@ -0,0 +1,154 @@
+// Compute available booking slots for an installer over a date range.
+//
+// Inputs:
+// installerId
+// startDate, endDate (ISO strings or Date objects, inclusive)
+// slotMinutes (default 60)
+// bufferMinutes (default 15) — gap kept between bookings
+//
+// Algorithm:
+// 1. Pull recurring weekly availability for the installer.
+// 2. Pull existing confirmed/pending bookings in the range.
+// 3. Pull time-off blocks in the range.
+// 4. For each calendar day in [start, end]:
+// - Find availability windows for that day_of_week.
+// - Subtract any time_off intersecting that day.
+// - Subtract any booking that intersects.
+// - Slice the remaining gaps into slotMinutes-sized openings.
+// 5. Return list of {start, end} ISO strings.
+
+const { DateTime, Interval } = require('luxon');
+const db = require('./db');
+
+const DEFAULT_TZ = 'America/Los_Angeles';
+
+async function getAvailability(installerId) {
+ return db.many(
+ `SELECT day_of_week, start_time::text AS start_time, end_time::text AS end_time, timezone
+ FROM installer_availability
+ WHERE installer_id = $1 AND active = true
+ ORDER BY day_of_week, start_time`,
+ [installerId]
+ );
+}
+
+async function getTimeOff(installerId, fromIso, toIso) {
+ return db.many(
+ `SELECT start_at, end_at, all_day
+ FROM installer_time_off
+ WHERE installer_id = $1
+ AND end_at >= $2
+ AND start_at <= $3`,
+ [installerId, fromIso, toIso]
+ );
+}
+
+async function getBookings(installerId, fromIso, toIso) {
+ return db.many(
+ `SELECT scheduled_start, scheduled_end
+ FROM bookings
+ WHERE installer_id = $1
+ AND status IN ('pending','confirmed')
+ AND scheduled_end >= $2
+ AND scheduled_start <= $3`,
+ [installerId, fromIso, toIso]
+ );
+}
+
+function subtractIntervals(base, blockers) {
+ // base: Interval[]; blockers: Interval[]; returns base minus union(blockers)
+ let result = base.slice();
+ for (const b of blockers) {
+ const next = [];
+ for (const r of result) {
+ if (!r.overlaps(b)) { next.push(r); continue; }
+ const before = Interval.fromDateTimes(r.start, b.start);
+ const after = Interval.fromDateTimes(b.end, r.end);
+ if (before.isValid && before.length('minutes') > 0) next.push(before);
+ if (after.isValid && after.length('minutes') > 0) next.push(after);
+ }
+ result = next;
+ }
+ return result;
+}
+
+function sliceIntoSlots(intervals, slotMinutes, bufferMinutes) {
+ const out = [];
+ for (const iv of intervals) {
+ let cursor = iv.start;
+ const span = slotMinutes;
+ while (cursor.plus({ minutes: span }) <= iv.end) {
+ const end = cursor.plus({ minutes: span });
+ out.push({ start: cursor.toISO(), end: end.toISO() });
+ cursor = end.plus({ minutes: bufferMinutes });
+ }
+ }
+ return out;
+}
+
+async function availableSlots(installerId, opts = {}) {
+ const slotMinutes = opts.slotMinutes || 60;
+ const bufferMinutes = opts.bufferMinutes || 15;
+ const tz = opts.timezone || DEFAULT_TZ;
+
+ const start = DateTime.fromISO(opts.startDate, { zone: tz }).startOf('day');
+ const end = DateTime.fromISO(opts.endDate, { zone: tz }).endOf('day');
+ if (!start.isValid || !end.isValid || end <= start) return [];
+
+ const fromIso = start.toUTC().toISO();
+ const toIso = end.toUTC().toISO();
+
+ const [avail, timeOff, bookings] = await Promise.all([
+ getAvailability(installerId),
+ getTimeOff(installerId, fromIso, toIso),
+ getBookings(installerId, fromIso, toIso)
+ ]);
+
+ if (avail.length === 0) return [];
+
+ // Group availability by day-of-week
+ const byDow = {};
+ for (const a of avail) {
+ if (!byDow[a.day_of_week]) byDow[a.day_of_week] = [];
+ byDow[a.day_of_week].push(a);
+ }
+
+ const blockerIntervals = [
+ ...timeOff.map(t => Interval.fromDateTimes(
+ DateTime.fromJSDate(t.start_at).setZone(tz),
+ DateTime.fromJSDate(t.end_at).setZone(tz)
+ )),
+ ...bookings.map(b => Interval.fromDateTimes(
+ DateTime.fromJSDate(b.scheduled_start).setZone(tz),
+ DateTime.fromJSDate(b.scheduled_end).setZone(tz)
+ ))
+ ].filter(i => i.isValid);
+
+ const allSlots = [];
+ let cursor = start;
+ while (cursor < end) {
+ // Luxon weekday: 1=Mon..7=Sun. We stored 0=Sun..6=Sat.
+ const dow = cursor.weekday === 7 ? 0 : cursor.weekday;
+ const todayWindows = byDow[dow] || [];
+ const dayIntervals = [];
+ for (const w of todayWindows) {
+ const [sh, sm] = w.start_time.split(':').map(Number);
+ const [eh, em] = w.end_time.split(':').map(Number);
+ const winStart = cursor.set({ hour: sh, minute: sm, second: 0, millisecond: 0 });
+ const winEnd = cursor.set({ hour: eh, minute: em, second: 0, millisecond: 0 });
+ const iv = Interval.fromDateTimes(winStart, winEnd);
+ if (iv.isValid) dayIntervals.push(iv);
+ }
+ if (dayIntervals.length) {
+ const free = subtractIntervals(dayIntervals, blockerIntervals);
+ allSlots.push(...sliceIntoSlots(free, slotMinutes, bufferMinutes));
+ }
+ cursor = cursor.plus({ days: 1 });
+ }
+
+ // Drop slots already in the past
+ const now = DateTime.now().setZone(tz);
+ return allSlots.filter(s => DateTime.fromISO(s.start).setZone(tz) > now);
+}
+
+module.exports = { availableSlots };
diff --git a/lib/stripe.js b/lib/stripe.js
new file mode 100644
index 0000000..1761859
--- /dev/null
+++ b/lib/stripe.js
@@ -0,0 +1,270 @@
+// Stripe subscription helpers.
+// Pricing tiers map to STRIPE_PRICE_* env vars (set via secrets-manager).
+// Until real keys are loaded, all functions return mocked responses so the
+// rest of the app can be developed end-to-end.
+
+const Stripe = require('stripe');
+
+const TIERS = {
+ pro: { name: 'Pro', month: 'STRIPE_PRICE_PRO_MONTH', year: 'STRIPE_PRICE_PRO_YEAR' },
+ signature: { name: 'Signature', month: 'STRIPE_PRICE_SIGNATURE_MONTH', year: 'STRIPE_PRICE_SIGNATURE_YEAR' },
+ enterprise: { name: 'Enterprise', month: 'STRIPE_PRICE_ENTERPRISE_MONTH', year: null }
+};
+
+const PRICE_TABLE = [
+ { tier: 'pro', cadence: 'month', amount: 39, label: '$39 / month' },
+ { tier: 'pro', cadence: 'year', amount: 399, label: '$399 / year' },
+ { tier: 'signature', cadence: 'month', amount: 149, label: '$149 / month' },
+ { tier: 'signature', cadence: 'year', amount: 1500, label: '$1,500 / year' },
+ { tier: 'enterprise', cadence: 'month', amount: 399, label: '$399 / month and up' }
+];
+
+function client() {
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key || !key.startsWith('sk_')) return null;
+ return new Stripe(key, { apiVersion: '2024-10-28.acacia' });
+}
+
+function isLive() { return !!client(); }
+
+function priceIdFor(tier, cadence) {
+ const t = TIERS[tier];
+ if (!t) return null;
+ const envKey = cadence === 'year' ? t.year : t.month;
+ if (!envKey) return null;
+ return process.env[envKey] || null;
+}
+
+// Reverse lookup: given a Stripe price.id, derive { tier, cadence }. Webhook
+// events for `customer.subscription.updated` don't carry our metadata.tier, so
+// the price ID is the only safe source of truth for the tier transition.
+function tierFromPriceId(priceId) {
+ if (!priceId) return null;
+ for (const [tier, cfg] of Object.entries(TIERS)) {
+ for (const cadence of ['month', 'year']) {
+ const envKey = cfg[cadence];
+ if (envKey && process.env[envKey] && process.env[envKey] === priceId) {
+ return { tier, cadence };
+ }
+ }
+ }
+ return null;
+}
+
+async function ensureCustomer(installer) {
+ const c = client();
+ if (!c) {
+ return { mocked: true, id: 'cus_mock_' + installer.id };
+ }
+ if (installer.stripe_customer_id) return { id: installer.stripe_customer_id };
+ const created = await c.customers.create({
+ email: installer.email,
+ name: installer.business_name,
+ metadata: { installer_id: String(installer.id) }
+ });
+ return { id: created.id };
+}
+
+async function createCheckoutSession({ installer, tier, cadence, successUrl, cancelUrl }) {
+ const c = client();
+ const priceId = priceIdFor(tier, cadence);
+ if (!c) {
+ return {
+ mocked: true,
+ url: `${successUrl}?mock=1&tier=${tier}&cadence=${cadence}`,
+ tier, cadence
+ };
+ }
+ if (!priceId) throw new Error('price_not_configured');
+ const cust = await ensureCustomer(installer);
+ const session = await c.checkout.sessions.create({
+ mode: 'subscription',
+ customer: cust.id,
+ line_items: [{ price: priceId, quantity: 1 }],
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ metadata: { installer_id: String(installer.id), tier, cadence },
+ allow_promotion_codes: true
+ });
+ return { url: session.url, id: session.id, customer_id: cust.id };
+}
+
+async function createPortalSession({ installer, returnUrl }) {
+ const c = client();
+ if (!c) return { mocked: true, url: returnUrl };
+ if (!installer.stripe_customer_id) throw new Error('no_customer');
+ const session = await c.billingPortal.sessions.create({
+ customer: installer.stripe_customer_id,
+ return_url: returnUrl
+ });
+ return { url: session.url };
+}
+
+function constructWebhookEvent(rawBody, signature) {
+ const c = client();
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
+ if (!c || !secret) return null;
+ return c.webhooks.constructEvent(rawBody, signature, secret);
+}
+
+// =======================================================================
+// MARKETPLACE — booking deposits + Stripe Connect Express
+// =======================================================================
+// Every booking carries a deposit. NPH retains `application_fee_amount` from
+// each charge as the platform cut; the rest transfers to the installer's
+// Connect account. When the installer has no account yet (unclaimed or
+// onboarding incomplete), the deposit holds in NPH's platform balance and is
+// flagged for manual transfer at claim-time via metadata.
+
+const DEFAULT_DEPOSIT_CENTS = parseInt(process.env.NPH_DEFAULT_DEPOSIT_CENTS || '9900', 10);
+const DEFAULT_PLATFORM_FEE_BPS = parseInt(process.env.NPH_PLATFORM_FEE_BPS || '1000', 10);
+
+function platformFeeCents(amountCents, feeBps) {
+ return Math.floor((amountCents * feeBps) / 10000);
+}
+
+async function createBookingDepositIntent({ booking, installer, amountCents, platformFeeBps }) {
+ const amount = amountCents || DEFAULT_DEPOSIT_CENTS;
+ const feeBps = platformFeeBps || DEFAULT_PLATFORM_FEE_BPS;
+ const fee = platformFeeCents(amount, feeBps);
+
+ const c = client();
+ if (!c) {
+ return {
+ mocked: true,
+ client_secret: `pi_mock_${booking.uuid}_secret_mock`,
+ intent_id: `pi_mock_${booking.uuid}`,
+ amount_cents: amount,
+ application_fee_cents: fee,
+ destination: installer.stripe_account_id || null,
+ mode: installer.stripe_account_id && installer.stripe_account_charges_enabled
+ ? 'destination' : 'platform_hold'
+ };
+ }
+
+ const useConnect = !!(installer.stripe_account_id && installer.stripe_account_charges_enabled);
+ const params = {
+ amount,
+ currency: 'usd',
+ capture_method: 'automatic',
+ description: `NPH booking ${booking.uuid} · ${installer.business_name}`,
+ receipt_email: booking.customer_email,
+ metadata: {
+ booking_uuid: booking.uuid,
+ booking_id: String(booking.id),
+ installer_id: String(installer.id),
+ installer_slug: installer.slug,
+ platform_fee_bps: String(feeBps),
+ payout_mode: useConnect ? 'destination' : 'platform_hold'
+ }
+ };
+ if (useConnect) {
+ params.application_fee_amount = fee;
+ params.transfer_data = { destination: installer.stripe_account_id };
+ } else {
+ params.metadata.pending_payout_to_installer_id = String(installer.id);
+ }
+
+ const intent = await c.paymentIntents.create(params);
+ return {
+ client_secret: intent.client_secret,
+ intent_id: intent.id,
+ amount_cents: amount,
+ application_fee_cents: useConnect ? fee : 0,
+ destination: installer.stripe_account_id || null,
+ mode: useConnect ? 'destination' : 'platform_hold'
+ };
+}
+
+async function createConnectAccount({ installer }) {
+ const c = client();
+ if (!c) {
+ return {
+ mocked: true,
+ account_id: `acct_mock_${installer.id}`,
+ charges_enabled: false,
+ payouts_enabled: false
+ };
+ }
+ const acct = await c.accounts.create({
+ type: 'express',
+ country: 'US',
+ email: installer.email,
+ capabilities: {
+ transfers: { requested: true },
+ card_payments: { requested: true }
+ },
+ business_type: 'company',
+ business_profile: {
+ name: installer.business_name,
+ url: installer.website || undefined,
+ mcc: '1771'
+ },
+ metadata: {
+ installer_id: String(installer.id),
+ installer_slug: installer.slug
+ }
+ });
+ return {
+ account_id: acct.id,
+ charges_enabled: !!acct.charges_enabled,
+ payouts_enabled: !!acct.payouts_enabled
+ };
+}
+
+async function createConnectAccountLink({ accountId, returnUrl, refreshUrl }) {
+ const c = client();
+ if (!c) {
+ return {
+ mocked: true,
+ url: `${returnUrl || '/admin'}?mock=connect&acct=${encodeURIComponent(accountId)}`
+ };
+ }
+ const link = await c.accountLinks.create({
+ account: accountId,
+ refresh_url: refreshUrl,
+ return_url: returnUrl,
+ type: 'account_onboarding'
+ });
+ return { url: link.url };
+}
+
+async function getConnectAccount({ accountId }) {
+ const c = client();
+ if (!c) {
+ return {
+ mocked: true,
+ account_id: accountId,
+ charges_enabled: false,
+ payouts_enabled: false,
+ details_submitted: false
+ };
+ }
+ const acct = await c.accounts.retrieve(accountId);
+ return {
+ account_id: acct.id,
+ charges_enabled: !!acct.charges_enabled,
+ payouts_enabled: !!acct.payouts_enabled,
+ details_submitted: !!acct.details_submitted
+ };
+}
+
+module.exports = {
+ TIERS,
+ PRICE_TABLE,
+ isLive,
+ priceIdFor,
+ tierFromPriceId,
+ ensureCustomer,
+ createCheckoutSession,
+ createPortalSession,
+ constructWebhookEvent,
+ // marketplace
+ DEFAULT_DEPOSIT_CENTS,
+ DEFAULT_PLATFORM_FEE_BPS,
+ platformFeeCents,
+ createBookingDepositIntent,
+ createConnectAccount,
+ createConnectAccountLink,
+ getConnectAccount
+};
diff --git a/lib/utils.js b/lib/utils.js
new file mode 100644
index 0000000..ab74599
--- /dev/null
+++ b/lib/utils.js
@@ -0,0 +1,29 @@
+// Tiny shared helpers used by both routes and scripts.
+// Keep this file dependency-free.
+
+function escapeHtml(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+// Returns a normalized http(s) URL string, or null if invalid.
+function safeHttpUrl(input) {
+ const v = String(input || '').trim();
+ if (!v || v.length > 2000) return null;
+ let u;
+ try { u = new URL(v); } catch { return null; }
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
+ return u.toString();
+}
+
+// Length-cap user input bound for varchar/text columns.
+function clampLen(v, max) {
+ const s = String(v == null ? '' : v);
+ return s.length > max ? s.slice(0, max) : s;
+}
+
+module.exports = { escapeHtml, safeHttpUrl, clampLen };
diff --git a/outreach/COMMUNITY_CHANNELS.md b/outreach/COMMUNITY_CHANNELS.md
new file mode 100644
index 0000000..a557a31
--- /dev/null
+++ b/outreach/COMMUNITY_CHANNELS.md
@@ -0,0 +1,119 @@
+# NPH community channel map
+
+Where U.S. trade wallcovering installers actually live online — researched
+2026-05-06 via Exa. Reddit is essentially dead for this niche; the highest-
+signal surfaces are WIA-gated. Use this in priority order.
+
+## Tier 1 — primary engagement (gated, highest density)
+
+1. **WIA private Facebook group** — gated to WIA members. Confirmed by a
+ PaintTalk member: "I belong to the WIA now, and they have a facebook
+ group, so theres a ton of hangars all over America that ask each other
+ questions." ~500 installers across 30 U.S. chapters.
+ - **Unlock:** Steve joins WIA as an Associate Member (paid). Gets
+ directory listing + group access + newsletter inclusion.
+ - **Engagement rule:** never post NPH directory pitches. Share genuine
+ install-side content first (paste/seam/grasscloth tips). Earn a voice
+ before mentioning NPH.
+ - **Admin:** WIA national office — info@wallcoveringinstallers.org,
+ 800-254-6477.
+
+2. **WIA public Facebook page** — facebook.com/wallcoveringinstallers
+ (Lebanon OH, run by WIA HQ).
+ - **Engagement:** tag @wallcoveringinstallers on NPH posts featuring
+ WIA-member installers; ask HQ about a sponsored post or newsletter
+ mention.
+
+3. **WIA local chapters** — ~30 U.S. chapters at
+ wallcoveringinstallers.org/find-a-local-chapter. Chapter chairs are
+ the single highest-leverage humans in the niche.
+ - **Engagement:** sponsor 1 chapter meeting in LA or NYC for luxury-
+ residential density. Door-opening play, not a content play.
+
+## Tier 2 — public forums (build credibility before pitching)
+
+4. **PaintTalk.com Wallpaper subforum** — painttalk.com/forums.
+ Active named pros: Ohio painter, ProWallGuy, VinylHanger. Searchable +
+ indexed. Strict on self-promo.
+ - **Engagement:** build a real profile, answer 5-10 paste/seam/
+ grasscloth questions before mentioning NPH.
+
+5. **ContractorTalk.com Wallpaper subforum** —
+ contractortalk.com/forums/wallpaper.21 (1.4K posts, 1.2M views).
+ Same playbook as PaintTalk.
+
+## Tier 3 — discovery / content surfaces
+
+6. **Instagram trade-installer hashtags:**
+ - `#paperhanger`
+ - `#wallcoveringinstaller`
+ - `#wallcoveringinstallation`
+ - `#wallpaperinstaller` (clean enough — but `#wallpaperinstall` is
+ polluted by peel-and-stick DIYers; avoid)
+ - **Reference pro account:** @coolbeansdesignllc (Nancy Bean, WIA member)
+ - **Engagement:** NPH IG should follow + comment on every install reel
+ from accounts using these tags; feature one trade installer/week.
+
+7. **TikTok** — low priority. Dominated by DIY peel-and-stick. One legit
+ trade creator: @capaperhangers (SF Bay). Seed Steve's own install-side
+ content (trade tools, grasscloth seams, silk install) with #paperhanger.
+
+## Tier 4 — Reddit (NOT a primary channel)
+
+Reddit is essentially dead for trade paperhangers as of 2026-05.
+r/HomeImprovement, r/Painting, r/Construction, r/contractors all have
+occasional wallcovering threads but they're 90%+ homeowners asking how to
+remove old paper. No subreddit dedicated to professional paperhanging
+exists.
+
+- **Engagement:** occasional answer on r/Painting threads where a pro
+ asks paste/primer questions. Do not invest sustained effort here.
+
+## Tier 5 — trade press / pitch targets (not community)
+
+- **Wallcoverings Association (WA)** — wallcoverings.org. Manufacturer-side
+ org. Pitch NPH as the consumer-facing companion to their installer-
+ recruitment funnel (IUPAT, DC14 Chicago, DC21 Philly apprenticeships).
+- **PDCA** (Painting & Decorating Contractors of America) — WIA partner.
+- **PDRA** (Painting & Decorating Retailers Association) — WIA partner.
+- **"Wallcovering Installer" newsletter** — WIA's annual. Paid ad slot
+ directly to the audience.
+- **IUPAT** — union paperhanger pipelines (Finishing Chicago, DC14, DC21).
+
+## Recommended execution order
+
+1. **Join WIA Associate.** Unlocks the private FB group + member directory
+ + newsletter mentions. Single biggest move.
+2. **Sponsor 1 WIA chapter meeting** in LA or NYC.
+3. **Build NPH IG presence** around `#paperhanger` `#wallcoveringinstaller`
+ — feature 1 WIA pro/week.
+4. **Quietly contribute** on PaintTalk + ContractorTalk wallpaper subforums.
+ No link drops.
+5. **Pitch the WIA newsletter editor** on a "modern booking marketplace
+ for hand-painted / silk / grasscloth installs" feature article.
+
+## What to NEVER do
+
+- Post NPH pitches in the WIA private FB group without earning voice.
+- Cold IG-DM the 17 paid-ad-running studios from a fresh NPH account
+ (already ruled out — see feedback_no_ig_dm_outreach.md).
+- Cold-email any installer at @info on their domain without
+ comms-compliance gates closed (see scripts/send-claim-invitations.js
+ + DATA_POLICY.md §7).
+- Spam Reddit. The trade isn't there.
+
+## Sources
+
+- https://www.wallcoveringinstallers.org/
+- https://www.wallcoveringinstallers.org/member-benefits/
+- https://wallcoveringinstallers.org/associate-members/
+- https://www.wallcoveringinstallers.org/find-a-local-chapter
+- https://www.facebook.com/wallcoveringinstallers/
+- https://www.painttalk.com/threads/wallpaper-joins.96807/ (private-group reference)
+- https://www.painttalk.com/threads/wallpapering-tips.29850/
+- https://www.painttalk.com/threads/who-does-your-wallcovering.30/
+- https://www.contractortalk.com/forums/wallpaper.21/
+- https://www.wallcoverings.org/
+- https://www.wallcoverings.org/page/WallcoveringInstallerBenefits
+- https://www.wallcoveringinstallers.org/how-to-level-up-in-the-wallpaper-hanging-industry-a-fresh-perspective/
+- https://linktr.ee/capaperhangers
diff --git a/outreach/IG_DISCOVERED_NEW.md b/outreach/IG_DISCOVERED_NEW.md
new file mode 100644
index 0000000..8eca708
--- /dev/null
+++ b/outreach/IG_DISCOVERED_NEW.md
@@ -0,0 +1,109 @@
+# Newly discovered IG-active wallcovering installers
+
+Generated 2026-05-06 via Exa research. **All 30 below are NOT in NPH's
+522-studio WIA-scrape DB.** Cross-referenced against the 30 IG handles
+already known (Merenda Wallpaper, AM Wallcovering, Captain Wallpaper, etc.).
+
+Ranked by **craft signal density** — strongest trade pros at top.
+
+## Tier 1 — top brand endorsements (highest priority for outreach)
+
+| # | Handle | Studio · Location | Why |
+|---|---|---|---|
+| 1 | @austinpaperhanging | Austin Paperhanging (David Vaneman), Austin TX | Hangs Holly Hunt, Phillip Jeffries, Cole & Son for top designers; documents installs strip-by-strip; travels for trade work |
+| 2 | @hicountrypaperworks | Hi-Country Paperworks (Steve Vaneman), Austin / Hill Country TX | Third-generation, family back to 1960s Houston; partner with The Wallpaper Concierge |
+| 3 | @created_dimensions_dennis | Created Dimensions (Dennis Delpome), NYC/NJ | **Phillip Jeffries' "master installer"** — co-host of THE HANG video series; trains installers nationally |
+| 4 | @hjholtzandson | H.J. Holtz & Son (Rick Holtz), Richmond VA | **One of <36 de Gournay-recommended installers in the entire U.S.** — only one in Virginia |
+| 5 | @parfaitwallpaperservices | Parfait Wallpaper Services, Mill Valley/Bay Area CA | WIA member; installs de Gournay, Fromental, Gracie, Phillip Jeffries, Maya Romanoff; warranty-backed |
+| 6 | @waxwingwallcovering | Waxwing Wallcovering (Larry Usé), Nashville TN + LA | Second-generation since early 1980s; multi-state luxury residential + commercial |
+| 7 | @scenichanger | Chris R. Murphy Paperhanger Ltd., Atlanta GA | **Maya Romanoff Preferred Installer** — Zuber, Gracie, de Gournay, Fromental hand-prints |
+| 8 | @jdpaperhangingcorp | J.D. Paperhanging Corp (Jeffrey DiFilippo), NYC + Hamptons | **46 years**, 10-person team; "Master Craftsman" feature in Behind the Hedges; high-end Palm Beach/Hamptons |
+| 9 | @wallpaperfelipe | Felipe Lima Wallpaper, NY/NJ/CT | Rhinne-vetted tri-state |
+| 10 | @masterwallpaper_ | Master Wallpaper (Ricardo), NY/NJ/CT | Rhinne-vetted tri-state |
+
+## Tier 2 — Rhinne-vetted regional pros
+
+| # | Handle | Studio · Location | Why |
+|---|---|---|---|
+| 11 | @masterwallcovering | Master Wallcoverings (Hugo), TX | Rhinne-vetted |
+| 12 | @matchpointwalls | Matchpoint Wallcoverings (Travis Clayborne), DC | Rhinne-vetted |
+| 13 | @newgenwc | New Generation Wallcovering, CA | Rhinne-vetted |
+| 14 | @seattlewallpaper | Seattle Wallpaper Pros, WA | Rhinne-vetted |
+| 15 | @boston.wallpaper | David Jenness, Boston MA | Rhinne-listed, works alongside Wallpaper West |
+| 16 | @magwallcovering | Magan Wallcovering Installers, Stamford CT/NY tri-state | **WIA Honorable Mention 2017** — Stark veneer, Elitis, MDC, Designtex |
+
+## Tier 3 — women-owned + heritage shops (natural feature angles)
+
+| # | Handle | Studio · Location | Why |
+|---|---|---|---|
+| 17 | @wall_ace_installation | Wall-Ace Luxury Wallcovering (Jessica Wallace), Tampa FL | Women-owned; profiled in VoyageTampa; 2-yr apprenticeship |
+| 18 | @thepaperdollwallpaper | The Paper Dolls (Meghan VanAlstyne), New Orleans LA | **Third-generation paperhanger**, women-owned since 2015 |
+| 19 | @talkingwalls901 | Talking Walls (PoLLy Ann Corpuz), Memphis TN | Studio brand: pattern consultations + install for designer trade |
+| 20 | @eastendwallcovering | East End Wallcovering (Yumi Hunt), Bucks County PA | DKNY/textile-design background; trained under Sarah Merenda; gold leaf, glass-bead, English papers |
+| 21 | @perlapaperhanging | Perla Paperhanging (Lauren Buckholtz), Baton Rouge LA | Trained at the Vermont U.S. School of Professional Paperhanging + apprenticed under master; women-owned |
+| 22 | @wallpaperhangerasheville | Susan Koenig, Asheville NC | **U.S. School of Professional Paperhanging certified (1987)**; former NGPP NJ chapter president |
+| 23 | @valpaintpaper | Valerie Lang Paint & Paper, Philadelphia PA | **WIA Residential Installation Specialist (RIS)** certificate holder |
+| 24 | @womenwhowallpaper | Women Who Wallpaper (Dina), New Orleans + NYC | Multi-city operation, insured residential + commercial |
+| 25 | @jincyswalls | Jincy's Wallcovering Services, Tampa Bay FL | **50+ years since 1973** — mother-of-pearl, mural, grasscloth |
+
+## Tier 4 — heritage / niche
+
+| # | Handle | Studio · Location | Why |
+|---|---|---|---|
+| 26 | @stuclarkwallcovering | Stu Clark Wallcovering, US-based / international | Since 1981, flies internationally for elite clients |
+| 27 | @platinumpaperhanging | Platinum Paperhanging (John Clift), Philly/Main Line PA | **Featured in Fine Homebuilding** demonstrating install technique |
+| 28 | @hangthemoonwallpaperinstaller | Hang The Moon LLC (Lynnie Long), Colorado Rockies | 35-year solo installer since 1990 |
+| 29 | @reevespaperhanging | Reeves Paperhanging Inc., East Coast | **40+ years** — Maya Romanoff, Fromental, de Gournay, Burberry, Elitis for ASID designers |
+| 30 | @persnicketypaperhanger | The Persnickety Paperhanger, Cleveland OH | 25+ years; three-generation apprenticed; English Pulp/Suede/Wood Veneer/Foil |
+
+## Verification flag
+
+- **@aPaperhanger (Heidi Wright Mead, Bay Area)** — referenced in 2018
+ Elworthy Studio interview as one of the most respected Bay Area
+ installers. Borderline find — check if she's already in our DB under
+ a variant handle before adding.
+
+## Excluded (out of US scope)
+
+Wells Interiors, Hamiltons, Bellefair, Medlin with Paint, Ian Chescoe (all
+UK), Twill (Canada/UK-rooted).
+
+## How to use this list
+
+**Option A — feature in IG-pipeline rotation:**
+After the initial 12 weeks (`outreach/IG_FEATURE_PIPELINE.md`), rows
+1–10 above become weeks 13–22. They're stronger feature subjects than
+WIA-only studios because each carries an external brand endorsement.
+
+**Option B — invite onto NPH directory:**
+Run a manual claim-onboard flow: visit each studio's website (column
+linked in the table), confirm domain, generate a pre-filled NPH listing
+draft (use the existing scrape-lite shape: name, city/state, website,
+IG, accreditations). Don't scrape automatically — the sources here are
+already curated.
+
+**Option C — DM each for permission:**
+Per `IG_FEATURE_PIPELINE.md` permissions guardrail — NPH IG can DM each
+asking permission to feature their public install photos in a future
+"WIA pro of the week" post. This is B2B reuse-rights permissioning,
+distinct from the cold marketing DM ban in `feedback_no_ig_dm_outreach.md`.
+
+**Do NOT:**
+- Ingest into DB without their permission (DATA_POLICY §6 — auto-listing
+ unconsented studios is what the WIA scrape was scoped for; this list
+ is curated discovery, different lane).
+- Cold-DM with NPH-marketing pitches.
+- Scrape their IG via Browserbase or any automated browser (Meta TOS).
+
+## Sources
+
+- WIA — wallcoveringinstallers.org
+- Phillip Jeffries blog (THE HANG, Master Installer features) — blog.phillipjeffries.com
+- Rhinne installer referral list — rhinne.us/resource/referrals
+- Pepper Home installer recommendations — pepper-home.com/blogs/the-thread
+- Paper Mills resources — papermills.net/resources
+- Behind the Hedges (J.D. DiFilippo profile) — behindthehedges.com
+- VoyageTampa (Wall-Ace profile) — voyagetampa.com
+- de Gournay technical hangers — degournay.com/technical
+- Elworthy Studio interview (Heidi Wright Mead reference) — elworthystudio.com
+- Individual studio websites (linked from each row)
diff --git a/outreach/IG_DM_PLAYBOOK.md b/outreach/IG_DM_PLAYBOOK.md
new file mode 100644
index 0000000..dd672ff
--- /dev/null
+++ b/outreach/IG_DM_PLAYBOOK.md
@@ -0,0 +1,91 @@
+# IG DM Playbook — Operator Guide
+
+One page. Read it once before the first batch.
+
+## (a) Why IG DM (and not cold email) for the paid-ad cohort
+
+The dream-team-fast panel ruled this is the right channel for studios already running paid ads (Google / Meta / Pinterest / TikTok / etc.). Why:
+
+- **Legally clean.** IG DM is a business channel, not commercial email. Zero CAN-SPAM / §17529.5 exposure, no MAILING_ADDRESS requirement in the body, no unsubscribe link required. (We still log the send for audit — see (e).)
+- **Higher response rate.** In a small luxury-trade niche, a peer-to-peer DM from Steve's actual IG profile reads as a colleague reaching out, not a vendor blast. Cold email from this cohort reads as spam.
+- **Signal of legitimacy.** The recipient sees Steve's IG profile (Designer Wallcoverings + portfolio of work) before they read the message. The DM lands warm.
+
+Email stays as a fallback, but only with explicit per-send authorization — see `scripts/send-claim-invitations.js`.
+
+## (b) How to generate the drafts
+
+```sh
+cd ~/Projects/NationalPaperHangers
+npm run dm-drafts -- --out=outreach/ig-batch.csv
+```
+
+Optional flags:
+- `--min=2` — only studios on 2+ paid platforms (tightest cohort)
+- `--state=CA` — scope to one state
+- `--limit=5` — cap to today's send count
+
+Each row gives you: business_name, ig_handle, city, state, paid_platforms, **dm_message** (paste-ready), claim_url. The dm_message field already ends with the claim URL — copy the whole field, paste, send.
+
+## (c) Cadence
+
+**5 DMs per day, max.** Hard rule.
+
+- More than that and IG starts treating Steve's account as spam (rate-limit, then shadow-ban).
+- The luxury trade is small — bursts get noticed and talked about. Slow drip = signal of genuine interest.
+- Spread the 5 across morning/afternoon, not all in one minute.
+
+Skip weekends. Trade buyers and studio owners are at projects then.
+
+## (d) When a studio replies
+
+**The claim flow is the funnel — not a sales pitch.** When they reply:
+
+1. Thank them for getting back.
+2. Send them their personal claim URL (it's already in the original DM, but resend if they ask).
+3. Answer one or two specific questions if they have them — but route them to `/installer/<slug>/claim` for anything beyond that. The page does the heavy lift.
+4. If they ask about pricing, the honest answer: Basic is free forever, Pro is $39/mo for the live calendar + Verified badge, Signature is $149/mo for premium placement. No upsell pressure — the listing stays visible regardless of tier.
+
+**Do not** pitch them on Designer Wallcoverings, do not try to upsell, do not mention the WIA badge unless they're a WIA member. The conversation goal is exactly one thing: get them to click the claim URL.
+
+## (e) Logging the send
+
+Every DM gets a row in `comms_send_audit`. The schema is channel-agnostic; `channel='ig_dm'` works fine.
+
+```sql
+INSERT INTO comms_send_audit
+ (channel, campaign, recipient, installer_id, decision, payload)
+VALUES
+ ('ig_dm', 'claim_invite', '@<handle>', <installer_id>, 'sent',
+ '{"dm_text":"<full message>","claim_url":"<url>"}'::jsonb);
+```
+
+Or, if you'd rather batch it, save the CSV after sending and run a one-shot loader; the audit row's just for reply tracking and the 7-day follow-up window.
+
+If they ask to be left alone, add the handle to suppression:
+
+```sql
+INSERT INTO comms_suppression (channel, identifier, reason, source, installer_id)
+VALUES ('ig_dm', '@<handle>', 'optout_reply', 'ig_dm_reply', <installer_id>);
+```
+
+The `generate-ig-dm-drafts.js` script does **not** scrub against suppression today (read-only, no DB writes). Before sending, eyeball the CSV against any handles in `comms_suppression WHERE channel='ig_dm'`.
+
+## (f) 7-day follow-up
+
+If no reply at day 7, **one polite nudge — then drop forever.**
+
+```
+Hey, following up on this in case it got buried — totally fine to ignore if not for you. Same link if it's useful: <claim_url>
+```
+
+If still no reply at day 14, do not message them again. Add a `comms_suppression` row with `reason='no_response_after_followup'` so we don't accidentally re-target them in a future batch.
+
+## Failure-rate watch (Steve's standing rule)
+
+If more than 10% of a batch fails (account locked, message bounced, IG flagged the sends as spam), pause everything and investigate. The DM script itself can't detect this — it's a manual eyeball check on Steve's IG outbox the next morning.
+
+## What this script will NOT do
+
+- Send to IG via the API. Meta TOS forbids unauthorized automation; Steve sends each one by hand.
+- Write anything to the DB. The script is read-only.
+- Make LLM calls. The DM is template + interpolation.
diff --git a/outreach/IG_FEATURE_PIPELINE.md b/outreach/IG_FEATURE_PIPELINE.md
new file mode 100644
index 0000000..d5fe36c
--- /dev/null
+++ b/outreach/IG_FEATURE_PIPELINE.md
@@ -0,0 +1,173 @@
+# NPH Instagram — 12-week feature pipeline
+
+Generated 2026-05-06 from live DB. Cadence: 1 featured WIA pro per week.
+Audience target: trade buyers (designers, hospitality, architects).
+Account voice: NPH (Steve), not the studios themselves. Focus is on
+**craft visibility** — the seam, the corner, the silk paste. Boring
+hero shots = scroll-past. Surface the install detail and you build trust.
+
+## The 12 weeks (live data)
+
+| W | Studio | City | IG | Angle |
+|---|---|---|---|---|
+| 1 | All American Wallpapering Inc. (Lic. #668551) | Murrieta CA | @wallpaper_hanger_greg | WIA + Google-Ads-active |
+| 2 | M. Carlson Painting & Wallcovering | Wayzata MN | @m.carlson.painting | WIA + Meta-active |
+| 3 | Bill Bender Painting & Wallcovering | Windham CT | @billbenderpainting | WIA |
+| 4 | Brian Atchley | Louisville KY | @brianatchley | WIA |
+| 5 | Byers Wallpaper & Painting | Purcellville VA | @byerswallpaper | WIA |
+| 6 | Coverwalls, Inc. | Pelham AL | @coverwallsinc1979 | WIA · est. 1979 |
+| 7 | D&L Wall Design | Miami FL | @dlwalldesign | WIA · hospitality DNA |
+| 8 | DiGilio Decorating | Forest Park IL | @digdecr8 | WIA · Chicago heritage |
+| 9 | Eleven Walls | Goodlettsville TN | @elevenwallsnash | WIA · Nashville growth |
+| 10 | Elite Paperhanging | Costa Mesa CA | @elitepaperhanging | WIA |
+| 11 | Gimme Paper LLC | Jacksonville FL | @gimme_wallpaper | WIA |
+| 12 | Hyde Park Wall Co. | Austin TX | @hydeparkwallco | WIA |
+
+Re-run anytime to pull a fresh-ranked list:
+
+```bash
+psql -d national_paper_hangers -f /dev/stdin <<'SQL'
+SELECT '@' || instagram_handle, business_name, city || ', ' || state
+FROM installers
+WHERE instagram_handle IS NOT NULL
+ AND (status = 'active' OR claim_status = 'unclaimed')
+ORDER BY
+ ('WIA Certified Installer' = ANY(accreditations))::int DESC,
+ COALESCE((ad_signals->>'paid_ads_count')::int, 0) DESC,
+ business_name
+LIMIT 50;
+SQL
+```
+
+## Weekly post template
+
+Steve runs `npm run dm-drafts` to refresh the pipeline. Then for each
+featured studio, this is the IG-post recipe (post natively from
+@nationalpaperhangers, not from the studio's own account):
+
+### Caption pattern (carousel post, 3-5 slides)
+
+```
+{Studio name} — {city}, {state}
+
+Slide 1: hero install shot from their feed (if their grid permits, or
+ask them via DM-with-permission first)
+Slide 2: a seam closeup OR a corner wrap — where the craft is visible
+Slide 3: their tools / crew / paste table
+Slide 4: their service-area + materials they specialize in
+Slide 5: NPH directory CTA
+
+Caption:
+
+This week's featured studio: @{ig_handle} in {city}, {state} —
+{angle, e.g. "WIA-certified, three generations of paperhangers"}.
+
+What we look for at NPH: studios who treat the seam, the corner, and
+the ceiling-line as the visible craft — not what to hide. {Studio}
+delivers on that with {a specific install material from their bio}.
+
+Trade buyers (designers, hospitality operators, architects) — search
+{their_city} on https://nationalpaperhangers.com to book.
+
+#paperhanger #wallcoveringinstaller #wallcoveringinstallation
+#luxurywallcovering #handpaintedwallpaper #grasscloth #silk #mural
+#tradedirectory #interiordesigner
+```
+
+### Permissions guardrail (CRITICAL)
+
+NEVER repost a studio's portfolio image to NPH's grid without their
+explicit permission. The request flow:
+
+1. NPH IG follows the studio account.
+2. NPH DMs (one-shot, polite, brand-channel — not a marketing pitch)
+ asking permission to feature 3 of their public install photos
+ in next week's "WIA pro of the week" post.
+3. If yes: tag them in the post + caption + first comment.
+4. If no / no-reply within 7 days: skip them, advance to next.
+
+This is a **business-to-business permissioning DM**, distinct from
+cold marketing DMs. It's specifically about reuse rights for content
+they've already published publicly. Different from the
+feedback_no_ig_dm_outreach.md ban (which was about NPH cold-pitching
+17 paid-ad-running studios for the claim flow).
+
+## Hashtag stack
+
+**Always include (these are real trade pros' tags):**
+- `#paperhanger`
+- `#wallcoveringinstaller`
+- `#wallcoveringinstallation`
+- `#wallpaperinstaller`
+
+**Material-specific (rotate based on the install featured):**
+- `#handpaintedwallpaper`
+- `#grasscloth`
+- `#silkwallcovering`
+- `#metallicleaf`
+- `#muralinstall`
+
+**Audience tags (signals trade-buyer intent):**
+- `#interiordesigner`
+- `#hospitalitydesign`
+- `#luxuryinteriors`
+- `#tradeprofessional`
+
+**AVOID:**
+- `#wallpaperinstall` — DIY peel-and-stick polluted
+- `#wallpaper` — desktop-wallpaper noise
+- generic `#decor` `#design` — drowned in volume
+
+## Bio + first 3 launch posts
+
+### IG bio
+
+```
+National Paper Hangers
+Trade directory + booking platform for luxury wallcovering installers.
+Hand-painted · silk · grasscloth · murals.
+Founded by @designerwallcoverings · NYC-based.
+🔗 nationalpaperhangers.com/find
+```
+
+### Launch Post 1: "What we mean by 'verified'"
+
+Carousel slide 1: black & white text on bg — "We don't slap 'verified' on
+a directory listing because they paid for it." Slide 2: NPH's actual
+verification gate (insurance + WIA + brand-trained credential review).
+Slide 3: link to the public ops review queue concept (anonymized).
+
+### Launch Post 2: "The seam tells you everything"
+
+A 4-image teaching post on grasscloth seams. Why you can't hide them,
+how a luxury install embraces the run-direction. Tags trade pros who
+demonstrate this in their portfolios. Good first-week SEO beacon for
+the trade hashtags.
+
+### Launch Post 3: "Why we list studios that haven't claimed yet"
+
+Frame the directory model honestly. Trade buyers find unclaimed
+studios via NPH; studios self-claim with a domain-restricted email.
+This is the on-ramp — counter the "yet another paid pay-to-play
+directory" assumption upfront.
+
+## What success looks like at week 12
+
+- 50-200 IG followers (small but trade-dense, not vanity)
+- 3-5 featured studios reciprocate by tagging NPH
+- 1 WIA chapter chair has reached out (the high-leverage "IG to
+ newsletter" pipeline opens)
+- 5+ studios from the 522 directory have claimed organically after
+ noticing their feature
+- 0 spam complaints, 0 IG DM blowback (because we never cold-DM'd)
+
+## Forum (PaintTalk + ContractorTalk) prep — NEXT TICK
+
+Forum research needs WebFetch (currently flaky on classifier). When it
+recovers OR via exa-agent at the next tick:
+1. Pull 5-10 recent unanswered threads on PaintTalk.com/forums where
+ the question is paste/primer/seam/grasscloth-specific.
+2. Draft Steve's substantive answer for each — voice = experienced
+ designer (Designer Wallcoverings), not a hands-on installer.
+3. NEVER drop NPH links in answers. Build a real profile first; the
+ subforum mods are strict on self-promo.
diff --git a/outreach/WIA_PARTNERSHIP.md b/outreach/WIA_PARTNERSHIP.md
new file mode 100644
index 0000000..487ef39
--- /dev/null
+++ b/outreach/WIA_PARTNERSHIP.md
@@ -0,0 +1,73 @@
+# WIA Partnership Outreach — National Paper Hangers
+
+**To:** Wallcovering Installers Association leadership (Executive Director + Board)
+**From:** Steve Abrams — Founder, National Paper Hangers / Owner, Designer Wallcoverings
+**Subject:** Booking infrastructure for WIA members — partnership proposal
+**Channel:** Email (steve@designerwallcoverings.com), reply-friendly
+
+---
+
+## What this is
+
+A short proposal to make National Paper Hangers (NPH) a WIA-recommended directory and booking platform — at zero cost to WIA, with concrete benefits we'd extend to your members on day one.
+
+## Who I am, briefly
+
+I run Designer Wallcoverings — a luxury wallcovering retailer that's been routing trade installs (designers, hotels, hospitality groups) to qualified installers for years. NPH is the booking-and-discovery infrastructure I built to make that referral flow scale beyond the names I personally know in Los Angeles.
+
+## The problem we're solving
+
+Trade buyers — interior designers, hospitality operators, architects — don't have a single place to (a) find a vetted luxury wallcovering installer outside their immediate market, and (b) actually schedule a consultation or site visit. WIA's member directory at wallcoveringinstallers.org is the trusted *list*, and wallpaperinstaller.com is a useful directory, but neither has a live booking calendar. The buyer ends up emailing six studios and waiting.
+
+NPH closes that loop: searchable profiles with portfolio + materials + brands handled, plus a real availability calendar so a designer in Atlanta can book a consult with a New York studio at 2pm Tuesday without a phone tag chain.
+
+## What's in it for WIA members
+
+If we move forward, every active WIA member in good standing gets:
+
+1. **Free Pro-tier trial (90 days, no credit card).** Pro is normally $39/mo and includes the live booking calendar, multi-image portfolio, and Verified badge. After 90 days the member can downgrade to the free Basic tier with no service interruption — their listing stays live either way.
+2. **"Verified-WIA" badge** on their NPH listing — a visible trust mark we'd cross-link to their WIA member page so search traffic that lands on NPH flows back to your association.
+3. **Dedicated WIA tab on /find.** Our public discovery page (nationalpaperhangers.com/find) would gain a "WIA Members" filter so buyers actively looking for accredited installers can scope to your roster in one click.
+4. **No data scraping.** We do not store member emails or phone numbers without explicit member consent. Members claim their own listing via a domain-restricted email-verify flow (DATA_POLICY.md v1.0). WIA's member list never leaves WIA's hands.
+
+## What I'd ask in return
+
+One of the following — whichever WIA leadership prefers:
+
+- **Mention NPH in one upcoming member newsletter** as a free booking-platform option for members. We provide the copy (draft below). Friction: 5 minutes.
+- **Co-host a 30-minute member webinar.** I demo the platform, members ask questions, anyone interested gets onboarded in real time. Friction: one calendar invite.
+- **Formal partnership agreement.** Logo placement on NPH ("WIA-recommended"), reciprocal link to WIA from our footer, and a handshake review at 6 months. Friction: a short MOU.
+
+I'm equally happy with any of the three. The newsletter mention is the lightest lift and the one most likely to get us off the ground; the formal partnership is the highest leverage if WIA wants to commit further.
+
+## What I won't do
+
+To pre-empt the obvious concerns:
+
+- I won't represent NPH as WIA-endorsed without your written sign-off.
+- I won't email or DM your members from any list WIA shares with me — outreach to non-LA studios goes through public channels (their own websites, public IG profiles) on a one-by-one basis.
+- I won't charge members anything during the trial window, and the Basic tier stays free indefinitely.
+
+## Draft member-facing announcement (WIA-co-signable, copy/paste)
+
+> **A new free tool for WIA members: National Paper Hangers**
+>
+> WIA has partnered with National Paper Hangers (nationalpaperhangers.com), a directory and booking platform built specifically for luxury wallcovering installers. WIA members in good standing get a 90-day free trial of the Pro tier — including a live booking calendar, portfolio gallery, and a Verified-WIA badge — at no cost. After the trial, the basic listing stays free indefinitely.
+>
+> Designer Wallcoverings, a long-time supporter of WIA's members, routes trade buyer leads through NPH, so claiming your listing now puts you in front of designers and hospitality operators actively sourcing installers outside their home market.
+>
+> Claim your listing in two minutes at nationalpaperhangers.com/find — search for your studio, click "Claim this listing", and verify with an email at your studio's domain.
+>
+> Questions: steve@designerwallcoverings.com.
+
+You're welcome to edit this freely; the goal is to make saying "yes" cost as little of your team's time as possible.
+
+## Next step
+
+If any of this is interesting, a 20-minute call works for me any weekday this month. Reply with two or three slots and I'll send the calendar invite.
+
+Either way — thank you for the work WIA has done for this trade. NPH was built to amplify it, not compete with it.
+
+— Steve Abrams
+National Paper Hangers · Designer Wallcoverings
+steve@designerwallcoverings.com
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..445b38d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2726 @@
+{
+ "name": "national-paper-hangers",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "national-paper-hangers",
+ "version": "0.1.0",
+ "dependencies": {
+ "@browserbasehq/sdk": "^2.10.0",
+ "bcrypt": "^5.1.1",
+ "connect-pg-simple": "^9.0.1",
+ "dotenv": "^16.4.5",
+ "ejs": "^3.1.10",
+ "express": "^4.21.0",
+ "express-rate-limit": "^8.5.0",
+ "express-session": "^1.18.0",
+ "helmet": "^8.1.0",
+ "luxon": "^3.5.0",
+ "morgan": "^1.10.0",
+ "multer": "^2.1.1",
+ "pg": "^8.13.1",
+ "playwright-core": "^1.59.1",
+ "slugify": "^1.6.6",
+ "stripe": "^17.4.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.7",
+ "supertest": "^7.2.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@browserbasehq/sdk": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.10.0.tgz",
+ "integrity": "sha512-pOL4yW8P8AI2+N5y6zEP6XXKqIXtYyKunr1JXppqQDOyKLxxvZEDqQCHJXWUzqgx3R1tGWpn7m9AjXN7MeYInA==",
+ "dependencies": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7"
+ }
+ },
+ "node_modules/@browserbasehq/sdk/node_modules/@types/node": {
+ "version": "18.19.130",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@browserbasehq/sdk/node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "license": "MIT"
+ },
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@paralleldrive/cuid2": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
+ "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^1.1.5"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.13",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+ "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^4.0.4"
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC"
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agent-base/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/agent-base/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
+ "node_modules/aproba": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
+ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
+ "license": "ISC"
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/basic-auth/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/bcrypt": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
+ "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.11",
+ "node-addon-api": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/component-emitter": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
+ "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "license": "MIT"
+ },
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/connect-pg-simple": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-9.0.1.tgz",
+ "integrity": "sha512-BuwWJH3K3aLpONkO9s12WhZ9ceMjIBxIJAh0JD9x4z1Y9nShmWqZvge5PG/+4j2cIOcguUoa2PSQ4HO/oTsrVg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg": "^8.8.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/cookiejar": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
+ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "license": "MIT"
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dezalgo": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
+ "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "asap": "^2.0.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz",
+ "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-session": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
+ "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "~0.7.2",
+ "cookie-signature": "~1.0.7",
+ "debug": "~2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "~5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-safe-stringify": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data-encoder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+ "license": "MIT"
+ },
+ "node_modules/formdata-node": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "1.0.0",
+ "web-streams-polyfill": "4.0.0-beta.3"
+ },
+ "engines": {
+ "node": ">= 12.20"
+ }
+ },
+ "node_modules/formidable": {
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
+ "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@paralleldrive/cuid2": "^2.2.2",
+ "dezalgo": "^1.0.4",
+ "once": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/tunnckoCore/commissions"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/morgan": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
+ "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth": "~2.0.1",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-finished": "~2.3.0",
+ "on-headers": "~1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/morgan/node_modules/on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/multer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+ "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
+ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
+ "license": "MIT"
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.14",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/nodemon/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/nodemon/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/nodemon/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.12.0",
+ "pg-pool": "^3.13.0",
+ "pg-protocol": "^1.13.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/slugify": {
+ "version": "1.6.9",
+ "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
+ "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/stripe": {
+ "version": "17.7.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
+ "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=8.1.0",
+ "qs": "^6.11.0"
+ },
+ "engines": {
+ "node": ">=12.*"
+ }
+ },
+ "node_modules/superagent": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
+ "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "component-emitter": "^1.3.1",
+ "cookiejar": "^2.1.4",
+ "debug": "^4.3.7",
+ "fast-safe-stringify": "^2.1.1",
+ "form-data": "^4.0.5",
+ "formidable": "^3.5.4",
+ "methods": "^1.1.2",
+ "mime": "2.6.0",
+ "qs": "^6.14.1"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
+ "node_modules/superagent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/superagent/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/superagent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supertest": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
+ "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cookie-signature": "^1.2.2",
+ "methods": "^1.1.2",
+ "superagent": "^10.3.0"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
+ "node_modules/supertest/node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "4.0.0-beta.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..37b2108
--- /dev/null
+++ b/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "national-paper-hangers",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Luxury wallcovering installer marketplace + scheduling platform",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "nodemon server.js",
+ "schema": "psql $PGDATABASE < db/schema.sql",
+ "seed": "psql $PGDATABASE < db/seed.sql",
+ "test": "node --test tests/smoke.test.js tests/compliance.test.js",
+ "dm-drafts": "node scripts/generate-ig-dm-drafts.js",
+ "go-live-check": "node scripts/go-live-check.js",
+ "gen-secrets": "node scripts/gen-secrets.js"
+ },
+ "dependencies": {
+ "@browserbasehq/sdk": "^2.10.0",
+ "bcrypt": "^5.1.1",
+ "connect-pg-simple": "^9.0.1",
+ "dotenv": "^16.4.5",
+ "ejs": "^3.1.10",
+ "express": "^4.21.0",
+ "express-rate-limit": "^8.5.0",
+ "express-session": "^1.18.0",
+ "helmet": "^8.1.0",
+ "luxon": "^3.5.0",
+ "morgan": "^1.10.0",
+ "multer": "^2.1.1",
+ "pg": "^8.13.1",
+ "playwright-core": "^1.59.1",
+ "slugify": "^1.6.6",
+ "stripe": "^17.4.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.7",
+ "supertest": "^7.2.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/public/css/admin.css b/public/css/admin.css
new file mode 100644
index 0000000..b859c80
--- /dev/null
+++ b/public/css/admin.css
@@ -0,0 +1,103 @@
+/* Admin dashboard styles */
+.admin-main { max-width: 1200px; margin: 0 auto; padding: 32px; }
+.admin-page-head { margin-bottom: 32px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
+.admin-page-head h1 { font-size: 36px; margin: 0 0 4px; }
+.admin-section { margin: 48px 0; }
+.section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 16px; }
+.section-link { font-size: 13px; }
+
+.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
+.stat { padding: 24px; border: 1px solid var(--border); background: var(--bg-alt); }
+.stat-label { display: block; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--fg-muted); margin-bottom: 8px; }
+.stat-value { font-family: var(--serif); font-size: 40px; }
+
+.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
+.action-card { display: block; padding: 24px; border: 1px solid var(--border); background: var(--bg); }
+.action-card:hover { border-color: var(--fg); opacity: 1; }
+.action-card h3 { margin-bottom: 4px; }
+.action-card p { font-size: 13px; color: var(--fg-muted); margin: 0; }
+
+.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.data-table th, .data-table td { padding: 12px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
+.data-table th { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); font-weight: 500; }
+.data-table .actions { display: flex; gap: 4px; }
+
+.filter-tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border); }
+.filter-tabs a { padding: 12px 16px; font-size: 13px; letter-spacing: 0.04em; text-transform: capitalize; border: none; color: var(--fg-muted); }
+.filter-tabs a.is-current { color: var(--fg); border-bottom: 2px solid var(--fg); }
+
+.inline-form { display: inline; margin: 0; }
+.inline-form-row { display: flex; gap: 8px; align-items: end; flex-wrap: wrap; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
+.inline-form-row input, .inline-form-row select { width: auto; }
+
+.cal-layout { display: grid; gap: 32px; }
+.availability-table td { padding: 8px 8px; }
+.time-off-list { list-style: none; padding: 0; margin: 0; }
+.time-off-list li { padding: 12px 0; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; font-size: 14px; }
+
+.profile-form fieldset { background: var(--bg-alt); }
+
+.price-card-current { border-color: var(--brass); border-width: 2px; }
+
+/* Onboarding checklist */
+.onboarding-checklist { background: var(--bg-alt); border: 1px solid var(--border); padding: 24px 28px; margin-bottom: 32px; }
+.onboarding-title { font-family: var(--serif); font-size: 20px; margin: 0 0 16px; }
+.onboarding-steps { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0; }
+.onboarding-step { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
+.onboarding-step:last-child { border-bottom: none; }
+.step-icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
+.step-done .step-icon { color: #4a7c4a; }
+.step-todo .step-icon { color: var(--fg-muted); }
+.step-label { flex: 1; }
+.step-done .step-label { color: var(--fg-muted); text-decoration: line-through; text-decoration-color: rgba(74,124,74,0.5); }
+.step-action { margin-left: auto; font-size: 12px; white-space: nowrap; }
+a.step-action { border-bottom: none; }
+.step-done-label { margin-left: auto; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: #4a7c4a; }
+
+/* Tag-input pill component */
+.tag-input-label { cursor: default; }
+.tag-input-hint { display: block; font-size: 11px; color: var(--fg-muted); text-transform: none; letter-spacing: 0; margin-bottom: 6px; }
+.tag-input-wrapper { position: relative; margin-bottom: 12px; }
+.tag-pill-box {
+ display: flex; flex-wrap: wrap; align-items: center;
+ gap: 6px; padding: 8px 10px; border: 1px solid var(--border-strong);
+ background: var(--bg); cursor: text; min-height: 46px;
+}
+.tag-pill-box:focus-within { border-color: var(--fg); }
+.tag-pill {
+ display: inline-flex; align-items: center; gap: 4px;
+ background: var(--fg); color: var(--bg);
+ font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
+ padding: 4px 8px; cursor: default; white-space: nowrap;
+}
+.tag-pill-remove {
+ background: none; border: none; cursor: pointer; color: inherit;
+ padding: 0 0 0 2px; font-size: 14px; line-height: 1;
+ display: flex; align-items: center;
+}
+.tag-pill-remove:hover { opacity: 0.65; }
+.tag-text-input {
+ border: none; outline: none; background: transparent; padding: 4px 0;
+ font-size: 13px; flex: 1; min-width: 140px; color: var(--fg); width: auto;
+}
+.tag-dropdown {
+ position: absolute; top: 100%; left: 0; right: 0; z-index: 100;
+ background: var(--bg); border: 1px solid var(--border-strong); border-top: none;
+ list-style: none; padding: 4px 0; margin: 0; max-height: 200px; overflow-y: auto;
+ box-shadow: var(--shadow);
+}
+.tag-dropdown-item {
+ padding: 9px 14px; font-size: 13px; cursor: pointer;
+}
+.tag-dropdown-item:hover, .tag-dropdown-item.is-active { background: var(--bg-alt); }
+
+/* Marketplace + Connect status (tick 3) */
+.callout-ok { background: rgba(74, 124, 74, 0.08); border-color: rgba(74, 124, 74, 0.4); color: var(--fg); }
+.callout-warn { background: rgba(196, 130, 14, 0.08); border-color: rgba(196, 130, 14, 0.4); }
+.stat-sub { display: block; font-size: 11px; margin-top: 4px; }
+.stat-warn .stat-value { color: #c4820e; }
+.deposit-paid { background: rgba(74, 124, 74, 0.18); color: #4a7c4a; }
+.deposit-requires_payment { background: rgba(196, 130, 14, 0.16); color: #c4820e; }
+.deposit-failed { background: rgba(196, 70, 70, 0.18); color: #c44646; }
+.deposit-canceled, .deposit-none { background: var(--bg-alt); color: var(--fg-muted); }
+.callout-prompt { background: rgba(184, 134, 11, 0.08); border: 1px solid rgba(184, 134, 11, 0.4); padding: 18px 22px; margin: 0 0 24px; }
diff --git a/public/css/public.css b/public/css/public.css
new file mode 100644
index 0000000..b6870bc
--- /dev/null
+++ b/public/css/public.css
@@ -0,0 +1,188 @@
+/* Public marketplace pages */
+
+/* Booking-deposit block on /installer/:slug/book — UX from parallel marketplace build */
+.deposit-block { border: 1px solid var(--border); padding: 20px; margin-top: 24px; background: var(--bg-alt); }
+.deposit-block legend { padding: 0 8px; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg-muted); }
+.callout-soft { display: block; padding: 12px 14px; background: var(--bg); border: 1px dashed var(--border-strong); font-size: 13px; color: var(--fg-muted); margin: 0; }
+.muted-2 { display: block; margin-top: 6px; font-size: 12px; color: var(--fg-muted); }
+#deposit-card-element { padding: 12px; background: var(--bg); border: 1px solid var(--border-strong); min-height: 44px; }
+#deposit-card-errors { margin-top: 8px; font-size: 13px; color: #c44646; min-height: 18px; }
+
+.hero { padding: 96px 32px 64px; }
+.hero-inner { max-width: 1100px; margin: 0 auto; }
+.hero-search { display: flex; gap: 8px; margin: 32px 0 12px; max-width: 720px; }
+.hero-search input, .hero-search select { flex: 1; }
+.hero-trust { font-size: 13px; color: var(--fg-muted); }
+
+.trust-strip { background: var(--bg-alt); padding: 32px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
+.trust-inner { max-width: 1440px; margin: 0 auto; display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; }
+.trust-inner div { display: flex; flex-direction: column; gap: 4px; }
+.trust-inner strong { font-family: var(--serif); font-size: 18px; }
+.trust-inner span { color: var(--fg-muted); font-size: 13px; }
+
+.featured, .how-it-works, .for-installers-cta, .find-page, .installer-profile, .long-form, .booking-confirmation { padding: 64px 32px; max-width: 1440px; margin: 0 auto; }
+.long-form { max-width: 720px; }
+.long-form h2 { margin-top: 32px; }
+
+.section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 32px; gap: 24px; flex-wrap: wrap; }
+
+.installer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 32px; }
+.installer-card { display: block; border: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 0; background: var(--bg); transition: transform 0.2s; overflow: hidden; }
+.installer-card:hover { transform: translateY(-2px); border-color: var(--border-strong); opacity: 1; }
+.card-image { aspect-ratio: 4/3; background: var(--bg-alt); display: flex; align-items: center; justify-content: center; border-bottom: 1px solid var(--border); }
+.card-image-placeholder { font-family: var(--serif); font-size: 56px; color: var(--brass); letter-spacing: 0.08em; }
+.card-body { padding: 20px; }
+.card-title { margin: 0 0 4px; font-size: 22px; }
+.card-meta { font-size: 12px; color: var(--fg-muted); margin: 0 0 12px; letter-spacing: 0.04em; }
+.card-headline { font-size: 14px; line-height: 1.5; margin: 0 0 12px; }
+.card-tags { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-wrap: wrap; }
+.card-creds { font-size: 12px; line-height: 1.5; color: var(--fg-muted); margin: 0 0 10px; padding: 6px 8px; background: var(--bg-alt); border-left: 2px solid var(--brass, #b8860b); }
+.card-creds .cred-pip { color: var(--brass, #b8860b); margin-right: 4px; }
+.card-foot { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
+
+.find-head { margin-bottom: 32px; }
+.find-filters { background: var(--bg-alt); padding: 24px; margin-bottom: 32px; border: 1px solid var(--border); }
+.filter-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1.5fr 1.5fr auto; gap: 12px; align-items: end; }
+.filter-row label { margin-bottom: 0; }
+.filter-row label span { display: block; margin-bottom: 4px; }
+.result-count { font-size: 13px; color: var(--fg-muted); margin-bottom: 16px; }
+
+.profile-hero { padding: 64px 32px; max-width: 1100px; margin: 0 auto; }
+.profile-hero-inner { max-width: 800px; }
+.profile-headline { font-size: 18px; margin: 12px 0 24px; }
+.profile-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 32px; }
+.profile-actions { display: flex; gap: 12px; flex-wrap: wrap; }
+.profile-body { display: grid; grid-template-columns: 2fr 1fr; gap: 64px; padding: 0 32px; max-width: 1100px; margin: 0 auto 64px; }
+.profile-bio p { font-size: 16px; line-height: 1.7; max-width: 60ch; }
+.profile-sidebar { background: var(--bg-alt); padding: 24px; border: 1px solid var(--border); }
+.profile-sidebar h3 { margin: 0 0 16px; font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
+.profile-sidebar dl { margin: 0; }
+.profile-sidebar dt { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); margin-top: 12px; }
+.profile-sidebar dd { margin: 4px 0 0; font-size: 14px; }
+.profile-portfolio { padding: 64px 32px; max-width: 1100px; margin: 0 auto; }
+.portfolio-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 32px; }
+.portfolio-card { border: 1px solid var(--border); }
+.portfolio-image { aspect-ratio: 3/2; background: var(--bg-alt); display: flex; align-items: center; justify-content: center; border-bottom: 1px solid var(--border); }
+.portfolio-image-placeholder { font-family: var(--serif); font-size: 64px; color: var(--brass); }
+
+/* "In the Seams" tabbed portfolio — UX idea #1 */
+.portfolio-card-tabbed .portfolio-image-wrap { position: relative; aspect-ratio: 3/2; background: var(--bg-alt); border-bottom: 1px solid var(--border); overflow: hidden; }
+.portfolio-card-tabbed .portfolio-shot { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 220ms ease; }
+.portfolio-card-tabbed .portfolio-shot.is-active { opacity: 1; }
+.portfolio-card-tabbed .portfolio-tabs { display: flex; border-bottom: 1px solid var(--border); }
+.portfolio-card-tabbed .portfolio-tab { flex: 1; padding: 10px 8px; background: transparent; border: 0; border-right: 1px solid var(--border); font: inherit; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg-muted); cursor: pointer; }
+.portfolio-card-tabbed .portfolio-tab:last-child { border-right: 0; }
+.portfolio-card-tabbed .portfolio-tab.is-active { color: var(--fg); background: var(--bg-alt); }
+.portfolio-card-tabbed .portfolio-tab:hover { color: var(--fg); }
+
+.portfolio-card h3 { padding: 16px 16px 0; margin: 0 0 4px; }
+.portfolio-meta { padding: 0 16px; font-size: 12px; color: var(--fg-muted); }
+.portfolio-desc { padding: 12px 16px 20px; font-size: 14px; }
+.profile-reviews { padding: 64px 32px; max-width: 1100px; margin: 0 auto; }
+.reviews-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px; }
+.review-card { padding: 20px; border: 1px solid var(--border); }
+.review-rating { color: var(--brass); font-size: 16px; letter-spacing: 0.1em; margin-bottom: 8px; }
+.review-author { font-size: 12px; color: var(--fg-muted); margin-top: 12px; }
+.profile-cta { text-align: center; padding: 96px 32px; background: var(--bg-alt); }
+
+.how-grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 48px; }
+.how-grid li { padding: 0; }
+.step { font-family: var(--serif); font-size: 56px; color: var(--brass); display: block; margin-bottom: 16px; }
+
+.for-installers-cta { background: var(--fg); color: var(--bg); padding: 96px 32px; text-align: center; max-width: none; margin: 64px 0 0; }
+.cta-inner { max-width: 720px; margin: 0 auto; }
+.for-installers-cta .display-sm { color: var(--bg); margin-bottom: 16px; }
+.for-installers-cta p { font-size: 18px; max-width: 56ch; margin: 0 auto 32px; opacity: 0.85; }
+.for-installers-cta .btn { background: var(--bg); color: var(--fg); border-color: var(--bg); }
+
+.pricing { padding: 64px 32px; max-width: 1440px; margin: 0 auto; }
+.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; }
+.price-card { border: 1px solid var(--border); padding: 32px; background: var(--bg); }
+.price-card-feature { background: var(--fg); color: var(--bg); }
+.price-card-feature .price, .price-card-feature h3 { color: var(--bg); }
+.price-card h3 { font-size: 22px; margin-bottom: 12px; }
+.price { font-family: var(--serif); font-size: 32px; margin: 0 0 24px; }
+.price span { font-size: 14px; color: var(--fg-muted); }
+.price-card-feature .price span { color: rgba(245,241,232,0.6); }
+.price-card ul { list-style: none; padding: 0; margin: 0 0 24px; }
+.price-card li { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.08); font-size: 14px; }
+.price-card-feature .btn { background: var(--bg); color: var(--fg); border-color: var(--bg); }
+
+.benefits-grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 32px; }
+.benefits-grid li { padding: 24px; background: var(--bg-alt); border: 1px solid var(--border); }
+
+/* Auth */
+.auth-page { display: flex; justify-content: center; padding: 96px 32px; }
+.auth-card { width: 100%; max-width: 420px; padding: 48px 32px; border: 1px solid var(--border); background: var(--bg-alt); }
+.auth-card-wide { max-width: 640px; }
+.auth-alt { font-size: 13px; color: var(--fg-muted); margin-top: 16px; text-align: center; }
+
+/* Booking page */
+.book-page { padding: 64px 32px; max-width: 1200px; margin: 0 auto; }
+.book-head { max-width: 720px; margin-bottom: 48px; }
+.book-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; }
+.calendar-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
+.slot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
+.slot-grid .day-group { grid-column: 1 / -1; font-family: var(--serif); font-size: 16px; color: var(--fg-muted); margin-top: 16px; padding-top: 8px; border-top: 1px solid var(--border); }
+.slot { padding: 12px 14px; border: 1px solid var(--border-strong); cursor: pointer; background: var(--bg); font-size: 13px; text-align: left; font-family: var(--sans); }
+.slot:hover { border-color: var(--fg); }
+.slot.is-selected { background: var(--fg); color: var(--bg); border-color: var(--fg); }
+.slot-loading { color: var(--fg-muted); font-size: 13px; }
+.selected-slot { padding: 16px; background: var(--bg-alt); margin-bottom: 24px; font-family: var(--serif); font-size: 18px; min-height: 56px; display: flex; align-items: center; }
+.book-form fieldset { margin-bottom: 16px; }
+
+.booking-confirmation { max-width: 720px; }
+.booking-card { background: var(--bg-alt); padding: 32px; border: 1px solid var(--border); margin: 32px 0; }
+.booking-card dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; gap: 12px 24px; }
+.booking-card dt { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
+.booking-card dd { margin: 0; font-size: 15px; }
+
+/* Find filter — loading transition */
+.installer-grid { transition: opacity 0.15s ease; }
+
+/* Claim — post-verification next-steps list */
+.claim-next-steps {
+ list-style: none; padding: 0; margin: 24px 0 0; counter-reset: claim-step;
+}
+.claim-next-steps li {
+ counter-increment: claim-step;
+ position: relative; padding: 16px 16px 16px 48px; margin-bottom: 8px;
+ background: var(--bg-alt); border: 1px solid var(--border); font-size: 14px; line-height: 1.6;
+}
+.claim-next-steps li::before {
+ content: counter(claim-step);
+ position: absolute; left: 14px; top: 16px;
+ font-family: var(--serif); font-size: 20px; color: var(--brass); line-height: 1;
+}
+.claim-next-steps li strong { display: block; margin-bottom: 4px; font-size: 15px; font-weight: 500; }
+
+@media (max-width: 900px) {
+ .footer-inner, .trust-inner, .how-grid, .profile-body, .filter-row, .book-layout { grid-template-columns: 1fr; gap: 24px; }
+ .footer-fineprint { flex-direction: column; }
+ .row3 { grid-template-columns: 1fr; }
+}
+
+/* PD imagery for cards (tick 4) */
+.card-image { overflow: hidden; }
+.card-image img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.installer-hero-image { position: relative; aspect-ratio: 16/7; overflow: hidden; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
+.installer-hero-image img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.installer-hero-image .photo-credit {
+ position: absolute; bottom: 8px; right: 8px;
+ background: rgba(0,0,0,0.55); color: #fff; padding: 4px 8px;
+ font-size: 10px; letter-spacing: 0.05em; border-radius: 2px;
+ text-decoration: none;
+}
+.installer-hero-image .photo-credit:hover { background: rgba(0,0,0,0.75); }
+
+/* Reserve CTA banner (tick 5) */
+.reserve-banner { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 18px 0; }
+.reserve-banner-inner { display: flex; gap: 24px; align-items: center; padding: 0 32px; max-width: 1200px; margin: 0 auto; flex-wrap: wrap; }
+.reserve-banner .reserve-copy { flex: 1; min-width: 280px; }
+.reserve-banner .reserve-kicker { margin: 0 0 4px; font-family: var(--serif); font-weight: 500; font-size: 1.15rem; color: var(--brass, #b8860b); }
+.reserve-banner .reserve-body { margin: 0; font-size: 0.92rem; color: var(--fg-muted); }
+.reserve-banner .reserve-cta { white-space: nowrap; }
+
+/* Card credential pip (tick 7) */
+.card-creds { margin: 8px 0 0; font-size: 12px; color: var(--brass, #b8860b); display: flex; align-items: center; gap: 6px; }
+.card-creds .cred-pip { font-size: 14px; }
diff --git a/public/css/templates.css b/public/css/templates.css
new file mode 100644
index 0000000..f98eec6
--- /dev/null
+++ b/public/css/templates.css
@@ -0,0 +1,273 @@
+/* ===================================================================
+ Per-template stylesheets for the 6 NPH installer page layouts.
+ Scoped under body.tpl-{name} so /admin/template can swap them
+ without touching public.css.
+ =================================================================== */
+
+/* Shared resets that apply across all templates */
+body[class^="tpl-"] .tpl-root,
+body[class*=" tpl-"] .tpl-root { display: block; max-width: 1280px; margin: 0 auto; padding: 0 24px; }
+body[class^="tpl-"] .tpl-portfolio-img,
+body[class*=" tpl-"] .tpl-portfolio-img { width: 100%; aspect-ratio: 4/3; object-fit: cover; display:block; }
+body[class^="tpl-"] .tpl-claim-cta,
+body[class*=" tpl-"] .tpl-claim-cta {
+ display:flex;align-items:center;gap:16px;padding:18px 22px;border:1px dashed currentColor;
+ border-radius:8px;margin:24px 0;font-size:14px;
+}
+
+/* ========================================================
+ 1. EDITORIAL — Architectural Digest profile feel.
+ Story-led, drop-cap intro, two-column body, Playfair display
+ ======================================================== */
+body.tpl-editorial { background: #fafaf6; color: #1a1a1a; font-family: 'Cormorant Garamond', Georgia, serif; }
+body.tpl-editorial .tpl-root { max-width: 1080px; padding: 0 32px; }
+body.tpl-editorial .ed-kicker {
+ font-family: Inter, sans-serif; font-size: 11px; letter-spacing: 0.18em;
+ text-transform: uppercase; color: #8c1d1d; margin: 48px 0 12px;
+}
+body.tpl-editorial .ed-title { font-size: clamp(38px, 6vw, 72px); font-weight: 500; line-height: 1.05; letter-spacing: -0.01em; margin: 0 0 8px; }
+body.tpl-editorial .ed-deck { font-size: 22px; font-weight: 300; font-style: italic; color: #555; max-width: 720px; margin: 0 0 36px; }
+body.tpl-editorial .ed-hero { width: 100%; aspect-ratio: 16/9; object-fit: cover; margin: 0 0 8px; border-radius: 2px; }
+body.tpl-editorial .ed-hero-cap { font-family: Inter, sans-serif; font-size: 12px; color: #777; margin: 0 0 56px; font-style: italic; }
+body.tpl-editorial .ed-body { display: grid; grid-template-columns: 1.6fr 1fr; gap: 64px; }
+body.tpl-editorial .ed-prose { font-size: 19px; line-height: 1.65; }
+body.tpl-editorial .ed-prose p:first-of-type::first-letter {
+ font-size: 78px; float: left; line-height: 0.85; padding: 8px 12px 0 0;
+ font-weight: 600; color: #8c1d1d;
+}
+body.tpl-editorial .ed-prose h2 { font-size: 30px; font-weight: 500; margin: 48px 0 16px; letter-spacing: -0.01em; }
+body.tpl-editorial .ed-side {
+ font-family: Inter, sans-serif; font-size: 14px; line-height: 1.55;
+ border-top: 2px solid #1a1a1a; padding-top: 24px; align-self: start; position: sticky; top: 24px;
+}
+body.tpl-editorial .ed-side h3 { font-family: 'Cormorant Garamond', serif; font-size: 22px; margin: 0 0 14px; }
+body.tpl-editorial .ed-side dl { display: grid; grid-template-columns: 100px 1fr; gap: 6px 18px; margin: 0 0 28px; }
+body.tpl-editorial .ed-side dt { color: #777; text-transform: uppercase; font-size: 11px; letter-spacing: 0.1em; }
+body.tpl-editorial .ed-side dd { margin: 0; color: #1a1a1a; }
+body.tpl-editorial .ed-portfolio { margin-top: 80px; padding-top: 56px; border-top: 2px solid #1a1a1a; }
+body.tpl-editorial .ed-portfolio h2 { font-size: 38px; font-weight: 500; margin: 0 0 28px; }
+body.tpl-editorial .ed-pf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 28px; }
+body.tpl-editorial .ed-pf-card figcaption { font-family: Inter, sans-serif; font-size: 13px; padding: 12px 0; }
+body.tpl-editorial .ed-cta { background: #1a1a1a; color: #fafaf6; padding: 64px 48px; margin: 96px 0 0; text-align: center; }
+body.tpl-editorial .ed-cta h2 { font-size: 42px; font-weight: 500; margin: 0 0 24px; }
+body.tpl-editorial .btn-ed { display: inline-block; background: #fafaf6; color: #1a1a1a; padding: 16px 36px; font-family: Inter, sans-serif; font-size: 14px; text-decoration: none; letter-spacing: 0.05em; }
+@media (max-width: 800px) { body.tpl-editorial .ed-body { grid-template-columns: 1fr; gap: 32px; } }
+
+/* ========================================================
+ 2. TRADE-PRO — workshop floor / spec-sheet aesthetic.
+ Mono + sans, kraft-paper background, technical panel
+ ======================================================== */
+body.tpl-trade-pro { background: #ede5d3; color: #14110b; font-family: 'JetBrains Mono', 'Courier New', monospace; }
+body.tpl-trade-pro .tpl-root { max-width: 1240px; padding: 0 24px; }
+body.tpl-trade-pro .tp-banner {
+ background: #14110b; color: #ede5d3; padding: 20px 24px; margin: 0 -24px 32px;
+ display: flex; justify-content: space-between; align-items: center;
+ font-family: Inter, sans-serif; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase;
+}
+body.tpl-trade-pro .tp-banner b { color: #ffaa00; }
+body.tpl-trade-pro .tp-headerblock { border: 2px solid #14110b; padding: 32px; margin: 0 0 32px; background: #fff8e7; }
+body.tpl-trade-pro .tp-job-no { font-size: 11px; color: #6a5e3d; letter-spacing: 0.18em; }
+body.tpl-trade-pro .tp-name { font-family: Inter, sans-serif; font-size: 44px; font-weight: 700; margin: 4px 0 12px; letter-spacing: -0.02em; }
+body.tpl-trade-pro .tp-meta { font-size: 13px; line-height: 1.7; }
+body.tpl-trade-pro .tp-grid { display: grid; grid-template-columns: 2fr 1.2fr; gap: 32px; }
+body.tpl-trade-pro .tp-specs { background: #fff8e7; border: 2px solid #14110b; }
+body.tpl-trade-pro .tp-specs h3 {
+ background: #14110b; color: #ede5d3; margin: 0; padding: 14px 20px; font-size: 12px;
+ font-family: Inter, sans-serif; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase;
+}
+body.tpl-trade-pro .tp-specs table { width: 100%; border-collapse: collapse; font-size: 13px; }
+body.tpl-trade-pro .tp-specs th, body.tpl-trade-pro .tp-specs td { padding: 10px 20px; text-align: left; border-bottom: 1px solid #d4c8a8; }
+body.tpl-trade-pro .tp-specs th { color: #6a5e3d; font-weight: 400; width: 40%; text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em; }
+body.tpl-trade-pro .tp-bio { padding: 32px; background: #fff8e7; border: 2px solid #14110b; font-family: Inter, sans-serif; font-size: 16px; line-height: 1.6; }
+body.tpl-trade-pro .tp-bio h2 { font-size: 14px; letter-spacing: 0.2em; text-transform: uppercase; margin: 0 0 16px; color: #6a5e3d; font-weight: 700; }
+body.tpl-trade-pro .tp-detail-row { margin-top: 56px; }
+body.tpl-trade-pro .tp-detail-row h2 { font-family: Inter, sans-serif; font-size: 22px; letter-spacing: -0.01em; margin: 0 0 8px; }
+body.tpl-trade-pro .tp-detail-row p { font-family: Inter, sans-serif; font-size: 14px; color: #4a3f29; max-width: 640px; margin: 0 0 24px; }
+body.tpl-trade-pro .tp-detail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
+body.tpl-trade-pro .tp-detail-card { border: 2px solid #14110b; background: #fff8e7; }
+body.tpl-trade-pro .tp-detail-card .label {
+ background: #14110b; color: #ede5d3; padding: 8px 14px; font-family: Inter, sans-serif;
+ font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase;
+ display: flex; justify-content: space-between;
+}
+body.tpl-trade-pro .tp-detail-card .label .stamp { color: #ffaa00; }
+body.tpl-trade-pro .tp-cta { background: #14110b; color: #ede5d3; padding: 48px; margin: 64px 0 0; text-align: center; font-family: Inter, sans-serif; }
+body.tpl-trade-pro .tp-cta a { background: #ffaa00; color: #14110b; display: inline-block; padding: 14px 32px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; }
+@media (max-width: 800px) { body.tpl-trade-pro .tp-grid { grid-template-columns: 1fr; } body.tpl-trade-pro .tp-name { font-size: 28px; } }
+
+/* ========================================================
+ 3. CONCIERGE — black + ivory + brushed gold, by appointment
+ ======================================================== */
+body.tpl-concierge { background: #0e0e0e; color: #f3ede1; font-family: 'Cormorant Garamond', serif; }
+body.tpl-concierge .tpl-root { max-width: 920px; padding: 0 32px; text-align: center; }
+body.tpl-concierge .cc-eyebrow { font-family: Inter, sans-serif; font-size: 10px; letter-spacing: 0.4em; text-transform: uppercase; color: #c8a96a; margin: 80px 0 20px; }
+body.tpl-concierge .cc-rule { width: 64px; height: 1px; background: #c8a96a; margin: 0 auto 28px; }
+body.tpl-concierge .cc-name { font-size: clamp(48px, 7vw, 88px); font-weight: 300; letter-spacing: -0.01em; margin: 0 0 16px; }
+body.tpl-concierge .cc-place { font-family: Inter, sans-serif; font-size: 13px; letter-spacing: 0.3em; text-transform: uppercase; color: #aaa097; margin: 0 0 56px; }
+body.tpl-concierge .cc-line {
+ font-size: 28px; font-weight: 300; font-style: italic; color: #c8a96a;
+ max-width: 640px; margin: 0 auto 64px; line-height: 1.4;
+}
+body.tpl-concierge .cc-hero { width: 100%; aspect-ratio: 21/9; object-fit: cover; filter: grayscale(0.15) contrast(1.05); margin: 0 0 64px; }
+body.tpl-concierge .cc-bio { font-size: 19px; line-height: 1.7; max-width: 680px; margin: 0 auto 80px; color: #e6dec8; text-align: left; }
+body.tpl-concierge .cc-strip { display: flex; justify-content: center; gap: 64px; padding: 48px 0; border-top: 1px solid #2a2620; border-bottom: 1px solid #2a2620; margin: 0 0 80px; }
+body.tpl-concierge .cc-strip div { font-family: Inter, sans-serif; font-size: 12px; letter-spacing: 0.15em; text-transform: uppercase; color: #aaa097; }
+body.tpl-concierge .cc-strip div b { display: block; font-family: 'Cormorant Garamond', serif; font-size: 32px; font-weight: 400; letter-spacing: -0.01em; color: #f3ede1; margin-top: 4px; text-transform: none; }
+body.tpl-concierge .cc-portfolio { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin: 0 0 80px; }
+body.tpl-concierge .cc-portfolio figure { margin: 0; }
+body.tpl-concierge .cc-portfolio figcaption { font-family: Inter, sans-serif; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; padding: 14px 0; color: #aaa097; text-align: left; }
+body.tpl-concierge .cc-cta { padding: 80px 0 120px; }
+body.tpl-concierge .cc-cta a {
+ display: inline-block; padding: 18px 56px; border: 1px solid #c8a96a;
+ color: #c8a96a; font-family: Inter, sans-serif; font-size: 13px; letter-spacing: 0.3em;
+ text-transform: uppercase; text-decoration: none; transition: all .3s;
+}
+body.tpl-concierge .cc-cta a:hover { background: #c8a96a; color: #0e0e0e; }
+@media (max-width: 700px) { body.tpl-concierge .cc-portfolio { grid-template-columns: 1fr; } body.tpl-concierge .cc-strip { flex-direction: column; gap: 24px; } }
+
+/* ========================================================
+ 4. STUDIO — modern grid, color blocks, big tiles
+ ======================================================== */
+body.tpl-studio { background: #fff; color: #0a0a0a; font-family: Inter, system-ui, sans-serif; }
+body.tpl-studio .tpl-root { max-width: 1320px; padding: 0; }
+body.tpl-studio .st-hero { display: grid; grid-template-columns: 1.1fr 1fr; min-height: 540px; }
+body.tpl-studio .st-hero-text { padding: 96px 64px; display: flex; flex-direction: column; justify-content: center; }
+body.tpl-studio .st-pill {
+ display: inline-flex; align-items: center; gap: 6px; align-self: start;
+ background: #efece5; padding: 8px 14px; border-radius: 999px;
+ font-size: 12px; font-weight: 600; letter-spacing: 0.02em; margin: 0 0 24px;
+}
+body.tpl-studio .st-pill::before { content: '●'; color: #16a34a; font-size: 10px; }
+body.tpl-studio .st-name { font-size: clamp(40px, 5.5vw, 64px); line-height: 1.02; letter-spacing: -0.025em; font-weight: 700; margin: 0 0 20px; }
+body.tpl-studio .st-headline { font-size: 22px; line-height: 1.5; color: #444; max-width: 560px; margin: 0 0 36px; }
+body.tpl-studio .st-actions { display: flex; gap: 12px; flex-wrap: wrap; }
+body.tpl-studio .st-actions a {
+ padding: 14px 24px; border-radius: 12px; text-decoration: none; font-size: 15px; font-weight: 600;
+}
+body.tpl-studio .st-actions .primary { background: #0a0a0a; color: #fff; }
+body.tpl-studio .st-actions .ghost { border: 1px solid #d4d2c8; color: #0a0a0a; }
+body.tpl-studio .st-hero-img { background-size: cover; background-position: center; }
+body.tpl-studio .st-strip {
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #d4d2c8;
+ margin: 0; padding: 1px;
+}
+body.tpl-studio .st-strip div { background: #fff; padding: 32px; }
+body.tpl-studio .st-strip .num { font-size: 36px; font-weight: 700; letter-spacing: -0.02em; }
+body.tpl-studio .st-strip .lbl { font-size: 13px; color: #666; margin-top: 4px; }
+body.tpl-studio .st-section { padding: 80px 64px; }
+body.tpl-studio .st-section h2 { font-size: 36px; line-height: 1.1; letter-spacing: -0.02em; font-weight: 700; margin: 0 0 32px; }
+body.tpl-studio .st-bio { display: grid; grid-template-columns: 1fr 1.6fr; gap: 64px; max-width: 1100px; margin: 0; }
+body.tpl-studio .st-bio h2 { margin: 0; }
+body.tpl-studio .st-bio p { font-size: 18px; line-height: 1.65; color: #2a2a2a; max-width: 720px; }
+body.tpl-studio .st-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
+body.tpl-studio .st-tile { border-radius: 16px; overflow: hidden; background: #efece5; }
+body.tpl-studio .st-tile img { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
+body.tpl-studio .st-tile-meta { padding: 18px 22px; }
+body.tpl-studio .st-tile-meta h3 { margin: 0 0 4px; font-size: 18px; font-weight: 700; }
+body.tpl-studio .st-tile-meta p { margin: 0; font-size: 13px; color: #666; }
+body.tpl-studio .st-cta {
+ margin: 80px 64px; padding: 64px; border-radius: 24px;
+ background: linear-gradient(135deg, #0a0a0a 0%, #1f1f1f 100%); color: #fff; text-align: center;
+}
+body.tpl-studio .st-cta h2 { color: #fff; }
+body.tpl-studio .st-cta a { display: inline-block; background: #fff; color: #0a0a0a; padding: 16px 32px; border-radius: 12px; text-decoration: none; font-weight: 600; font-size: 16px; }
+@media (max-width: 900px) {
+ body.tpl-studio .st-hero { grid-template-columns: 1fr; }
+ body.tpl-studio .st-hero-img { min-height: 320px; }
+ body.tpl-studio .st-hero-text { padding: 56px 32px; }
+ body.tpl-studio .st-strip { grid-template-columns: repeat(2, 1fr); }
+ body.tpl-studio .st-section, body.tpl-studio .st-cta { padding: 48px 32px; margin: 48px 24px; }
+ body.tpl-studio .st-bio { grid-template-columns: 1fr; gap: 24px; }
+}
+
+/* ========================================================
+ 5. HERITAGE — sepia, classical serif, "Est. 19XX" emblem
+ ======================================================== */
+body.tpl-heritage { background: #f7f0e2; color: #2c1f10; font-family: 'Cormorant Garamond', Georgia, serif; }
+body.tpl-heritage .tpl-root { max-width: 1100px; padding: 0 32px; }
+body.tpl-heritage .hr-emblem {
+ text-align: center; margin: 60px auto 0; padding: 24px 32px;
+ border-top: 3px double #6b4a1f; border-bottom: 3px double #6b4a1f;
+ display: inline-block; min-width: 280px;
+}
+body.tpl-heritage .hr-emblem-wrap { text-align: center; }
+body.tpl-heritage .hr-est {
+ font-family: Inter, sans-serif; font-size: 11px; letter-spacing: 0.4em;
+ text-transform: uppercase; color: #6b4a1f;
+}
+body.tpl-heritage .hr-year { font-size: 56px; font-weight: 400; letter-spacing: -0.01em; line-height: 1; margin: 8px 0; }
+body.tpl-heritage .hr-name { font-size: clamp(40px, 5.5vw, 64px); font-weight: 400; text-align: center; margin: 32px 0 12px; letter-spacing: -0.005em; }
+body.tpl-heritage .hr-place { text-align: center; font-family: Inter, sans-serif; font-size: 13px; letter-spacing: 0.25em; text-transform: uppercase; color: #6b4a1f; margin: 0 0 64px; }
+body.tpl-heritage .hr-hero { width: 100%; aspect-ratio: 16/9; object-fit: cover; filter: sepia(0.25) contrast(1.05); border: 8px solid #fff; box-shadow: 0 10px 30px rgba(44,31,16,0.12); margin: 0 0 80px; }
+body.tpl-heritage .hr-body { display: grid; grid-template-columns: 1.4fr 1fr; gap: 64px; }
+body.tpl-heritage .hr-prose { font-size: 19px; line-height: 1.75; }
+body.tpl-heritage .hr-prose p:first-of-type::first-letter {
+ font-size: 64px; float: left; line-height: 0.9; padding: 6px 12px 0 0;
+ color: #6b4a1f; font-weight: 500;
+}
+body.tpl-heritage .hr-side { font-family: Inter, sans-serif; font-size: 14px; line-height: 1.6; }
+body.tpl-heritage .hr-side h3 { font-family: 'Cormorant Garamond', serif; font-size: 24px; font-weight: 500; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #6b4a1f; }
+body.tpl-heritage .hr-side ul { list-style: none; padding: 0; margin: 0 0 32px; }
+body.tpl-heritage .hr-side li { padding: 8px 0; border-bottom: 1px dotted #c9b48c; display: flex; justify-content: space-between; }
+body.tpl-heritage .hr-side li::before { content: '◆'; color: #6b4a1f; margin-right: 8px; font-size: 10px; }
+body.tpl-heritage .hr-portfolio { margin: 96px 0; }
+body.tpl-heritage .hr-portfolio h2 { text-align: center; font-size: 38px; font-weight: 400; margin: 0 0 12px; }
+body.tpl-heritage .hr-portfolio-sub { text-align: center; font-style: italic; color: #6b4a1f; margin: 0 0 48px; }
+body.tpl-heritage .hr-pf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 32px; }
+body.tpl-heritage .hr-pf-card { background: #fff; padding: 16px; box-shadow: 0 4px 12px rgba(44,31,16,0.08); }
+body.tpl-heritage .hr-pf-card img { filter: sepia(0.15); }
+body.tpl-heritage .hr-pf-card figcaption { font-family: Inter, sans-serif; font-size: 13px; padding: 12px 4px 4px; color: #4a3520; }
+body.tpl-heritage .hr-cta { background: #2c1f10; color: #f7f0e2; padding: 64px 48px; text-align: center; margin: 64px 0; }
+body.tpl-heritage .hr-cta h2 { font-size: 38px; font-weight: 400; margin: 0 0 24px; }
+body.tpl-heritage .hr-cta a { display: inline-block; background: #c9a662; color: #2c1f10; padding: 14px 32px; text-decoration: none; font-family: Inter, sans-serif; letter-spacing: 0.1em; font-weight: 600; }
+@media (max-width: 800px) { body.tpl-heritage .hr-body { grid-template-columns: 1fr; gap: 32px; } }
+
+/* ========================================================
+ 6. BILINGÜE — EN/ES toggle, warm ochre + terracotta
+ ======================================================== */
+body.tpl-bilingue { background: #fff7ec; color: #2b1812; font-family: Inter, system-ui, sans-serif; }
+body.tpl-bilingue .tpl-root { max-width: 1180px; padding: 0 24px; }
+body.tpl-bilingue .bl-langtoggle {
+ display: inline-flex; gap: 0; border: 2px solid #2b1812; border-radius: 999px; overflow: hidden;
+ font-size: 12px; font-weight: 700; letter-spacing: 0.1em; margin: 32px 0 0;
+}
+body.tpl-bilingue .bl-langtoggle button {
+ background: #fff7ec; color: #2b1812; border: 0; padding: 8px 18px; cursor: pointer; font: inherit;
+}
+body.tpl-bilingue .bl-langtoggle button.active { background: #2b1812; color: #fff7ec; }
+body.tpl-bilingue [data-lang]:not([data-lang="en"]) { display: none; }
+body.tpl-bilingue.lang-es [data-lang="en"] { display: none; }
+body.tpl-bilingue.lang-es [data-lang="es"] { display: initial; }
+body.tpl-bilingue .bl-hero { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center; padding: 48px 0 80px; }
+body.tpl-bilingue .bl-hero-img { width: 100%; aspect-ratio: 4/5; object-fit: cover; border-radius: 24px; }
+body.tpl-bilingue .bl-name { font-size: clamp(40px, 6vw, 68px); line-height: 1.05; letter-spacing: -0.02em; font-weight: 800; margin: 16px 0 12px; color: #b85c2b; }
+body.tpl-bilingue .bl-place { font-size: 18px; letter-spacing: 0.05em; margin: 0 0 24px; color: #6a4538; }
+body.tpl-bilingue .bl-headline { font-size: 22px; line-height: 1.5; max-width: 520px; margin: 0 0 32px; color: #2b1812; }
+body.tpl-bilingue .bl-cta { display: inline-block; background: #b85c2b; color: #fff7ec; padding: 16px 32px; border-radius: 999px; text-decoration: none; font-weight: 700; font-size: 16px; }
+body.tpl-bilingue .bl-stats {
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
+ background: #ecd9b8; padding: 32px; border-radius: 24px; margin: 0 0 80px;
+}
+body.tpl-bilingue .bl-stats div { text-align: center; }
+body.tpl-bilingue .bl-stats .num { font-size: 36px; font-weight: 800; color: #b85c2b; line-height: 1; }
+body.tpl-bilingue .bl-stats .lbl { font-size: 13px; color: #6a4538; margin-top: 6px; }
+body.tpl-bilingue .bl-section { padding: 48px 0; }
+body.tpl-bilingue .bl-section h2 { font-size: 36px; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 24px; color: #2b1812; }
+body.tpl-bilingue .bl-callout {
+ background: #b85c2b; color: #fff7ec; padding: 24px 32px; border-radius: 16px;
+ font-size: 18px; font-weight: 600; margin: 0 0 32px; line-height: 1.5;
+}
+body.tpl-bilingue .bl-bio { font-size: 18px; line-height: 1.7; color: #2b1812; max-width: 760px; }
+body.tpl-bilingue .bl-pf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
+body.tpl-bilingue .bl-pf-card { background: #ecd9b8; border-radius: 16px; overflow: hidden; }
+body.tpl-bilingue .bl-pf-card img { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
+body.tpl-bilingue .bl-pf-card .meta { padding: 14px 18px; }
+body.tpl-bilingue .bl-pf-card h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; }
+body.tpl-bilingue .bl-pf-card p { margin: 0; font-size: 13px; color: #6a4538; }
+body.tpl-bilingue .bl-cta-final { background: #2b1812; color: #fff7ec; padding: 64px 48px; border-radius: 24px; text-align: center; margin: 64px 0; }
+body.tpl-bilingue .bl-cta-final h2 { color: #fff7ec; font-size: 36px; margin: 0 0 24px; }
+body.tpl-bilingue .bl-cta-final a { display: inline-block; background: #b85c2b; color: #fff7ec; padding: 16px 32px; border-radius: 999px; text-decoration: none; font-weight: 700; }
+@media (max-width: 800px) {
+ body.tpl-bilingue .bl-hero { grid-template-columns: 1fr; gap: 24px; padding: 32px 0 48px; }
+ body.tpl-bilingue .bl-stats { grid-template-columns: repeat(2, 1fr); }
+}
diff --git a/public/css/theme.css b/public/css/theme.css
new file mode 100644
index 0000000..68d34d3
--- /dev/null
+++ b/public/css/theme.css
@@ -0,0 +1,132 @@
+/* National Paper Hangers — luxury editorial theme.
+ Black + ivory base, oxblood accent, brass highlight.
+ CSS-var inversion drives dark/light toggle. */
+
+:root, [data-theme="light"] {
+ --bg: #f5f1e8;
+ --bg-alt: #ebe6d9;
+ --fg: #0e0e0e;
+ --fg-muted: #6a6a6a;
+ --border: rgba(14,14,14,0.12);
+ --border-strong: rgba(14,14,14,0.22);
+ --accent: #6b1a1a; /* oxblood */
+ --accent-fg: #f5f1e8;
+ --brass: #b08a3e;
+ --shadow: 0 1px 0 rgba(14,14,14,0.06), 0 8px 32px rgba(14,14,14,0.04);
+ --serif: 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
+ --sans: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+}
+[data-theme="dark"] {
+ --bg: #0e0e0e;
+ --bg-alt: #1a1a1a;
+ --fg: #f5f1e8;
+ --fg-muted: #8e8a82;
+ --border: rgba(245,241,232,0.14);
+ --border-strong: rgba(245,241,232,0.28);
+ --accent: #c47272;
+ --accent-fg: #0e0e0e;
+ --brass: #d6ac5c;
+ --shadow: 0 1px 0 rgba(0,0,0,0.4), 0 8px 32px rgba(0,0,0,0.4);
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+ font-family: var(--sans);
+ background: var(--bg);
+ color: var(--fg);
+ -webkit-font-smoothing: antialiased;
+ font-feature-settings: 'ss01';
+ line-height: 1.55;
+}
+a { color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--border-strong); transition: opacity .15s; }
+a:hover { opacity: 0.7; }
+
+/* Type */
+.display, .display-sm, h1, h2, h3, h4 { font-family: var(--serif); font-weight: 400; letter-spacing: 0.005em; line-height: 1.1; }
+.display { font-size: clamp(48px, 7vw, 96px); }
+.display-sm { font-size: clamp(32px, 4vw, 56px); }
+h1 { font-size: 36px; margin: 0 0 16px; }
+h2 { font-size: 28px; margin: 0 0 16px; }
+h3 { font-size: 20px; margin: 0 0 8px; font-weight: 500; }
+.kicker { font-family: var(--sans); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg-muted); margin: 0 0 20px; }
+.lede { font-size: 18px; line-height: 1.6; max-width: 64ch; color: var(--fg); }
+.muted { color: var(--fg-muted); font-size: 13px; }
+.section-title { font-family: var(--serif); font-size: 32px; margin: 0; }
+.section-sub { color: var(--fg-muted); font-size: 14px; }
+.section-link { font-size: 13px; }
+
+/* Layout */
+main { min-height: 60vh; }
+.site-header { border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 50; }
+.header-inner { display: flex; align-items: center; gap: 24px; padding: 18px 32px; max-width: 1440px; margin: 0 auto; }
+.brand { display: flex; align-items: center; gap: 12px; border: none; }
+.brand-mark { font-family: var(--serif); font-size: 14px; letter-spacing: 0.3em; color: var(--brass); }
+.brand-name { font-family: var(--serif); font-size: 20px; }
+.admin-tag { font-size: 10px; letter-spacing: 0.2em; padding: 2px 6px; background: var(--accent); color: var(--accent-fg); border-radius: 1px; text-transform: uppercase; margin-left: 8px; }
+.primary-nav { display: flex; gap: 24px; margin-left: auto; }
+.primary-nav a { border: none; font-size: 13px; letter-spacing: 0.04em; color: var(--fg-muted); }
+.primary-nav a.is-current, .primary-nav a:hover { color: var(--fg); opacity: 1; }
+.header-actions { display: flex; gap: 12px; align-items: center; }
+
+.theme-toggle {
+ background: transparent; border: 1px solid var(--border-strong); width: 32px; height: 32px;
+ border-radius: 999px; cursor: pointer; color: var(--fg); padding: 0; display: inline-flex;
+ align-items: center; justify-content: center; font-size: 14px;
+}
+[data-theme="light"] .t-dark, [data-theme="dark"] .t-light { display: none; }
+
+/* Buttons */
+.btn { display: inline-block; padding: 12px 22px; font-family: var(--sans); font-size: 13px; letter-spacing: 0.04em; border: 1px solid var(--fg); cursor: pointer; background: transparent; color: var(--fg); border-radius: 0; line-height: 1; text-align: center; }
+.btn:hover { opacity: 0.85; border-bottom: 1px solid var(--fg); }
+.btn-primary { background: var(--fg); color: var(--bg); }
+.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
+.btn-ghost { background: transparent; }
+.btn-lg { padding: 16px 28px; font-size: 14px; }
+.btn-sm { padding: 8px 14px; font-size: 12px; }
+a.btn { border-bottom: 1px solid var(--fg); }
+
+/* Forms */
+input, select, textarea { font-family: var(--sans); font-size: 14px; padding: 12px 14px; background: var(--bg); color: var(--fg); border: 1px solid var(--border-strong); border-radius: 0; width: 100%; }
+input:focus, select:focus, textarea:focus { outline: none; border-color: var(--fg); }
+label { display: block; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-muted); margin-bottom: 12px; }
+label > span { display: block; margin-bottom: 6px; }
+label.check { display: flex; align-items: center; gap: 8px; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--fg); }
+label.check input { width: auto; }
+fieldset { border: 1px solid var(--border); padding: 20px; margin: 0 0 20px; }
+legend { font-family: var(--serif); font-size: 18px; padding: 0 8px; }
+.row3 { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 16px; }
+.form-error { background: var(--accent); color: var(--accent-fg); padding: 12px 16px; margin: 0 0 20px; }
+.form-fineprint { font-size: 11px; color: var(--fg-muted); margin-top: 16px; }
+
+/* Footer */
+.site-footer { border-top: 1px solid var(--border); margin-top: 96px; padding: 64px 32px 32px; background: var(--bg-alt); }
+.footer-inner { max-width: 1440px; margin: 0 auto; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 48px; }
+.footer-brand { font-family: var(--serif); font-size: 22px; margin-bottom: 8px; }
+.footer-tag { color: var(--fg-muted); font-size: 13px; max-width: 36ch; }
+.footer-contact a { font-size: 13px; }
+.footer-col h4 { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg-muted); margin: 0 0 12px; font-weight: 500; }
+.footer-col a { display: block; font-size: 13px; padding: 4px 0; border-bottom: none; }
+.footer-fineprint { max-width: 1440px; margin: 48px auto 0; padding-top: 24px; border-top: 1px solid var(--border); font-size: 11px; color: var(--fg-muted); display: flex; justify-content: space-between; gap: 24px; }
+
+/* Callouts */
+.callout { background: var(--bg-alt); padding: 16px 20px; border-left: 3px solid var(--brass); margin: 0 0 24px; font-size: 14px; }
+.callout-success { border-left-color: #4a7c4a; }
+.callout-warn { border-left-color: var(--brass); }
+
+/* Badges */
+.tag { display: inline-block; font-size: 11px; padding: 4px 8px; background: var(--bg-alt); color: var(--fg); margin-right: 6px; margin-bottom: 4px; letter-spacing: 0.04em; }
+.verified-badge { display: inline-block; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; padding: 4px 8px; background: var(--accent); color: var(--accent-fg); }
+.signature-badge { display: inline-block; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; padding: 4px 8px; background: var(--brass); color: var(--bg); margin-left: 6px; }
+.unclaimed-badge { display: inline-block; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; padding: 4px 8px; background: var(--bg-alt); color: var(--fg-muted); border: 1px solid var(--border-strong); }
+.directory-badge { display: inline-block; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; padding: 4px 8px; background: var(--bg-alt); color: var(--fg-muted); border: 1px solid var(--border); }
+.badge { display: inline-block; font-size: 11px; padding: 4px 10px; border: 1px solid var(--border-strong); margin-right: 6px; margin-bottom: 4px; }
+.status-badge { font-size: 11px; padding: 3px 8px; letter-spacing: 0.12em; text-transform: uppercase; }
+.status-pending { background: var(--brass); color: var(--bg); }
+.status-confirmed { background: var(--fg); color: var(--bg); }
+.status-completed { background: var(--bg-alt); color: var(--fg-muted); }
+.status-canceled, .status-declined, .status-no_show { background: var(--bg-alt); color: var(--fg-muted); text-decoration: line-through; }
+.response-sla { font-size: 11px; color: var(--fg-muted); margin-left: 8px; }
+
+/* Empty state */
+.empty-state { text-align: center; padding: 64px 24px; border: 1px dashed var(--border-strong); }
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..8d8fb13
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">N</text>
+</svg>
\ No newline at end of file
diff --git a/public/img/segments/generic/69-drawing-of-an-interior-cabinet-du-salon-met-dp809355.jpg b/public/img/segments/generic/69-drawing-of-an-interior-cabinet-du-salon-met-dp809355.jpg
new file mode 100644
index 0000000..250ae53
Binary files /dev/null and b/public/img/segments/generic/69-drawing-of-an-interior-cabinet-du-salon-met-dp809355.jpg differ
diff --git a/public/img/segments/generic/70-drawing-of-an-interior-cabinet-du-salon-met-1972-642-9.jpg b/public/img/segments/generic/70-drawing-of-an-interior-cabinet-du-salon-met-1972-642-9.jpg
new file mode 100644
index 0000000..89d6d06
Binary files /dev/null and b/public/img/segments/generic/70-drawing-of-an-interior-cabinet-du-salon-met-1972-642-9.jpg differ
diff --git a/public/img/segments/generic/71-drawing-of-an-interior-salon-met-1972-642-8.jpg b/public/img/segments/generic/71-drawing-of-an-interior-salon-met-1972-642-8.jpg
new file mode 100644
index 0000000..8a8f781
Binary files /dev/null and b/public/img/segments/generic/71-drawing-of-an-interior-salon-met-1972-642-8.jpg differ
diff --git a/public/img/segments/generic/72-drawing-of-an-interior-salon-met-dp809413.jpg b/public/img/segments/generic/72-drawing-of-an-interior-salon-met-dp809413.jpg
new file mode 100644
index 0000000..8c2ab1c
Binary files /dev/null and b/public/img/segments/generic/72-drawing-of-an-interior-salon-met-dp809413.jpg differ
diff --git a/public/img/segments/generic/76-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg b/public/img/segments/generic/76-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg
new file mode 100644
index 0000000..77e99d8
Binary files /dev/null and b/public/img/segments/generic/76-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/77-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg b/public/img/segments/generic/77-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg
new file mode 100644
index 0000000..d189901
Binary files /dev/null and b/public/img/segments/generic/77-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/78-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg b/public/img/segments/generic/78-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg
new file mode 100644
index 0000000..0be3a7d
Binary files /dev/null and b/public/img/segments/generic/78-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/79-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg b/public/img/segments/generic/79-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg
new file mode 100644
index 0000000..48b64d5
Binary files /dev/null and b/public/img/segments/generic/79-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/80-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg b/public/img/segments/generic/80-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg
new file mode 100644
index 0000000..8da13e7
Binary files /dev/null and b/public/img/segments/generic/80-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/81-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg b/public/img/segments/generic/81-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg
new file mode 100644
index 0000000..f33435a
Binary files /dev/null and b/public/img/segments/generic/81-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/82-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg b/public/img/segments/generic/82-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg
new file mode 100644
index 0000000..c35d7f2
Binary files /dev/null and b/public/img/segments/generic/82-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/83-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg b/public/img/segments/generic/83-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg
new file mode 100644
index 0000000..400910f
Binary files /dev/null and b/public/img/segments/generic/83-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/84-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg b/public/img/segments/generic/84-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg
new file mode 100644
index 0000000..db824a9
Binary files /dev/null and b/public/img/segments/generic/84-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg differ
diff --git a/public/img/segments/generic/85-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg b/public/img/segments/generic/85-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg
new file mode 100644
index 0000000..1fb23ae
Binary files /dev/null and b/public/img/segments/generic/85-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg differ
diff --git a/public/img/segments/grasscloth/43-grasscloth-usa-1890-ch-18386939.jpg b/public/img/segments/grasscloth/43-grasscloth-usa-1890-ch-18386939.jpg
new file mode 100644
index 0000000..9201cb8
Binary files /dev/null and b/public/img/segments/grasscloth/43-grasscloth-usa-1890-ch-18386939.jpg differ
diff --git a/public/img/segments/grasscloth/44-grasscloth-usa-1890-ch-18386939-2.jpg b/public/img/segments/grasscloth/44-grasscloth-usa-1890-ch-18386939-2.jpg
new file mode 100644
index 0000000..b1327a3
Binary files /dev/null and b/public/img/segments/grasscloth/44-grasscloth-usa-1890-ch-18386939-2.jpg differ
diff --git a/public/img/segments/grasscloth/45-sidewall-usa-1900-1920-ch-18476209.jpg b/public/img/segments/grasscloth/45-sidewall-usa-1900-1920-ch-18476209.jpg
new file mode 100644
index 0000000..8c5518a
Binary files /dev/null and b/public/img/segments/grasscloth/45-sidewall-usa-1900-1920-ch-18476209.jpg differ
diff --git a/public/img/segments/grasscloth/46-sidewall-usa-1900-1920-ch-18476209-2.jpg b/public/img/segments/grasscloth/46-sidewall-usa-1900-1920-ch-18476209-2.jpg
new file mode 100644
index 0000000..67f23b4
Binary files /dev/null and b/public/img/segments/grasscloth/46-sidewall-usa-1900-1920-ch-18476209-2.jpg differ
diff --git a/public/img/segments/grasscloth/47-sidewall-france-1880-98-ch-18798297.jpg b/public/img/segments/grasscloth/47-sidewall-france-1880-98-ch-18798297.jpg
new file mode 100644
index 0000000..eb697cb
Binary files /dev/null and b/public/img/segments/grasscloth/47-sidewall-france-1880-98-ch-18798297.jpg differ
diff --git a/public/img/segments/grasscloth/48-borders-usa-1900-1920-ch-18471695.jpg b/public/img/segments/grasscloth/48-borders-usa-1900-1920-ch-18471695.jpg
new file mode 100644
index 0000000..d840e12
Binary files /dev/null and b/public/img/segments/grasscloth/48-borders-usa-1900-1920-ch-18471695.jpg differ
diff --git a/public/img/segments/grasscloth/49-sidewall-fragment-usa-1890-1910-ch-18475367.jpg b/public/img/segments/grasscloth/49-sidewall-fragment-usa-1890-1910-ch-18475367.jpg
new file mode 100644
index 0000000..c66eb6b
Binary files /dev/null and b/public/img/segments/grasscloth/49-sidewall-fragment-usa-1890-1910-ch-18475367.jpg differ
diff --git a/public/img/segments/grasscloth/50-sidewall-fragment-usa-1890-1910-ch-18475367-2.jpg b/public/img/segments/grasscloth/50-sidewall-fragment-usa-1890-1910-ch-18475367-2.jpg
new file mode 100644
index 0000000..1714604
Binary files /dev/null and b/public/img/segments/grasscloth/50-sidewall-fragment-usa-1890-1910-ch-18475367-2.jpg differ
diff --git a/public/img/segments/grasscloth/51-sample-book-usa-ca-1915-ch-18491413-11.jpg b/public/img/segments/grasscloth/51-sample-book-usa-ca-1915-ch-18491413-11.jpg
new file mode 100644
index 0000000..62a8770
Binary files /dev/null and b/public/img/segments/grasscloth/51-sample-book-usa-ca-1915-ch-18491413-11.jpg differ
diff --git a/public/img/segments/grasscloth/52-sample-book-usa-ca-1915-ch-18491413-20.jpg b/public/img/segments/grasscloth/52-sample-book-usa-ca-1915-ch-18491413-20.jpg
new file mode 100644
index 0000000..3d7126b
Binary files /dev/null and b/public/img/segments/grasscloth/52-sample-book-usa-ca-1915-ch-18491413-20.jpg differ
diff --git a/public/img/segments/grasscloth/53-sample-book-usa-ca-1915-ch-18491413-40.jpg b/public/img/segments/grasscloth/53-sample-book-usa-ca-1915-ch-18491413-40.jpg
new file mode 100644
index 0000000..0318ff6
Binary files /dev/null and b/public/img/segments/grasscloth/53-sample-book-usa-ca-1915-ch-18491413-40.jpg differ
diff --git a/public/img/segments/grasscloth/54-behangstalenboek-r-d-grasscloth-serie-3-objectnr-ka-17948-14.jpg b/public/img/segments/grasscloth/54-behangstalenboek-r-d-grasscloth-serie-3-objectnr-ka-17948-14.jpg
new file mode 100644
index 0000000..7259fa1
Binary files /dev/null and b/public/img/segments/grasscloth/54-behangstalenboek-r-d-grasscloth-serie-3-objectnr-ka-17948-14.jpg differ
diff --git a/public/img/segments/grasscloth/55-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg b/public/img/segments/grasscloth/55-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg
new file mode 100644
index 0000000..07599f8
Binary files /dev/null and b/public/img/segments/grasscloth/55-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg differ
diff --git a/public/img/segments/grasscloth/56-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg b/public/img/segments/grasscloth/56-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg
new file mode 100644
index 0000000..cc6a0c2
Binary files /dev/null and b/public/img/segments/grasscloth/56-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg differ
diff --git a/public/img/segments/hand_painted/29-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg b/public/img/segments/hand_painted/29-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg
new file mode 100644
index 0000000..35a7921
Binary files /dev/null and b/public/img/segments/hand_painted/29-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg differ
diff --git a/public/img/segments/hand_painted/30-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg b/public/img/segments/hand_painted/30-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg
new file mode 100644
index 0000000..6f1d3b5
Binary files /dev/null and b/public/img/segments/hand_painted/30-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg differ
diff --git a/public/img/segments/hand_painted/31-wallpaper-panel-met-25607.jpg b/public/img/segments/hand_painted/31-wallpaper-panel-met-25607.jpg
new file mode 100644
index 0000000..6f23d1e
Binary files /dev/null and b/public/img/segments/hand_painted/31-wallpaper-panel-met-25607.jpg differ
diff --git a/public/img/segments/hand_painted/32-wallpaper-panel-met-25906.jpg b/public/img/segments/hand_painted/32-wallpaper-panel-met-25906.jpg
new file mode 100644
index 0000000..859ca08
Binary files /dev/null and b/public/img/segments/hand_painted/32-wallpaper-panel-met-25906.jpg differ
diff --git a/public/img/segments/hand_painted/33-wallpaper-panel-met-25609.jpg b/public/img/segments/hand_painted/33-wallpaper-panel-met-25609.jpg
new file mode 100644
index 0000000..1eafbd4
Binary files /dev/null and b/public/img/segments/hand_painted/33-wallpaper-panel-met-25609.jpg differ
diff --git a/public/img/segments/hand_painted/34-wallpaper-panel-met-25610.jpg b/public/img/segments/hand_painted/34-wallpaper-panel-met-25610.jpg
new file mode 100644
index 0000000..c2447b3
Binary files /dev/null and b/public/img/segments/hand_painted/34-wallpaper-panel-met-25610.jpg differ
diff --git a/public/img/segments/hand_painted/35-wallpaper-panel-met-25900.jpg b/public/img/segments/hand_painted/35-wallpaper-panel-met-25900.jpg
new file mode 100644
index 0000000..777da90
Binary files /dev/null and b/public/img/segments/hand_painted/35-wallpaper-panel-met-25900.jpg differ
diff --git a/public/img/segments/hand_painted/36-wallpaper-panel-met-25903.jpg b/public/img/segments/hand_painted/36-wallpaper-panel-met-25903.jpg
new file mode 100644
index 0000000..adea56b
Binary files /dev/null and b/public/img/segments/hand_painted/36-wallpaper-panel-met-25903.jpg differ
diff --git a/public/img/segments/hand_painted/37-wallpaper-panel-met-25611.jpg b/public/img/segments/hand_painted/37-wallpaper-panel-met-25611.jpg
new file mode 100644
index 0000000..82eb7c1
Binary files /dev/null and b/public/img/segments/hand_painted/37-wallpaper-panel-met-25611.jpg differ
diff --git a/public/img/segments/hand_painted/38-wallpaper-panel-met-25612.jpg b/public/img/segments/hand_painted/38-wallpaper-panel-met-25612.jpg
new file mode 100644
index 0000000..5c0af34
Binary files /dev/null and b/public/img/segments/hand_painted/38-wallpaper-panel-met-25612.jpg differ
diff --git a/public/img/segments/hand_painted/39-behang-chinoiserie.jpg b/public/img/segments/hand_painted/39-behang-chinoiserie.jpg
new file mode 100644
index 0000000..7035338
Binary files /dev/null and b/public/img/segments/hand_painted/39-behang-chinoiserie.jpg differ
diff --git a/public/img/segments/hand_painted/40-rex-whistler-wallpaper-in-the-chinoiserie-style-with-a-pictu.jpg b/public/img/segments/hand_painted/40-rex-whistler-wallpaper-in-the-chinoiserie-style-with-a-pictu.jpg
new file mode 100644
index 0000000..4b89f31
Binary files /dev/null and b/public/img/segments/hand_painted/40-rex-whistler-wallpaper-in-the-chinoiserie-style-with-a-pictu.jpg differ
diff --git a/public/img/segments/hand_painted/41-sidewall-france-1890-1900-ch-18606171.jpg b/public/img/segments/hand_painted/41-sidewall-france-1890-1900-ch-18606171.jpg
new file mode 100644
index 0000000..7e10878
Binary files /dev/null and b/public/img/segments/hand_painted/41-sidewall-france-1890-1900-ch-18606171.jpg differ
diff --git a/public/img/segments/hand_painted/42-sidewall-france-1890-1900-ch-18606171-2.jpg b/public/img/segments/hand_painted/42-sidewall-france-1890-1900-ch-18606171-2.jpg
new file mode 100644
index 0000000..dbcf40f
Binary files /dev/null and b/public/img/segments/hand_painted/42-sidewall-france-1890-1900-ch-18606171-2.jpg differ
diff --git a/public/img/segments/hospitality/13-kenmore-hotel-1100-washington-avenue-lobby-stairway-detail-m.jpg b/public/img/segments/hospitality/13-kenmore-hotel-1100-washington-avenue-lobby-stairway-detail-m.jpg
new file mode 100644
index 0000000..9daeb36
Binary files /dev/null and b/public/img/segments/hospitality/13-kenmore-hotel-1100-washington-avenue-lobby-stairway-detail-m.jpg differ
diff --git a/public/img/segments/hospitality/14-kenmore-hotel-1100-washington-avenue-lobby-stairway35-kenmor.jpg b/public/img/segments/hospitality/14-kenmore-hotel-1100-washington-avenue-lobby-stairway35-kenmor.jpg
new file mode 100644
index 0000000..be7398b
Binary files /dev/null and b/public/img/segments/hospitality/14-kenmore-hotel-1100-washington-avenue-lobby-stairway35-kenmor.jpg differ
diff --git a/public/img/segments/hospitality/15-art-deco-geometric-carpets-designed-by-marion-dorn-for-the-l.jpg b/public/img/segments/hospitality/15-art-deco-geometric-carpets-designed-by-marion-dorn-for-the-l.jpg
new file mode 100644
index 0000000..2f75b71
Binary files /dev/null and b/public/img/segments/hospitality/15-art-deco-geometric-carpets-designed-by-marion-dorn-for-the-l.jpg differ
diff --git a/public/img/segments/luxury_residential/01-statelibqld-1-254167-drawing-room-in-the-bank-of-new-south-w.jpg b/public/img/segments/luxury_residential/01-statelibqld-1-254167-drawing-room-in-the-bank-of-new-south-w.jpg
new file mode 100644
index 0000000..af8253c
Binary files /dev/null and b/public/img/segments/luxury_residential/01-statelibqld-1-254167-drawing-room-in-the-bank-of-new-south-w.jpg differ
diff --git a/public/img/segments/luxury_residential/02-view-of-mckenzie-richey-house-interior-showing-living-room-a.jpg b/public/img/segments/luxury_residential/02-view-of-mckenzie-richey-house-interior-showing-living-room-a.jpg
new file mode 100644
index 0000000..f59ef54
Binary files /dev/null and b/public/img/segments/luxury_residential/02-view-of-mckenzie-richey-house-interior-showing-living-room-a.jpg differ
diff --git a/public/img/segments/luxury_residential/03-first-floor-veterans-tiffany-room-detail-of-table-with-wallp.jpg b/public/img/segments/luxury_residential/03-first-floor-veterans-tiffany-room-detail-of-table-with-wallp.jpg
new file mode 100644
index 0000000..2df547b
Binary files /dev/null and b/public/img/segments/luxury_residential/03-first-floor-veterans-tiffany-room-detail-of-table-with-wallp.jpg differ
diff --git a/public/img/segments/luxury_residential/04-interior-second-floor-room-6-detail-of-fireplace-john-g-wils.jpg b/public/img/segments/luxury_residential/04-interior-second-floor-room-6-detail-of-fireplace-john-g-wils.jpg
new file mode 100644
index 0000000..77e86e2
Binary files /dev/null and b/public/img/segments/luxury_residential/04-interior-second-floor-room-6-detail-of-fireplace-john-g-wils.jpg differ
diff --git a/public/img/segments/luxury_residential/05-drawing-an-interior-1837-40-ch-18708251.jpg b/public/img/segments/luxury_residential/05-drawing-an-interior-1837-40-ch-18708251.jpg
new file mode 100644
index 0000000..dda0a0f
Binary files /dev/null and b/public/img/segments/luxury_residential/05-drawing-an-interior-1837-40-ch-18708251.jpg differ
diff --git a/public/img/segments/luxury_residential/06-whittemore-house-parlor.jpg b/public/img/segments/luxury_residential/06-whittemore-house-parlor.jpg
new file mode 100644
index 0000000..c2e22bf
Binary files /dev/null and b/public/img/segments/luxury_residential/06-whittemore-house-parlor.jpg differ
diff --git a/public/img/segments/luxury_residential/07-victorian-parlor.jpg b/public/img/segments/luxury_residential/07-victorian-parlor.jpg
new file mode 100644
index 0000000..21c2193
Binary files /dev/null and b/public/img/segments/luxury_residential/07-victorian-parlor.jpg differ
diff --git a/public/img/segments/luxury_residential/08-interior-detail-of-fireplace-in-parlor-savannah-victorian-hi.jpg b/public/img/segments/luxury_residential/08-interior-detail-of-fireplace-in-parlor-savannah-victorian-hi.jpg
new file mode 100644
index 0000000..b41b807
Binary files /dev/null and b/public/img/segments/luxury_residential/08-interior-detail-of-fireplace-in-parlor-savannah-victorian-hi.jpg differ
diff --git a/public/img/segments/luxury_residential/09-interior-detail-of-cornice-around-chimneypiece-in-rear-parlo.jpg b/public/img/segments/luxury_residential/09-interior-detail-of-cornice-around-chimneypiece-in-rear-parlo.jpg
new file mode 100644
index 0000000..ba690b5
Binary files /dev/null and b/public/img/segments/luxury_residential/09-interior-detail-of-cornice-around-chimneypiece-in-rear-parlo.jpg differ
diff --git a/public/img/segments/luxury_residential/10-interior-detail-of-ceiling-medallion-in-rear-parlor-savannah.jpg b/public/img/segments/luxury_residential/10-interior-detail-of-ceiling-medallion-in-rear-parlor-savannah.jpg
new file mode 100644
index 0000000..67bc92d
Binary files /dev/null and b/public/img/segments/luxury_residential/10-interior-detail-of-ceiling-medallion-in-rear-parlor-savannah.jpg differ
diff --git a/public/img/segments/luxury_residential/11-interior-detail-of-ceiling-paper-in-rear-parlor-savannah-vic.jpg b/public/img/segments/luxury_residential/11-interior-detail-of-ceiling-paper-in-rear-parlor-savannah-vic.jpg
new file mode 100644
index 0000000..1f5c082
Binary files /dev/null and b/public/img/segments/luxury_residential/11-interior-detail-of-ceiling-paper-in-rear-parlor-savannah-vic.jpg differ
diff --git a/public/img/segments/luxury_residential/12-interior-detail-of-doorway-between-double-parlors-savannah-v.jpg b/public/img/segments/luxury_residential/12-interior-detail-of-doorway-between-double-parlors-savannah-v.jpg
new file mode 100644
index 0000000..2cd130d
Binary files /dev/null and b/public/img/segments/luxury_residential/12-interior-detail-of-doorway-between-double-parlors-savannah-v.jpg differ
diff --git a/public/img/segments/luxury_residential/73-414-east-waldburg-street-interior-detail-of-arch-in-middle-p.jpg b/public/img/segments/luxury_residential/73-414-east-waldburg-street-interior-detail-of-arch-in-middle-p.jpg
new file mode 100644
index 0000000..ea71df3
Binary files /dev/null and b/public/img/segments/luxury_residential/73-414-east-waldburg-street-interior-detail-of-arch-in-middle-p.jpg differ
diff --git a/public/img/segments/luxury_residential/74-interior-detail-wallpaper-stairhall-first-floor-bowen-house-.jpg b/public/img/segments/luxury_residential/74-interior-detail-wallpaper-stairhall-first-floor-bowen-house-.jpg
new file mode 100644
index 0000000..13ce1fb
Binary files /dev/null and b/public/img/segments/luxury_residential/74-interior-detail-wallpaper-stairhall-first-floor-bowen-house-.jpg differ
diff --git a/public/img/segments/manifest.json b/public/img/segments/manifest.json
new file mode 100644
index 0000000..41186e6
--- /dev/null
+++ b/public/img/segments/manifest.json
@@ -0,0 +1,1110 @@
+{
+ "generated_at": "2026-05-06T07:23:19.474Z",
+ "items": [
+ {
+ "file": "/img/segments/luxury_residential/01-statelibqld-1-254167-drawing-room-in-the-bank-of-new-south-w.jpg",
+ "segment": "luxury_residential",
+ "query": "drawing room interior wallpaper",
+ "source_title": "File:StateLibQld 1 254167 Drawing room in the Bank of New South Wales residence, Brisbane, ca. 1869.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:StateLibQld_1_254167_Drawing_room_in_the_Bank_of_New_South_Wales_residence,_Brisbane,_ca._1869.jpg",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "Item is held by John Oxley Library, State Library of Queensland.",
+ "width": 1200,
+ "height": 943,
+ "bytes": 110462
+ },
+ {
+ "file": "/img/segments/luxury_residential/02-view-of-mckenzie-richey-house-interior-showing-living-room-a.jpg",
+ "segment": "luxury_residential",
+ "query": "drawing room interior wallpaper",
+ "source_title": "File:View of McKenzie-Richey House interior showing living room and bedroom. Note the wallpaper, door trim and lean-to ceiling, facing northwest. - McKenzie Property, House, North HABS ID,8-PLAVI.V,2A-6.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:View_of_McKenzie-Richey_House_interior_showing_living_room_and_bedroom._Note_the_wallpaper,_door_trim_and_lean-to_ceiling,_facing_northwest._-_McKenzie_Property,_House,_North_HABS_ID,8-PLAVI.V,2A-6.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/id0415.photos.334231p",
+ "width": 1200,
+ "height": 971,
+ "bytes": 226833
+ },
+ {
+ "file": "/img/segments/luxury_residential/03-first-floor-veterans-tiffany-room-detail-of-table-with-wallp.jpg",
+ "segment": "luxury_residential",
+ "query": "drawing room interior wallpaper",
+ "source_title": "File:FIRST FLOOR, VETERANS (TIFFANY) ROOM, DETAIL OF TABLE WITH WALLPAPER ROLL LEGS - Seventh Regiment Armory, 643 Park Avenue, New York, New York County, NY HABS NY,31-NEYO,121-27.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:FIRST_FLOOR,_VETERANS_(TIFFANY)_ROOM,_DETAIL_OF_TABLE_WITH_WALLPAPER_ROLL_LEGS_-_Seventh_Regiment_Armory,_643_Park_Avenue,_New_York,_New_York_County,_NY_HABS_NY,31-NEYO,121-27.tif",
+ "license": "Public domain",
+ "creator": "Boucher, Jack E.\nRelated names:\n\nClinton, Charles\nPrice, Virginia B, transmitter",
+ "credit": "https://www.loc.gov/pictures/item/ny1581.photos.118682p",
+ "width": 1200,
+ "height": 1676,
+ "bytes": 594943
+ },
+ {
+ "file": "/img/segments/luxury_residential/04-interior-second-floor-room-6-detail-of-fireplace-john-g-wils.jpg",
+ "segment": "luxury_residential",
+ "query": "drawing room interior wallpaper",
+ "source_title": "File:INTERIOR, SECOND FLOOR, ROOM '-6', DETAIL OF FIREPLACE - John G. Wilson Building, Shenandoah Street, Harpers Ferry, Jefferson County, WV HABS WVA,19-HARF,20-6.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR,_SECOND_FLOOR,_ROOM_%27-6%27,_DETAIL_OF_FIREPLACE_-_John_G._Wilson_Building,_Shenandoah_Street,_Harpers_Ferry,_Jefferson_County,_WV_HABS_WVA,19-HARF,20-6.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/wv0244.photos.172476p",
+ "width": 1200,
+ "height": 864,
+ "bytes": 253880
+ },
+ {
+ "file": "/img/segments/luxury_residential/05-drawing-an-interior-1837-40-ch-18708251.jpg",
+ "segment": "luxury_residential",
+ "query": "drawing room interior wallpaper",
+ "source_title": "File:Drawing, An Interior, 1837–40 (CH 18708251).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing,_An_Interior,_1837%E2%80%9340_(CH_18708251).jpg",
+ "license": "Public domain",
+ "creator": "Mary Ellen Best",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 805,
+ "bytes": 370155
+ },
+ {
+ "file": "/img/segments/luxury_residential/06-whittemore-house-parlor.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:Whittemore House - parlor.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Whittemore_House_-_parlor.jpg",
+ "license": "Public domain",
+ "creator": "Frances Benjamin Johnston",
+ "credit": "This image is available from the United States Library of Congress's Prints and Photographs division under the digital ID cph.3a29532.This tag does not indicate the copyright status of the attached work. A normal copyright tag is still required. See Commons:Licensing.",
+ "width": 1200,
+ "height": 894,
+ "bytes": 222460
+ },
+ {
+ "file": "/img/segments/luxury_residential/07-victorian-parlor.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:Victorian Parlor.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Victorian_Parlor.jpg",
+ "license": "Public domain",
+ "creator": "Family member of JGKlein; author died more than 70 years ago",
+ "credit": "Family member of JGKlein",
+ "width": 1200,
+ "height": 902,
+ "bytes": 242166
+ },
+ {
+ "file": "/img/segments/luxury_residential/08-interior-detail-of-fireplace-in-parlor-savannah-victorian-hi.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:INTERIOR, DETAIL OF FIREPLACE IN PARLOR. - Savannah Victorian Historic District, 1002 Drayton Street (House), Savannah, Chatham County, GA HABS GA,26-SAV,53P-5.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR,_DETAIL_OF_FIREPLACE_IN_PARLOR._-_Savannah_Victorian_Historic_District,_1002_Drayton_Street_(House),_Savannah,_Chatham_County,_GA_HABS_GA,26-SAV,53P-5.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/ga0009.photos.055688p",
+ "width": 1200,
+ "height": 1682,
+ "bytes": 220594
+ },
+ {
+ "file": "/img/segments/luxury_residential/09-interior-detail-of-cornice-around-chimneypiece-in-rear-parlo.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:INTERIOR, DETAIL OF CORNICE AROUND CHIMNEYPIECE IN REAR PARLOR - Savannah Victorian Historic District, 224 East Henry Street (House), Savannah, Chatham County, GA HABS GA,26-SAV,53U-3.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR,_DETAIL_OF_CORNICE_AROUND_CHIMNEYPIECE_IN_REAR_PARLOR_-_Savannah_Victorian_Historic_District,_224_East_Henry_Street_(House),_Savannah,_Chatham_County,_GA_HABS_GA,26-SAV,53U-3.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/ga0083.photos.055706p",
+ "width": 1200,
+ "height": 1678,
+ "bytes": 277749
+ },
+ {
+ "file": "/img/segments/luxury_residential/10-interior-detail-of-ceiling-medallion-in-rear-parlor-savannah.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:INTERIOR, DETAIL OF CEILING MEDALLION IN REAR PARLOR - Savannah Victorian Historic District, 215 West Gwinnett Street (House), Savannah, Chatham County, GA HABS GA,26-SAV,53A-3.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR,_DETAIL_OF_CEILING_MEDALLION_IN_REAR_PARLOR_-_Savannah_Victorian_Historic_District,_215_West_Gwinnett_Street_(House),_Savannah,_Chatham_County,_GA_HABS_GA,26-SAV,53A-3.tif",
+ "license": "Public domain",
+ "creator": "Smalling, Walter Jr., creator",
+ "credit": "https://www.loc.gov/pictures/item/ga0086.photos.055637p",
+ "width": 1200,
+ "height": 1686,
+ "bytes": 472384
+ },
+ {
+ "file": "/img/segments/luxury_residential/11-interior-detail-of-ceiling-paper-in-rear-parlor-savannah-vic.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:INTERIOR, DETAIL OF CEILING, PAPER IN REAR PARLOR - Savannah Victorian Historic District, 215 West Gwinnett Street (House), Savannah, Chatham County, GA HABS GA,26-SAV,53A-4.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR,_DETAIL_OF_CEILING,_PAPER_IN_REAR_PARLOR_-_Savannah_Victorian_Historic_District,_215_West_Gwinnett_Street_(House),_Savannah,_Chatham_County,_GA_HABS_GA,26-SAV,53A-4.tif",
+ "license": "Public domain",
+ "creator": "Smalling, Walter Jr., creator",
+ "credit": "https://www.loc.gov/pictures/item/ga0086.photos.055638p",
+ "width": 1200,
+ "height": 1681,
+ "bytes": 380075
+ },
+ {
+ "file": "/img/segments/luxury_residential/12-interior-detail-of-doorway-between-double-parlors-savannah-v.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:INTERIOR, DETAIL OF DOORWAY BETWEEN DOUBLE PARLORS - Savannah Victorian Historic District, 321 East Bolton Street (House), Savannah, Chatham County, GA HABS GA,26-SAV,53L-8.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR,_DETAIL_OF_DOORWAY_BETWEEN_DOUBLE_PARLORS_-_Savannah_Victorian_Historic_District,_321_East_Bolton_Street_(House),_Savannah,_Chatham_County,_GA_HABS_GA,26-SAV,53L-8.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/ga0109.photos.055677p",
+ "width": 1200,
+ "height": 855,
+ "bytes": 136419
+ },
+ {
+ "file": "/img/segments/hospitality/13-kenmore-hotel-1100-washington-avenue-lobby-stairway-detail-m.jpg",
+ "segment": "hospitality",
+ "query": "art deco hotel lobby",
+ "source_title": "File:KENMORE HOTEL, 1100 WASHINGTON AVENUE, LOBBY STAIRWAY, DETAIL - Miami Beach Art Deco Historic District, Miami, Miami-Dade County, FL HABS FLA,13-MIAM,5-35.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:KENMORE_HOTEL,_1100_WASHINGTON_AVENUE,_LOBBY_STAIRWAY,_DETAIL_-_Miami_Beach_Art_Deco_Historic_District,_Miami,_Miami-Dade_County,_FL_HABS_FLA,13-MIAM,5-35.tif",
+ "license": "Public domain",
+ "creator": "Smalling, Walter Jr., creator",
+ "credit": "https://www.loc.gov/pictures/item/fl0160.photos.052210p",
+ "width": 1200,
+ "height": 1668,
+ "bytes": 279018
+ },
+ {
+ "file": "/img/segments/hospitality/14-kenmore-hotel-1100-washington-avenue-lobby-stairway35-kenmor.jpg",
+ "segment": "hospitality",
+ "query": "art deco hotel lobby",
+ "source_title": "File:KENMORE HOTEL, 1100 WASHINGTON AVENUE, LOBBY STAIRWAY35. KENMORE HOTEL, 1100 WASHINGTON AVENUE, LOBBY STAIRWAY, DETAIL - Miami Beach Art Deco Historic District, Miami, Miami-Dade HABS FLA,13-MIAM,5-34.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:KENMORE_HOTEL,_1100_WASHINGTON_AVENUE,_LOBBY_STAIRWAY35._KENMORE_HOTEL,_1100_WASHINGTON_AVENUE,_LOBBY_STAIRWAY,_DETAIL_-_Miami_Beach_Art_Deco_Historic_District,_Miami,_Miami-Dade_HABS_FLA,13-MIAM,5-34.tif",
+ "license": "Public domain",
+ "creator": "Smalling, Walter Jr., creator",
+ "credit": "https://www.loc.gov/pictures/item/fl0160.photos.052209p",
+ "width": 1200,
+ "height": 865,
+ "bytes": 166406
+ },
+ {
+ "file": "/img/segments/hospitality/15-art-deco-geometric-carpets-designed-by-marion-dorn-for-the-l.jpg",
+ "segment": "hospitality",
+ "query": "art deco hotel lobby",
+ "source_title": "File:Art Deco geometric carpets designed by Marion Dorn for the lobby at Claridge's Hotel in London, 1931.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Art_Deco_geometric_carpets_designed_by_Marion_Dorn_for_the_lobby_at_Claridge%27s_Hotel_in_London,_1931.jpg",
+ "license": "Public domain",
+ "creator": "Unknown authorUnknown author",
+ "credit": "https://raggedlifeblog.com/discovering-marion-dorn/",
+ "width": 1200,
+ "height": 1710,
+ "bytes": 316084
+ },
+ {
+ "file": "/img/segments/museum/16-household-furniture-and-interior-decoration-met-dp211452.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Household Furniture and Interior Decoration MET DP211452.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Household_Furniture_and_Interior_Decoration_MET_DP211452.jpg",
+ "license": "CC0",
+ "creator": "Creator:Thomas HopeCreator:Thomas BensleyCreator:Longman, Hurst, Rees and Orme",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1600,
+ "bytes": 345327
+ },
+ {
+ "file": "/img/segments/museum/17-household-furniture-and-interior-decoration-met-49pp-511r2.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Household Furniture and Interior Decoration MET 49PP 511R2.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Household_Furniture_and_Interior_Decoration_MET_49PP_511R2.jpg",
+ "license": "CC0",
+ "creator": "Creator:Thomas HopeCreator:Thomas BensleyCreator:Longman, Hurst, Rees and Orme",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 904,
+ "bytes": 448291
+ },
+ {
+ "file": "/img/segments/museum/18-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Furniture with Candelabra and Interior Decoration MET MM89905.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Furniture_with_Candelabra_and_Interior_Decoration_MET_MM89905.jpg",
+ "license": "CC0",
+ "creator": "Richard Bridgens",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 859,
+ "bytes": 140840
+ },
+ {
+ "file": "/img/segments/museum/19-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Furniture with Candelabra and Interior Decoration MET MM89903.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Furniture_with_Candelabra_and_Interior_Decoration_MET_MM89903.jpg",
+ "license": "CC0",
+ "creator": "Richard Bridgens",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 876,
+ "bytes": 153809
+ },
+ {
+ "file": "/img/segments/museum/20-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Furniture with Candelabra and Interior Decoration MET MM89902.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Furniture_with_Candelabra_and_Interior_Decoration_MET_MM89902.jpg",
+ "license": "CC0",
+ "creator": "Richard Bridgens",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1509,
+ "bytes": 253127
+ },
+ {
+ "file": "/img/segments/museum/21-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Furniture with Candelabra and Interior Decoration MET MM89904.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Furniture_with_Candelabra_and_Interior_Decoration_MET_MM89904.jpg",
+ "license": "CC0",
+ "creator": "Richard Bridgens",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 884,
+ "bytes": 113457
+ },
+ {
+ "file": "/img/segments/museum/22-bowl-with-interior-geometric-decoration-met-cop0206s1.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Bowl with Interior Geometric Decoration MET cop0206s1.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Bowl_with_Interior_Geometric_Decoration_MET_cop0206s1.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 900,
+ "bytes": 247301
+ },
+ {
+ "file": "/img/segments/museum/23-bowl-with-interior-geometric-decoration-met-dp276089.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Bowl with Interior Geometric Decoration MET DP276089.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Bowl_with_Interior_Geometric_Decoration_MET_DP276089.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 900,
+ "bytes": 180002
+ },
+ {
+ "file": "/img/segments/museum/24-bowl-with-interior-geometric-decoration-met-dp276088.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:Bowl with Interior Geometric Decoration MET DP276088.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Bowl_with_Interior_Geometric_Decoration_MET_DP276088.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 971,
+ "bytes": 157683
+ },
+ {
+ "file": "/img/segments/museum/25-national-museum-of-serbia-staircase-decoration-p1.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:National Museum of Serbia - staircase decoration - p1.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:National_Museum_of_Serbia_-_staircase_decoration_-_p1.jpg",
+ "license": "CC0",
+ "creator": "Александр Сигачёв",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 800,
+ "bytes": 299400
+ },
+ {
+ "file": "/img/segments/museum/26-national-museum-of-serbia-staircase-decoration-p2.jpg",
+ "segment": "museum",
+ "query": "museum interior decoration",
+ "source_title": "File:National Museum of Serbia - staircase decoration - p2.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:National_Museum_of_Serbia_-_staircase_decoration_-_p2.jpg",
+ "license": "CC0",
+ "creator": "Александр Сигачёв",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 800,
+ "bytes": 295211
+ },
+ {
+ "file": "/img/segments/museum/27-dining-room-longfellow-national-historic-site-dsc04698.jpg",
+ "segment": "museum",
+ "query": "historic dining room interior",
+ "source_title": "File:Dining room - Longfellow National Historic Site - DSC04698.JPG",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Dining_room_-_Longfellow_National_Historic_Site_-_DSC04698.JPG",
+ "license": "CC0",
+ "creator": "Daderot",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 800,
+ "bytes": 282363
+ },
+ {
+ "file": "/img/segments/museum/28-08152-grand-canyon-historic-interior-of-bright-angel-lodge-d.jpg",
+ "segment": "museum",
+ "query": "historic dining room interior",
+ "source_title": "File:08152 Grand Canyon Historic Interior of Bright Angel Lodge Dining Room (5897851706).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:08152_Grand_Canyon_Historic_Interior_of_Bright_Angel_Lodge_Dining_Room_(5897851706).jpg",
+ "license": "Public domain",
+ "creator": "Grand Canyon National Park",
+ "credit": "08152 Grand Canyon Historic_ Interior of Bright Angel Lodge Dining Room",
+ "width": 1200,
+ "height": 947,
+ "bytes": 91142
+ },
+ {
+ "file": "/img/segments/hand_painted/29-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Hand-painted wallpaper at Morgan's Mount Vernon Farm of Southside, West Virginia LCCN2015631937.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Hand-painted_wallpaper_at_Morgan%27s_Mount_Vernon_Farm_of_Southside,_West_Virginia_LCCN2015631937.tif",
+ "license": "Public domain",
+ "creator": "Carol M. Highsmith",
+ "credit": "Library of Congress\n\nCatalog: http://lccn.loc.gov/2015631937\nImage download: https://cdn.loc.gov/master/pnp/highsm/31800/31862a.tif\nOriginal url: http://hdl.loc.gov/loc.pnp/highsm.31862",
+ "width": 1200,
+ "height": 1798,
+ "bytes": 356736
+ },
+ {
+ "file": "/img/segments/hand_painted/30-hand-painted-wallpaper-at-morgan-s-mount-vernon-farm-of-sout.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Hand-painted wallpaper at Morgan's Mount Vernon Farm of Southside, West Virginia LCCN2015631938.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Hand-painted_wallpaper_at_Morgan%27s_Mount_Vernon_Farm_of_Southside,_West_Virginia_LCCN2015631938.tif",
+ "license": "Public domain",
+ "creator": "Carol M. Highsmith",
+ "credit": "Library of Congress\n\nCatalog: http://lccn.loc.gov/2015631938\nImage download: https://cdn.loc.gov/master/pnp/highsm/31800/31863a.tif\nOriginal url: http://hdl.loc.gov/loc.pnp/highsm.31863",
+ "width": 1200,
+ "height": 801,
+ "bytes": 181661
+ },
+ {
+ "file": "/img/segments/hand_painted/31-wallpaper-panel-met-25607.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25607.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25607.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1517,
+ "bytes": 152156
+ },
+ {
+ "file": "/img/segments/hand_painted/32-wallpaper-panel-met-25906.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25906.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25906.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 955,
+ "bytes": 207896
+ },
+ {
+ "file": "/img/segments/hand_painted/33-wallpaper-panel-met-25609.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25609.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25609.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1497,
+ "bytes": 258009
+ },
+ {
+ "file": "/img/segments/hand_painted/34-wallpaper-panel-met-25610.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25610.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25610.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1505,
+ "bytes": 328810
+ },
+ {
+ "file": "/img/segments/hand_painted/35-wallpaper-panel-met-25900.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25900.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25900.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1473,
+ "bytes": 483579
+ },
+ {
+ "file": "/img/segments/hand_painted/36-wallpaper-panel-met-25903.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25903.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25903.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1497,
+ "bytes": 244159
+ },
+ {
+ "file": "/img/segments/hand_painted/37-wallpaper-panel-met-25611.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25611.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25611.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1488,
+ "bytes": 603319
+ },
+ {
+ "file": "/img/segments/hand_painted/38-wallpaper-panel-met-25612.jpg",
+ "segment": "hand_painted",
+ "query": "hand painted wallpaper",
+ "source_title": "File:Wallpaper Panel MET 25612.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_Panel_MET_25612.jpg",
+ "license": "CC0",
+ "creator": null,
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 1471,
+ "bytes": 513324
+ },
+ {
+ "file": "/img/segments/hand_painted/39-behang-chinoiserie.jpg",
+ "segment": "hand_painted",
+ "query": "chinoiserie wallpaper",
+ "source_title": "File:Behang.chinoiserie.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Behang.chinoiserie.jpg",
+ "license": "Public domain",
+ "creator": "Taco Tichelaar",
+ "credit": "Museum Geelvinck-Hinlopen Huis",
+ "width": 1200,
+ "height": 1770,
+ "bytes": 488429
+ },
+ {
+ "file": "/img/segments/hand_painted/40-rex-whistler-wallpaper-in-the-chinoiserie-style-with-a-pictu.jpg",
+ "segment": "hand_painted",
+ "query": "chinoiserie wallpaper",
+ "source_title": "File:Rex Whistler - Wallpaper in the Chinoiserie Style, with a Picture Frame as its Central Motif 1932.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Rex_Whistler_-_Wallpaper_in_the_Chinoiserie_Style,_with_a_Picture_Frame_as_its_Central_Motif_1932.jpg",
+ "license": "Public domain",
+ "creator": "Rex Whistler",
+ "credit": "https://artuk.org/discover/artworks/wallpaper-in-the-chinoiserie-style-with-a-picture-frame-as-its-central-motif-painted-to-house-picassos-lenfant-au-pigeon-31062",
+ "width": 1200,
+ "height": 1588,
+ "bytes": 2249751
+ },
+ {
+ "file": "/img/segments/hand_painted/41-sidewall-france-1890-1900-ch-18606171.jpg",
+ "segment": "hand_painted",
+ "query": "chinoiserie wallpaper",
+ "source_title": "File:Sidewall (France), 1890–1900 (CH 18606171).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_(France),_1890%E2%80%931900_(CH_18606171).jpg",
+ "license": "Public domain",
+ "creator": "Made by Zuber & Cie",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1797,
+ "bytes": 950588
+ },
+ {
+ "file": "/img/segments/hand_painted/42-sidewall-france-1890-1900-ch-18606171-2.jpg",
+ "segment": "hand_painted",
+ "query": "chinoiserie wallpaper",
+ "source_title": "File:Sidewall (France), 1890–1900 (CH 18606171-2).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_(France),_1890%E2%80%931900_(CH_18606171-2).jpg",
+ "license": "Public domain",
+ "creator": "Made by Zuber & Cie",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1816,
+ "bytes": 1166668
+ },
+ {
+ "file": "/img/segments/grasscloth/43-grasscloth-usa-1890-ch-18386939.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Grasscloth (USA), 1890 (CH 18386939).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Grasscloth_(USA),_1890_(CH_18386939).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 730,
+ "bytes": 327792
+ },
+ {
+ "file": "/img/segments/grasscloth/44-grasscloth-usa-1890-ch-18386939-2.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Grasscloth (USA), 1890 (CH 18386939-2).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Grasscloth_(USA),_1890_(CH_18386939-2).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 742,
+ "bytes": 304418
+ },
+ {
+ "file": "/img/segments/grasscloth/45-sidewall-usa-1900-1920-ch-18476209.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sidewall (USA), 1900–1920 (CH 18476209).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_(USA),_1900%E2%80%931920_(CH_18476209).jpg",
+ "license": "Public domain",
+ "creator": "Made by Illinois W.P. Mills",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 2046,
+ "bytes": 1091965
+ },
+ {
+ "file": "/img/segments/grasscloth/46-sidewall-usa-1900-1920-ch-18476209-2.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sidewall (USA), 1900–1920 (CH 18476209-2).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_(USA),_1900%E2%80%931920_(CH_18476209-2).jpg",
+ "license": "Public domain",
+ "creator": "Made by Illinois W.P. Mills",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 2232,
+ "bytes": 1031695
+ },
+ {
+ "file": "/img/segments/grasscloth/47-sidewall-france-1880-98-ch-18798297.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sidewall (France), 1880–98 (CH 18798297).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_(France),_1880%E2%80%9398_(CH_18798297).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1200,
+ "bytes": 320588
+ },
+ {
+ "file": "/img/segments/grasscloth/48-borders-usa-1900-1920-ch-18471695.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Borders (USA), 1900–1920 (CH 18471695).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Borders_(USA),_1900%E2%80%931920_(CH_18471695).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 732,
+ "bytes": 422129
+ },
+ {
+ "file": "/img/segments/grasscloth/49-sidewall-fragment-usa-1890-1910-ch-18475367.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sidewall - Fragment (USA), 1890–1910 (CH 18475367).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_-_Fragment_(USA),_1890%E2%80%931910_(CH_18475367).jpg",
+ "license": "Public domain",
+ "creator": "Made by Janeway & Co. Inc.",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 10072,
+ "bytes": 3061092
+ },
+ {
+ "file": "/img/segments/grasscloth/50-sidewall-fragment-usa-1890-1910-ch-18475367-2.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sidewall - Fragment (USA), 1890–1910 (CH 18475367-2).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sidewall_-_Fragment_(USA),_1890%E2%80%931910_(CH_18475367-2).jpg",
+ "license": "Public domain",
+ "creator": "Made by Janeway & Co. Inc.",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 2467,
+ "bytes": 1545789
+ },
+ {
+ "file": "/img/segments/grasscloth/51-sample-book-usa-ca-1915-ch-18491413-11.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sample Book (USA), ca. 1915 (CH 18491413-11).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sample_Book_(USA),_ca._1915_(CH_18491413-11).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1632,
+ "bytes": 428398
+ },
+ {
+ "file": "/img/segments/grasscloth/52-sample-book-usa-ca-1915-ch-18491413-20.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sample Book (USA), ca. 1915 (CH 18491413-20).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sample_Book_(USA),_ca._1915_(CH_18491413-20).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1632,
+ "bytes": 489243
+ },
+ {
+ "file": "/img/segments/grasscloth/53-sample-book-usa-ca-1915-ch-18491413-40.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Sample Book (USA), ca. 1915 (CH 18491413-40).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Sample_Book_(USA),_ca._1915_(CH_18491413-40).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1608,
+ "bytes": 757132
+ },
+ {
+ "file": "/img/segments/grasscloth/54-behangstalenboek-r-d-grasscloth-serie-3-objectnr-ka-17948-14.jpg",
+ "segment": "grasscloth",
+ "query": "grasscloth wallcovering",
+ "source_title": "File:Behangstalenboek (R&D Grasscloth serie 3), objectnr KA 17948.140.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Behangstalenboek_(R%26D_Grasscloth_serie_3),_objectnr_KA_17948.140.jpg",
+ "license": "CC0",
+ "creator": "Amsterdam Museum",
+ "credit": "https://hdl.handle.net/11259/collection.24198",
+ "width": 1200,
+ "height": 900,
+ "bytes": 109026
+ },
+ {
+ "file": "/img/segments/grasscloth/55-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg",
+ "segment": "grasscloth",
+ "query": "bamboo wall covering interior",
+ "source_title": "File:Drawing, Design for Lobby, Possibly the Ante Room to the Music Room, 1815–22 (CH 18610031).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing,_Design_for_Lobby,_Possibly_the_Ante_Room_to_the_Music_Room,_1815%E2%80%9322_(CH_18610031).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1392,
+ "bytes": 635612
+ },
+ {
+ "file": "/img/segments/grasscloth/56-drawing-design-for-lobby-possibly-the-ante-room-to-the-music.jpg",
+ "segment": "grasscloth",
+ "query": "bamboo wall covering interior",
+ "source_title": "File:Drawing, Design for Lobby, Possibly the Ante Room to the Music Room, 1815–22 (CH 18610031-2).jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing,_Design_for_Lobby,_Possibly_the_Ante_Room_to_the_Music_Room,_1815%E2%80%9322_(CH_18610031-2).jpg",
+ "license": "Public domain",
+ "creator": "Unknown artistUnknown artist",
+ "credit": "Catalog Photo",
+ "width": 1200,
+ "height": 1411,
+ "bytes": 468014
+ },
+ {
+ "file": "/img/segments/mural/57-1851-townshend-and-parker-wallpaper-great-exhibition-london.jpg",
+ "segment": "mural",
+ "query": "mural wallpaper interior",
+ "source_title": "File:1851 Townshend and Parker wallpaper Great Exhibition London.png",
+ "source_url": "https://commons.wikimedia.org/wiki/File:1851_Townshend_and_Parker_wallpaper_Great_Exhibition_London.png",
+ "license": "Public domain",
+ "creator": "Spicer Bros.",
+ "credit": "Official descriptive and illustrated catalogue (Great exhibition of the works of industry of all nations, 1851)[1], London: Spicer Brothers, = 1851, OCLC 1044640",
+ "width": 1200,
+ "height": 1946,
+ "bytes": 506620
+ },
+ {
+ "file": "/img/segments/mural/58-wallpaper-2.jpg",
+ "segment": "mural",
+ "query": "mural wallpaper interior",
+ "source_title": "File:Wallpaper 2.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_2.jpg",
+ "license": "CC0",
+ "creator": "Ambadyanands",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 900,
+ "bytes": 310830
+ },
+ {
+ "file": "/img/segments/mural/59-wallpaper-3.jpg",
+ "segment": "mural",
+ "query": "mural wallpaper interior",
+ "source_title": "File:Wallpaper 3.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper_3.jpg",
+ "license": "CC0",
+ "creator": "Ambadyanands",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 900,
+ "bytes": 306283
+ },
+ {
+ "file": "/img/segments/mural/60-wallpaper-ca-1791-made-by-jacquemart-benard.jpg",
+ "segment": "mural",
+ "query": "mural wallpaper interior",
+ "source_title": "File:Wallpaper, ca. 1791. Made by Jacquemart & Benard.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Wallpaper,_ca._1791._Made_by_Jacquemart_%26_Benard.jpg",
+ "license": "Public domain",
+ "creator": "Jacquemart et Bérnard",
+ "credit": "https://www.cooperhewitt.org/2018/08/22/delicately-defying-gravity/",
+ "width": 1200,
+ "height": 2196,
+ "bytes": 962061
+ },
+ {
+ "file": "/img/segments/mural/61-moolenbergh-thomas-theodory.jpg",
+ "segment": "mural",
+ "query": "scenic wallpaper panorama",
+ "source_title": "File:Moolenbergh Thomas Theodory.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Moolenbergh_Thomas_Theodory.jpg",
+ "license": "Public domain",
+ "creator": "P. Moolenbergh (c. 1773–1807)",
+ "credit": "http://www.groningermuseum.nl/index.php?id=1208",
+ "width": 1200,
+ "height": 1456,
+ "bytes": 113528
+ },
+ {
+ "file": "/img/segments/mural/62-garden-of-armida-wallpaper-1854-by-douard-muller.jpg",
+ "segment": "mural",
+ "query": "scenic wallpaper panorama",
+ "source_title": "File:Garden of Armida Wallpaper (1854) by Édouard Muller.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Garden_of_Armida_Wallpaper_(1854)_by_%C3%89douard_Muller.jpg",
+ "license": "Public domain",
+ "creator": "Designed by Édouard Muller, French, 1823 - 1876 (born on 26 September 1823 in Mulhouse, France; died on 29 December 1876 in Nogent-sur-Marne, France) – Designer (French) Details on Google Art Project",
+ "credit": "SQFL_WcDteAgfA at Google Cultural Institute maximum zoom level",
+ "width": 1200,
+ "height": 1341,
+ "bytes": 547871
+ },
+ {
+ "file": "/img/segments/mural/63-most-beautiful-landscape-wallpaper.jpg",
+ "segment": "mural",
+ "query": "scenic wallpaper panorama",
+ "source_title": "File:Most beautiful landscape wallpaper.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Most_beautiful_landscape_wallpaper.jpg",
+ "license": "Public domain",
+ "creator": "U.S. Fish and Wildlife Service",
+ "credit": "http://www.public-domain-image.com/public-domain-images-pictures-free-stock-photos/wallpapers-public-domain-images-pictures/most-beautiful-landscape-wallpaper.jpg",
+ "width": 1200,
+ "height": 799,
+ "bytes": 391247
+ },
+ {
+ "file": "/img/segments/mural/64-panorama-scenics-photography.jpg",
+ "segment": "mural",
+ "query": "scenic wallpaper panorama",
+ "source_title": "File:Panorama scenics photography.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Panorama_scenics_photography.jpg",
+ "license": "Public domain",
+ "creator": "Poole Matt, U.S. Fish and Wildlife Service",
+ "credit": "http://www.public-domain-image.com/public-domain-images-pictures-free-stock-photos/wallpapers-public-domain-images-pictures/panorama-scenics-photography.jpg",
+ "width": 1200,
+ "height": 479,
+ "bytes": 174770
+ },
+ {
+ "file": "/img/segments/mural/65-efta00000925-sleek-apple-imac-displays-the-macos-desktop-fea.jpg",
+ "segment": "mural",
+ "query": "scenic wallpaper panorama",
+ "source_title": "File:EFTA00000925 - Sleek Apple iMac displays the macOS desktop featuring a mountain landscape wallpaper and open system preferences window on a wooden desk.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:EFTA00000925_-_Sleek_Apple_iMac_displays_the_macOS_desktop_featuring_a_mountain_landscape_wallpaper_and_open_system_preferences_window_on_a_wooden_desk.jpg",
+ "license": "Public domain",
+ "creator": "Federal Bureau of Investigation",
+ "credit": "https://www.justice.gov/epstein/doj-disclosures",
+ "width": 1200,
+ "height": 800,
+ "bytes": 268964
+ },
+ {
+ "file": "/img/segments/mural/66-interior-wall-decoration-museo-diocesano-genoa-dsc01769.jpg",
+ "segment": "mural",
+ "query": "fresco interior decoration",
+ "source_title": "File:Interior wall decoration - Museo Diocesano (Genoa) - DSC01769.JPG",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Interior_wall_decoration_-_Museo_Diocesano_(Genoa)_-_DSC01769.JPG",
+ "license": "CC0",
+ "creator": "Daderot",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 800,
+ "bytes": 351136
+ },
+ {
+ "file": "/img/segments/mural/67-interior-wall-decoration-museo-diocesano-genoa-dsc01747.jpg",
+ "segment": "mural",
+ "query": "fresco interior decoration",
+ "source_title": "File:Interior wall decoration - Museo Diocesano (Genoa) - DSC01747.JPG",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Interior_wall_decoration_-_Museo_Diocesano_(Genoa)_-_DSC01747.JPG",
+ "license": "CC0",
+ "creator": "Daderot",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 800,
+ "bytes": 271341
+ },
+ {
+ "file": "/img/segments/mural/68-interior-wall-decoration-museo-diocesano-genoa-dsc01764.jpg",
+ "segment": "mural",
+ "query": "fresco interior decoration",
+ "source_title": "File:Interior wall decoration - Museo Diocesano (Genoa) - DSC01764.JPG",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Interior_wall_decoration_-_Museo_Diocesano_(Genoa)_-_DSC01764.JPG",
+ "license": "CC0",
+ "creator": "Daderot",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 800,
+ "bytes": 349670
+ },
+ {
+ "file": "/img/segments/generic/69-drawing-of-an-interior-cabinet-du-salon-met-dp809355.jpg",
+ "segment": "generic",
+ "query": "salon interior 19th century",
+ "source_title": "File:Drawing of an Interior- Cabinet du Salon MET DP809355.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing_of_an_Interior-_Cabinet_du_Salon_MET_DP809355.jpg",
+ "license": "CC0",
+ "creator": "AnonymousUnknown author",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 865,
+ "bytes": 372903
+ },
+ {
+ "file": "/img/segments/generic/70-drawing-of-an-interior-cabinet-du-salon-met-1972-642-9.jpg",
+ "segment": "generic",
+ "query": "salon interior 19th century",
+ "source_title": "File:Drawing of an Interior- Cabinet du Salon MET 1972.642.9.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing_of_an_Interior-_Cabinet_du_Salon_MET_1972.642.9.jpg",
+ "license": "CC0",
+ "creator": "AnonymousUnknown author",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 875,
+ "bytes": 243248
+ },
+ {
+ "file": "/img/segments/generic/71-drawing-of-an-interior-salon-met-1972-642-8.jpg",
+ "segment": "generic",
+ "query": "salon interior 19th century",
+ "source_title": "File:Drawing of an Interior- Salon MET 1972.642.8.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing_of_an_Interior-_Salon_MET_1972.642.8.jpg",
+ "license": "CC0",
+ "creator": "AnonymousUnknown author",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 800,
+ "bytes": 756140
+ },
+ {
+ "file": "/img/segments/generic/72-drawing-of-an-interior-salon-met-dp809413.jpg",
+ "segment": "generic",
+ "query": "salon interior 19th century",
+ "source_title": "File:Drawing of an Interior- Salon MET DP809413.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Drawing_of_an_Interior-_Salon_MET_DP809413.jpg",
+ "license": "CC0",
+ "creator": "AnonymousUnknown author",
+ "credit": "This file was donated to Wikimedia Commons as part of a project by the Metropolitan Museum of Art. See the Image and Data Resources Open Access Policy",
+ "width": 1200,
+ "height": 804,
+ "bytes": 300936
+ },
+ {
+ "file": "/img/segments/luxury_residential/73-414-east-waldburg-street-interior-detail-of-arch-in-middle-p.jpg",
+ "segment": "luxury_residential",
+ "query": "Victorian parlor interior",
+ "source_title": "File:414 EAST WALDBURG STREET, INTERIOR, DETAIL OF ARCH IN MIDDLE PARLOR. - Savannah Victorian Historic District, 414-416 East Waldburg Street (House), Savannah, Chatham County, GA HABS GA,26-SAV,530-5.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:414_EAST_WALDBURG_STREET,_INTERIOR,_DETAIL_OF_ARCH_IN_MIDDLE_PARLOR._-_Savannah_Victorian_Historic_District,_414-416_East_Waldburg_Street_(House),_Savannah,_Chatham_County,_GA_HABS_GA,26-SAV,530-5.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/ga0112.photos.055634p",
+ "width": 1200,
+ "height": 855,
+ "bytes": 96115
+ },
+ {
+ "file": "/img/segments/luxury_residential/74-interior-detail-wallpaper-stairhall-first-floor-bowen-house-.jpg",
+ "segment": "luxury_residential",
+ "query": "historic interior wallpaper",
+ "source_title": "File:INTERIOR DETAIL WALLPAPER, STAIRHALL FIRST FLOOR - Bowen House, Woodstock, Windham County, CT HABS CONN,8-WOOD,1-32.tif",
+ "source_url": "https://commons.wikimedia.org/wiki/File:INTERIOR_DETAIL_WALLPAPER,_STAIRHALL_FIRST_FLOOR_-_Bowen_House,_Woodstock,_Windham_County,_CT_HABS_CONN,8-WOOD,1-32.tif",
+ "license": "Public domain",
+ "creator": null,
+ "credit": "https://www.loc.gov/pictures/item/ct0347.photos.025204p",
+ "width": 1200,
+ "height": 1669,
+ "bytes": 951153
+ },
+ {
+ "file": "/img/segments/mural/75-church-of-the-eremitani-padua-interior-fresco-of-saint-phili.jpg",
+ "segment": "mural",
+ "query": "fresco interior decoration",
+ "source_title": "File:Church of the Eremitani (Padua) - Interior - Fresco of saint Philip and saint Augustin.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Church_of_the_Eremitani_(Padua)_-_Interior_-_Fresco_of_saint_Philip_and_saint_Augustin.jpg",
+ "license": "Public domain",
+ "creator": "Didier Descouens",
+ "credit": "Own work",
+ "width": 1200,
+ "height": 1881,
+ "bytes": 1099447
+ },
+ {
+ "file": "/img/segments/generic/76-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 1 - William Morris and Company - page005.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_1_-_William_Morris_and_Company_-_page005.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.1_page005_PS1.jpg",
+ "width": 1200,
+ "height": 902,
+ "bytes": 48020
+ },
+ {
+ "file": "/img/segments/generic/77-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 1 - William Morris and Company - page012.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_1_-_William_Morris_and_Company_-_page012.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.1_page012_PS1.jpg",
+ "width": 1200,
+ "height": 922,
+ "bytes": 79628
+ },
+ {
+ "file": "/img/segments/generic/78-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 1 - William Morris and Company - page032.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_1_-_William_Morris_and_Company_-_page032.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.1_page032_PS1.jpg",
+ "width": 1200,
+ "height": 909,
+ "bytes": 103234
+ },
+ {
+ "file": "/img/segments/generic/79-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 1 - William Morris and Company - page127.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_1_-_William_Morris_and_Company_-_page127.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.1_page127_PS1.jpg",
+ "width": 1200,
+ "height": 1516,
+ "bytes": 147025
+ },
+ {
+ "file": "/img/segments/generic/80-brooklyn-museum-wallpaper-sample-book-1-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 1 - William Morris and Company - page132.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_1_-_William_Morris_and_Company_-_page132.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.1_page132_PS1.jpg",
+ "width": 1200,
+ "height": 1600,
+ "bytes": 113048
+ },
+ {
+ "file": "/img/segments/generic/81-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 2 - William Morris and Company - page004.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_2_-_William_Morris_and_Company_-_page004.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.2_page004_PS1.jpg",
+ "width": 1200,
+ "height": 1646,
+ "bytes": 87085
+ },
+ {
+ "file": "/img/segments/generic/82-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 2 - William Morris and Company - page025.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_2_-_William_Morris_and_Company_-_page025.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.2_page025_PS1.jpg",
+ "width": 1200,
+ "height": 891,
+ "bytes": 122233
+ },
+ {
+ "file": "/img/segments/generic/83-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 2 - William Morris and Company - page029.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_2_-_William_Morris_and_Company_-_page029.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.2_page029_PS2.jpg",
+ "width": 1200,
+ "height": 992,
+ "bytes": 83390
+ },
+ {
+ "file": "/img/segments/generic/84-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 2 - William Morris and Company - page055.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_2_-_William_Morris_and_Company_-_page055.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.2_page055_PS2.jpg",
+ "width": 1200,
+ "height": 1044,
+ "bytes": 187968
+ },
+ {
+ "file": "/img/segments/generic/85-brooklyn-museum-wallpaper-sample-book-2-william-morris-and-c.jpg",
+ "segment": "generic",
+ "query": "wallpaper sample",
+ "source_title": "File:Brooklyn Museum - Wallpaper Sample Book 2 - William Morris and Company - page059.jpg",
+ "source_url": "https://commons.wikimedia.org/wiki/File:Brooklyn_Museum_-_Wallpaper_Sample_Book_2_-_William_Morris_and_Company_-_page059.jpg",
+ "license": "Public domain",
+ "creator": "Morris & Co.",
+ "credit": "Online Collection of Brooklyn Museum; Photo: Brooklyn Museum, 2009, 71.151.2_page059_PS2.jpg",
+ "width": 1200,
+ "height": 931,
+ "bytes": 87695
+ }
+ ]
+}
\ No newline at end of file
diff --git a/public/img/segments/mural/57-1851-townshend-and-parker-wallpaper-great-exhibition-london.jpg b/public/img/segments/mural/57-1851-townshend-and-parker-wallpaper-great-exhibition-london.jpg
new file mode 100644
index 0000000..6d8cd16
Binary files /dev/null and b/public/img/segments/mural/57-1851-townshend-and-parker-wallpaper-great-exhibition-london.jpg differ
diff --git a/public/img/segments/mural/58-wallpaper-2.jpg b/public/img/segments/mural/58-wallpaper-2.jpg
new file mode 100644
index 0000000..259534b
Binary files /dev/null and b/public/img/segments/mural/58-wallpaper-2.jpg differ
diff --git a/public/img/segments/mural/59-wallpaper-3.jpg b/public/img/segments/mural/59-wallpaper-3.jpg
new file mode 100644
index 0000000..3706ff9
Binary files /dev/null and b/public/img/segments/mural/59-wallpaper-3.jpg differ
diff --git a/public/img/segments/mural/60-wallpaper-ca-1791-made-by-jacquemart-benard.jpg b/public/img/segments/mural/60-wallpaper-ca-1791-made-by-jacquemart-benard.jpg
new file mode 100644
index 0000000..13fe12d
Binary files /dev/null and b/public/img/segments/mural/60-wallpaper-ca-1791-made-by-jacquemart-benard.jpg differ
diff --git a/public/img/segments/mural/61-moolenbergh-thomas-theodory.jpg b/public/img/segments/mural/61-moolenbergh-thomas-theodory.jpg
new file mode 100644
index 0000000..0150c0b
Binary files /dev/null and b/public/img/segments/mural/61-moolenbergh-thomas-theodory.jpg differ
diff --git a/public/img/segments/mural/62-garden-of-armida-wallpaper-1854-by-douard-muller.jpg b/public/img/segments/mural/62-garden-of-armida-wallpaper-1854-by-douard-muller.jpg
new file mode 100644
index 0000000..f56d44f
Binary files /dev/null and b/public/img/segments/mural/62-garden-of-armida-wallpaper-1854-by-douard-muller.jpg differ
diff --git a/public/img/segments/mural/63-most-beautiful-landscape-wallpaper.jpg b/public/img/segments/mural/63-most-beautiful-landscape-wallpaper.jpg
new file mode 100644
index 0000000..6814d15
Binary files /dev/null and b/public/img/segments/mural/63-most-beautiful-landscape-wallpaper.jpg differ
diff --git a/public/img/segments/mural/64-panorama-scenics-photography.jpg b/public/img/segments/mural/64-panorama-scenics-photography.jpg
new file mode 100644
index 0000000..2e759d0
Binary files /dev/null and b/public/img/segments/mural/64-panorama-scenics-photography.jpg differ
diff --git a/public/img/segments/mural/65-efta00000925-sleek-apple-imac-displays-the-macos-desktop-fea.jpg b/public/img/segments/mural/65-efta00000925-sleek-apple-imac-displays-the-macos-desktop-fea.jpg
new file mode 100644
index 0000000..6f6ac78
Binary files /dev/null and b/public/img/segments/mural/65-efta00000925-sleek-apple-imac-displays-the-macos-desktop-fea.jpg differ
diff --git a/public/img/segments/mural/66-interior-wall-decoration-museo-diocesano-genoa-dsc01769.jpg b/public/img/segments/mural/66-interior-wall-decoration-museo-diocesano-genoa-dsc01769.jpg
new file mode 100644
index 0000000..5ac4b32
Binary files /dev/null and b/public/img/segments/mural/66-interior-wall-decoration-museo-diocesano-genoa-dsc01769.jpg differ
diff --git a/public/img/segments/mural/67-interior-wall-decoration-museo-diocesano-genoa-dsc01747.jpg b/public/img/segments/mural/67-interior-wall-decoration-museo-diocesano-genoa-dsc01747.jpg
new file mode 100644
index 0000000..7a5a693
Binary files /dev/null and b/public/img/segments/mural/67-interior-wall-decoration-museo-diocesano-genoa-dsc01747.jpg differ
diff --git a/public/img/segments/mural/68-interior-wall-decoration-museo-diocesano-genoa-dsc01764.jpg b/public/img/segments/mural/68-interior-wall-decoration-museo-diocesano-genoa-dsc01764.jpg
new file mode 100644
index 0000000..e6fb588
Binary files /dev/null and b/public/img/segments/mural/68-interior-wall-decoration-museo-diocesano-genoa-dsc01764.jpg differ
diff --git a/public/img/segments/mural/75-church-of-the-eremitani-padua-interior-fresco-of-saint-phili.jpg b/public/img/segments/mural/75-church-of-the-eremitani-padua-interior-fresco-of-saint-phili.jpg
new file mode 100644
index 0000000..ce44e5e
Binary files /dev/null and b/public/img/segments/mural/75-church-of-the-eremitani-padua-interior-fresco-of-saint-phili.jpg differ
diff --git a/public/img/segments/museum/16-household-furniture-and-interior-decoration-met-dp211452.jpg b/public/img/segments/museum/16-household-furniture-and-interior-decoration-met-dp211452.jpg
new file mode 100644
index 0000000..375f108
Binary files /dev/null and b/public/img/segments/museum/16-household-furniture-and-interior-decoration-met-dp211452.jpg differ
diff --git a/public/img/segments/museum/17-household-furniture-and-interior-decoration-met-49pp-511r2.jpg b/public/img/segments/museum/17-household-furniture-and-interior-decoration-met-49pp-511r2.jpg
new file mode 100644
index 0000000..3da7c6b
Binary files /dev/null and b/public/img/segments/museum/17-household-furniture-and-interior-decoration-met-49pp-511r2.jpg differ
diff --git a/public/img/segments/museum/18-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg b/public/img/segments/museum/18-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg
new file mode 100644
index 0000000..0c22d4d
Binary files /dev/null and b/public/img/segments/museum/18-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg differ
diff --git a/public/img/segments/museum/19-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg b/public/img/segments/museum/19-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg
new file mode 100644
index 0000000..37792e2
Binary files /dev/null and b/public/img/segments/museum/19-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg differ
diff --git a/public/img/segments/museum/20-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg b/public/img/segments/museum/20-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg
new file mode 100644
index 0000000..d8af798
Binary files /dev/null and b/public/img/segments/museum/20-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg differ
diff --git a/public/img/segments/museum/21-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg b/public/img/segments/museum/21-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg
new file mode 100644
index 0000000..b135cf9
Binary files /dev/null and b/public/img/segments/museum/21-furniture-with-candelabra-and-interior-decoration-met-mm8990.jpg differ
diff --git a/public/img/segments/museum/22-bowl-with-interior-geometric-decoration-met-cop0206s1.jpg b/public/img/segments/museum/22-bowl-with-interior-geometric-decoration-met-cop0206s1.jpg
new file mode 100644
index 0000000..cfdec54
Binary files /dev/null and b/public/img/segments/museum/22-bowl-with-interior-geometric-decoration-met-cop0206s1.jpg differ
diff --git a/public/img/segments/museum/23-bowl-with-interior-geometric-decoration-met-dp276089.jpg b/public/img/segments/museum/23-bowl-with-interior-geometric-decoration-met-dp276089.jpg
new file mode 100644
index 0000000..4f30b42
Binary files /dev/null and b/public/img/segments/museum/23-bowl-with-interior-geometric-decoration-met-dp276089.jpg differ
diff --git a/public/img/segments/museum/24-bowl-with-interior-geometric-decoration-met-dp276088.jpg b/public/img/segments/museum/24-bowl-with-interior-geometric-decoration-met-dp276088.jpg
new file mode 100644
index 0000000..5650f87
Binary files /dev/null and b/public/img/segments/museum/24-bowl-with-interior-geometric-decoration-met-dp276088.jpg differ
diff --git a/public/img/segments/museum/25-national-museum-of-serbia-staircase-decoration-p1.jpg b/public/img/segments/museum/25-national-museum-of-serbia-staircase-decoration-p1.jpg
new file mode 100644
index 0000000..e681758
Binary files /dev/null and b/public/img/segments/museum/25-national-museum-of-serbia-staircase-decoration-p1.jpg differ
diff --git a/public/img/segments/museum/26-national-museum-of-serbia-staircase-decoration-p2.jpg b/public/img/segments/museum/26-national-museum-of-serbia-staircase-decoration-p2.jpg
new file mode 100644
index 0000000..0c3f110
Binary files /dev/null and b/public/img/segments/museum/26-national-museum-of-serbia-staircase-decoration-p2.jpg differ
diff --git a/public/img/segments/museum/27-dining-room-longfellow-national-historic-site-dsc04698.jpg b/public/img/segments/museum/27-dining-room-longfellow-national-historic-site-dsc04698.jpg
new file mode 100644
index 0000000..cd98011
Binary files /dev/null and b/public/img/segments/museum/27-dining-room-longfellow-national-historic-site-dsc04698.jpg differ
diff --git a/public/img/segments/museum/28-08152-grand-canyon-historic-interior-of-bright-angel-lodge-d.jpg b/public/img/segments/museum/28-08152-grand-canyon-historic-interior-of-bright-angel-lodge-d.jpg
new file mode 100644
index 0000000..201d8f9
Binary files /dev/null and b/public/img/segments/museum/28-08152-grand-canyon-historic-interior-of-bright-angel-lodge-d.jpg differ
diff --git a/public/js/calendar-consumer.js b/public/js/calendar-consumer.js
new file mode 100644
index 0000000..e943cb4
--- /dev/null
+++ b/public/js/calendar-consumer.js
@@ -0,0 +1,211 @@
+// Consumer-side slot picker. Loads /api/installers/:slug/slots and renders
+// clickable buttons grouped by day. On submit, posts the booking.
+(function () {
+ const root = document.querySelector('.book-calendar');
+ const form = document.getElementById('book-form');
+ if (!root || !form) return;
+
+ const slug = root.dataset.installerSlug;
+ const slotsEl = root.querySelector('[data-cal-slots]');
+ const rangeEl = root.querySelector('[data-cal-range]');
+ const selectedEl = form.querySelector('[data-selected-slot]');
+ const submit = form.querySelector('[data-submit]');
+ const startInput = form.querySelector('#scheduled_start');
+ const endInput = form.querySelector('#scheduled_end');
+
+ let windowStart = new Date();
+ windowStart.setHours(0, 0, 0, 0);
+
+ function csrf() {
+ const m = document.querySelector('meta[name="csrf-token"]');
+ return m ? m.getAttribute('content') : '';
+ }
+
+ // ---------- Stripe Elements (deposit card field) ----------
+ // Mounted at page load if NPH_STRIPE_PK is present. We use card-element
+ // (PaymentElement requires server-rendered intent on init; this is simpler
+ // for the booking-then-confirm flow we want).
+ let stripe = null;
+ let cardEl = null;
+ const cardMount = document.getElementById('deposit-card-element');
+ const cardErrors = document.getElementById('deposit-card-errors');
+ if (window.NPH_STRIPE_PK && window.Stripe && cardMount) {
+ try {
+ stripe = window.Stripe(window.NPH_STRIPE_PK);
+ const elements = stripe.elements();
+ cardEl = elements.create('card', {
+ hidePostalCode: false,
+ style: {
+ base: {
+ color: getComputedStyle(document.body).color || '#eee',
+ fontFamily: 'Inter, system-ui, sans-serif',
+ fontSize: '15px',
+ '::placeholder': { color: '#888' }
+ },
+ invalid: { color: '#e2615a' }
+ }
+ });
+ cardEl.mount(cardMount);
+ cardEl.on('change', (e) => {
+ if (cardErrors) cardErrors.textContent = e.error ? e.error.message : '';
+ });
+ } catch (e) {
+ console.warn('[stripe] elements init failed', e.message);
+ }
+ }
+
+ async function load() {
+ slotsEl.innerHTML = '<p class="slot-loading">Loading available slots…</p>';
+ const from = windowStart.toISOString().slice(0, 10);
+ const toDate = new Date(windowStart);
+ toDate.setDate(toDate.getDate() + 14);
+ const to = toDate.toISOString().slice(0, 10);
+ rangeEl.textContent = fmtDate(windowStart) + ' – ' + fmtDate(toDate);
+
+ try {
+ const r = await fetch(`/api/installers/${encodeURIComponent(slug)}/slots?from=${from}&to=${to}`);
+ const j = await r.json();
+ if (!j.calendarEnabled) {
+ slotsEl.innerHTML = '<p class="slot-loading">This studio is not on calendar booking.</p>';
+ return;
+ }
+ render(j.slots || []);
+ } catch (e) {
+ slotsEl.innerHTML = '<p class="slot-loading">Could not load slots. Please try again.</p>';
+ }
+ }
+
+ function render(slots) {
+ if (!slots.length) {
+ slotsEl.innerHTML = '<p class="slot-loading">No openings in this range. Try a later window.</p>';
+ return;
+ }
+ const byDay = {};
+ for (const s of slots) {
+ const d = new Date(s.start);
+ const key = d.toDateString();
+ (byDay[key] = byDay[key] || []).push(s);
+ }
+ const html = Object.keys(byDay).map(day => {
+ const items = byDay[day].map(s => {
+ const t = new Date(s.start);
+ return `<button type="button" class="slot" data-start="${s.start}" data-end="${s.end}">${fmtTime(t)}</button>`;
+ }).join('');
+ return `<div class="day-group">${fmtDayLong(new Date(day))}</div>${items}`;
+ }).join('');
+ slotsEl.innerHTML = html;
+
+ slotsEl.querySelectorAll('.slot').forEach(btn => {
+ btn.addEventListener('click', () => {
+ slotsEl.querySelectorAll('.slot.is-selected').forEach(s => s.classList.remove('is-selected'));
+ btn.classList.add('is-selected');
+ startInput.value = btn.dataset.start;
+ endInput.value = btn.dataset.end;
+ const t = new Date(btn.dataset.start);
+ selectedEl.textContent = fmtDayLong(t) + ' at ' + fmtTime(t);
+ submit.disabled = false;
+
+ // Progressive disclosure — only reveal project + address fields once
+ // a slot is locked in. Reduces perceived form length by ~60%.
+ const step2 = form.querySelector('[data-step-2]');
+ if (step2) step2.hidden = false;
+ });
+ });
+ }
+
+ root.querySelector('[data-cal-prev]').addEventListener('click', () => {
+ const prev = new Date(windowStart);
+ prev.setDate(prev.getDate() - 14);
+ if (prev >= new Date(new Date().setHours(0, 0, 0, 0))) {
+ windowStart = prev;
+ load();
+ }
+ });
+ root.querySelector('[data-cal-next]').addEventListener('click', () => {
+ windowStart = new Date(windowStart);
+ windowStart.setDate(windowStart.getDate() + 14);
+ load();
+ });
+
+ const submitOriginalText = submit.textContent.trim();
+ function setError(msg) {
+ if (cardErrors) cardErrors.textContent = msg || '';
+ if (msg) submit.textContent = submitOriginalText;
+ submit.disabled = !!msg;
+ }
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ if (!startInput.value) return;
+ setError('');
+ submit.disabled = true;
+ submit.textContent = 'Reserving…';
+
+ const data = Object.fromEntries(new FormData(form).entries());
+
+ // Step 1: create the booking + payment intent.
+ let j;
+ try {
+ const r = await fetch(`/api/installers/${encodeURIComponent(slug)}/book`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf() },
+ body: JSON.stringify(data)
+ });
+ j = await r.json();
+ if (!r.ok) {
+ alert(j.error || 'Could not book. Please try a different slot.');
+ submit.disabled = false;
+ submit.textContent = submitOriginalText;
+ return;
+ }
+ } catch (err) {
+ alert('Network error. Please retry.');
+ submit.disabled = false;
+ submit.textContent = submitOriginalText;
+ return;
+ }
+
+ const deposit = j.deposit || null;
+ const qs = j.t ? `?t=${encodeURIComponent(j.t)}` : '';
+ const successUrl = `/bookings/${j.uuid}${qs}`;
+
+ // Step 2a: mocked deposit (no Stripe key on server) — skip card, redirect.
+ if (!deposit || deposit.mocked || !stripe || !cardEl || !deposit.client_secret) {
+ window.location = successUrl + (qs ? '&' : '?') + 'deposit=' + encodeURIComponent(deposit ? deposit.mode || 'mock' : 'none');
+ return;
+ }
+
+ // Step 2b: confirm card payment with the returned client_secret.
+ submit.textContent = 'Charging deposit…';
+ try {
+ const result = await stripe.confirmCardPayment(deposit.client_secret, {
+ payment_method: {
+ card: cardEl,
+ billing_details: {
+ name: data.customer_name || '',
+ email: data.customer_email || '',
+ phone: data.customer_phone || ''
+ }
+ }
+ });
+ if (result.error) {
+ setError(result.error.message || 'Card declined.');
+ submit.disabled = false;
+ submit.textContent = submitOriginalText;
+ return;
+ }
+ // Webhook flips deposit_status to 'paid'; redirect immediately.
+ window.location = successUrl;
+ } catch (err) {
+ setError('Payment failed. Please try a different card.');
+ submit.disabled = false;
+ submit.textContent = submitOriginalText;
+ }
+ });
+
+ function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
+ function fmtDayLong(d) { return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); }
+ function fmtTime(d) { return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); }
+
+ load();
+})();
diff --git a/public/js/calendar-installer.js b/public/js/calendar-installer.js
new file mode 100644
index 0000000..69d5032
--- /dev/null
+++ b/public/js/calendar-installer.js
@@ -0,0 +1,109 @@
+// Installer admin calendar JS — manage availability, time-off, view upcoming.
+(function () {
+ function csrf() {
+ const m = document.querySelector('meta[name="csrf-token"]');
+ return m ? m.getAttribute('content') : '';
+ }
+
+ // ---- Availability
+ const tbody = document.getElementById('availability-tbody');
+ const addAvail = document.getElementById('add-availability');
+
+ if (addAvail) {
+ addAvail.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const data = Object.fromEntries(new FormData(addAvail).entries());
+ const r = await fetch('/api/admin/availability', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf() },
+ body: JSON.stringify(data)
+ });
+ if (!r.ok) { alert('Could not add window'); return; }
+ const row = await r.json();
+ const tr = document.createElement('tr');
+ tr.dataset.id = row.id;
+ tr.innerHTML = `<td>${['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][row.day_of_week]}</td><td>${row.start_time}</td><td>${row.end_time}</td><td><button type="button" class="btn btn-ghost btn-sm" data-remove-availability="${row.id}">Remove</button></td>`;
+ tbody.appendChild(tr);
+ addAvail.reset();
+ });
+ }
+
+ document.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-remove-availability]');
+ if (!btn) return;
+ const id = btn.dataset.removeAvailability;
+ if (!confirm('Remove this availability window?')) return;
+ const r = await fetch(`/api/admin/availability/${id}`, {
+ method: 'DELETE',
+ headers: { 'x-csrf-token': csrf() }
+ });
+ if (r.ok) btn.closest('tr').remove();
+ });
+
+ // ---- Time-off
+ const offList = document.getElementById('time-off-list');
+ const addOff = document.getElementById('add-time-off');
+
+ async function loadTimeOff() {
+ if (!offList) return;
+ offList.innerHTML = '';
+ const r = await fetch('/api/admin/time-off');
+ const j = await r.json();
+ if (!j.timeOff || !j.timeOff.length) {
+ offList.innerHTML = '<li class="muted">No upcoming blocks.</li>';
+ return;
+ }
+ for (const t of j.timeOff) {
+ const li = document.createElement('li');
+ li.innerHTML = `<span>${new Date(t.start_at).toLocaleString()} → ${new Date(t.end_at).toLocaleString()}</span>${t.reason ? '<span class="muted"> · ' + escapeHtml(t.reason) + '</span>' : ''}<button type="button" class="btn btn-ghost btn-sm" data-remove-off="${t.id}" style="margin-left:auto">Remove</button>`;
+ offList.appendChild(li);
+ }
+ }
+
+ if (addOff) {
+ addOff.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const data = Object.fromEntries(new FormData(addOff).entries());
+ data.start_at = new Date(data.start_at).toISOString();
+ data.end_at = new Date(data.end_at).toISOString();
+ const r = await fetch('/api/admin/time-off', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf() },
+ body: JSON.stringify(data)
+ });
+ if (!r.ok) { alert('Could not add block'); return; }
+ addOff.reset();
+ loadTimeOff();
+ });
+ }
+
+ document.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-remove-off]');
+ if (!btn) return;
+ if (!confirm('Remove this block?')) return;
+ const r = await fetch(`/api/admin/time-off/${btn.dataset.removeOff}`, {
+ method: 'DELETE',
+ headers: { 'x-csrf-token': csrf() }
+ });
+ if (r.ok) loadTimeOff();
+ });
+
+ // ---- Upcoming bookings
+ async function loadUpcoming() {
+ const el = document.getElementById('upcoming-cal-list');
+ if (!el) return;
+ const r = await fetch('/api/admin/bookings.json');
+ const j = await r.json();
+ if (!j.bookings || !j.bookings.length) { el.textContent = 'No upcoming bookings.'; return; }
+ el.innerHTML = '<ul class="time-off-list">' + j.bookings.map(b =>
+ `<li><span><strong>${escapeHtml(b.customer_name)}</strong> · ${new Date(b.scheduled_start).toLocaleString()} · ${escapeHtml((b.project_type || 'consultation').replace(/_/g, ' '))}</span><span class="status-badge status-${b.status}" style="margin-left:auto">${b.status}</span></li>`
+ ).join('') + '</ul>';
+ }
+
+ function escapeHtml(s) {
+ return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+ }
+
+ loadTimeOff();
+ loadUpcoming();
+})();
diff --git a/public/js/find-filter.js b/public/js/find-filter.js
new file mode 100644
index 0000000..fa95766
--- /dev/null
+++ b/public/js/find-filter.js
@@ -0,0 +1,113 @@
+/**
+ * find-filter.js — debounced fetch-on-change for /find filters
+ * Intercepts form changes + submission, fetches rendered HTML from the server,
+ * replaces .installer-grid and .result-count with updated content via DOMParser.
+ * Uses history.pushState so the URL stays bookmarkable.
+ */
+(function () {
+ 'use strict';
+
+ var form = document.querySelector('.find-filters');
+ if (!form) return;
+
+ var grid = document.querySelector('.installer-grid');
+ var resultCount = document.querySelector('.result-count');
+ var debounceTimer = null;
+ var currentController = null;
+
+ function buildUrl() {
+ var params = new URLSearchParams(new FormData(form));
+ // remove blank values to keep URLs clean
+ for (var key of Array.from(params.keys())) {
+ if (!params.get(key)) params.delete(key);
+ }
+ return '/find?' + params.toString();
+ }
+
+ function showLoading() {
+ if (grid) {
+ grid.style.opacity = '0.45';
+ grid.style.pointerEvents = 'none';
+ }
+ if (resultCount) {
+ resultCount.dataset.original = resultCount.textContent;
+ resultCount.textContent = 'Loading…';
+ }
+ }
+
+ function hideLoading() {
+ if (grid) {
+ grid.style.opacity = '';
+ grid.style.pointerEvents = '';
+ }
+ }
+
+ function doFetch() {
+ var url = buildUrl();
+
+ // abort any in-flight request
+ if (currentController) currentController.abort();
+ currentController = new AbortController();
+
+ showLoading();
+ history.pushState(null, '', url);
+
+ fetch(url, {
+ headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
+ signal: currentController.signal
+ })
+ .then(function (res) {
+ if (!res.ok) throw new Error('Bad response: ' + res.status);
+ return res.text();
+ })
+ .then(function (html) {
+ var parser = new DOMParser();
+ var doc = parser.parseFromString(html, 'text/html');
+
+ var newGrid = doc.querySelector('.installer-grid');
+ var newCount = doc.querySelector('.result-count');
+
+ if (newGrid && grid) {
+ grid.innerHTML = newGrid.innerHTML;
+ }
+ if (newCount && resultCount) {
+ resultCount.textContent = newCount.textContent;
+ }
+ hideLoading();
+ })
+ .catch(function (err) {
+ if (err.name === 'AbortError') return; // superseded by newer request
+ hideLoading();
+ if (resultCount && resultCount.dataset.original) {
+ resultCount.textContent = resultCount.dataset.original;
+ }
+ console.warn('[find-filter] fetch error:', err);
+ });
+ }
+
+ function scheduleSearch() {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(doFetch, 300);
+ }
+
+ // Intercept native submit
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ clearTimeout(debounceTimer);
+ doFetch();
+ });
+
+ // Debounce on any input or select change
+ form.addEventListener('input', scheduleSearch);
+ form.addEventListener('change', scheduleSearch);
+
+ // Handle browser back/forward
+ window.addEventListener('popstate', function () {
+ // Re-sync form from URL then fetch
+ var params = new URLSearchParams(window.location.search);
+ form.querySelectorAll('input, select').forEach(function (el) {
+ if (el.name) el.value = params.get(el.name) || '';
+ });
+ doFetch();
+ });
+})();
diff --git a/public/js/portfolio-tabs.js b/public/js/portfolio-tabs.js
new file mode 100644
index 0000000..da90844
--- /dev/null
+++ b/public/js/portfolio-tabs.js
@@ -0,0 +1,15 @@
+// "In the Seams" portfolio tabs — UX idea #1.
+// Tiny vanilla-JS swap: click a tab, fade in the matching shot.
+(function () {
+ document.addEventListener('click', (e) => {
+ const btn = e.target.closest('.portfolio-tab[data-shot-target]');
+ if (!btn) return;
+ const card = btn.closest('.portfolio-card-tabbed');
+ if (!card) return;
+ const target = btn.dataset.shotTarget;
+ card.querySelectorAll('.portfolio-tab').forEach(t => t.classList.toggle('is-active', t === btn));
+ card.querySelectorAll('.portfolio-shot').forEach(img => {
+ img.classList.toggle('is-active', img.dataset.shot === target);
+ });
+ });
+})();
diff --git a/public/js/tag-input.js b/public/js/tag-input.js
new file mode 100644
index 0000000..7182f05
--- /dev/null
+++ b/public/js/tag-input.js
@@ -0,0 +1,199 @@
+/**
+ * tag-input.js — pill/chip tag input component (vanilla JS, no framework)
+ *
+ * Usage: add data-tag-input="fieldName" to a wrapper element.
+ * The script finds the existing hidden <input name="fieldName"> (which stores
+ * the comma-joined value the POST handler already reads), renders a pill UI
+ * on top, and keeps the hidden input in sync.
+ *
+ * Allowed values: pass JSON array via data-allowed="[...]" (optional).
+ * If omitted, free-text tags are accepted (brands_handled, accreditations).
+ */
+(function () {
+ 'use strict';
+
+ function initTagInput(wrapper) {
+ var fieldName = wrapper.dataset.tagInput;
+ var hidden = wrapper.querySelector('input[type="hidden"][name="' + fieldName + '"]');
+ if (!hidden) return;
+
+ var allowedRaw = wrapper.dataset.allowed;
+ var allowed = allowedRaw ? JSON.parse(allowedRaw) : null;
+
+ // Read current comma-split values from the hidden input
+ var tags = hidden.value
+ ? hidden.value.split(',').map(function (v) { return v.trim(); }).filter(Boolean)
+ : [];
+
+ // Build the pill container + text input
+ var pillBox = document.createElement('div');
+ pillBox.className = 'tag-pill-box';
+ pillBox.setAttribute('role', 'group');
+ pillBox.setAttribute('aria-label', fieldName.replace(/_/g, ' '));
+
+ var textInput = document.createElement('input');
+ textInput.type = 'text';
+ textInput.className = 'tag-text-input';
+ textInput.placeholder = allowed
+ ? 'Type to choose or press Enter'
+ : 'Type and press Enter to add';
+ textInput.setAttribute('autocomplete', 'off');
+ textInput.setAttribute('aria-label', 'Add ' + fieldName.replace(/_/g, ' '));
+
+ // Dropdown for enumerated values
+ var dropdown = null;
+ if (allowed) {
+ dropdown = document.createElement('ul');
+ dropdown.className = 'tag-dropdown';
+ dropdown.setAttribute('role', 'listbox');
+ dropdown.hidden = true;
+ pillBox.appendChild(dropdown);
+ }
+
+ pillBox.appendChild(textInput);
+ wrapper.appendChild(pillBox);
+
+ function sync() {
+ hidden.value = tags.join(', ');
+ }
+
+ function renderPills() {
+ // Remove existing pill elements (not the textInput or dropdown)
+ Array.from(pillBox.querySelectorAll('.tag-pill')).forEach(function (el) {
+ el.remove();
+ });
+ tags.forEach(function (tag, idx) {
+ var pill = document.createElement('span');
+ pill.className = 'tag-pill';
+ pill.textContent = tag.replace(/_/g, ' ');
+ pill.setAttribute('role', 'button');
+ pill.setAttribute('tabindex', '0');
+ pill.setAttribute('aria-label', 'Remove ' + tag.replace(/_/g, ' '));
+
+ var removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.className = 'tag-pill-remove';
+ removeBtn.innerHTML = '×';
+ removeBtn.setAttribute('aria-label', 'Remove ' + tag.replace(/_/g, ' '));
+ removeBtn.addEventListener('click', function () {
+ tags.splice(idx, 1);
+ renderPills();
+ sync();
+ });
+ pill.appendChild(removeBtn);
+
+ // Support keyboard removal via Enter/Space on the pill itself
+ pill.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ tags.splice(idx, 1);
+ renderPills();
+ sync();
+ textInput.focus();
+ }
+ });
+
+ // Insert before the textInput
+ pillBox.insertBefore(pill, textInput);
+ });
+ }
+
+ function addTag(raw) {
+ var val = raw.trim().toLowerCase().replace(/\s+/g, '_');
+ if (!val) return;
+ if (allowed && !allowed.includes(val)) return; // reject invalid enum
+ if (tags.includes(val)) {
+ textInput.value = '';
+ return; // dedupe
+ }
+ tags.push(val);
+ renderPills();
+ sync();
+ textInput.value = '';
+ if (dropdown) closeDropdown();
+ }
+
+ function openDropdown(filter) {
+ if (!dropdown) return;
+ var fl = filter.toLowerCase();
+ var matches = allowed.filter(function (v) {
+ return !tags.includes(v) && v.replace(/_/g, ' ').includes(fl);
+ });
+ dropdown.innerHTML = '';
+ if (!matches.length) { dropdown.hidden = true; return; }
+ matches.forEach(function (v) {
+ var li = document.createElement('li');
+ li.className = 'tag-dropdown-item';
+ li.setAttribute('role', 'option');
+ li.textContent = v.replace(/_/g, ' ');
+ li.addEventListener('mousedown', function (e) {
+ e.preventDefault(); // prevent blur before click
+ addTag(v);
+ });
+ dropdown.appendChild(li);
+ });
+ dropdown.hidden = false;
+ }
+
+ function closeDropdown() {
+ if (dropdown) dropdown.hidden = true;
+ }
+
+ textInput.addEventListener('input', function () {
+ if (allowed) openDropdown(textInput.value);
+ });
+
+ textInput.addEventListener('focus', function () {
+ if (allowed) openDropdown(textInput.value);
+ });
+
+ textInput.addEventListener('blur', function () {
+ setTimeout(closeDropdown, 150); // allow mousedown on item to fire first
+ });
+
+ textInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ addTag(textInput.value);
+ }
+ // Backspace on empty input removes last tag
+ if (e.key === 'Backspace' && !textInput.value && tags.length) {
+ tags.pop();
+ renderPills();
+ sync();
+ }
+ // Arrow keys navigate dropdown
+ if (dropdown && !dropdown.hidden) {
+ var items = Array.from(dropdown.querySelectorAll('.tag-dropdown-item'));
+ var focused = dropdown.querySelector('.tag-dropdown-item.is-active');
+ var idx = items.indexOf(focused);
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (focused) focused.classList.remove('is-active');
+ var next = items[(idx + 1) % items.length];
+ if (next) { next.classList.add('is-active'); next.scrollIntoView({ block: 'nearest' }); }
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (focused) focused.classList.remove('is-active');
+ var prev = items[(idx - 1 + items.length) % items.length];
+ if (prev) { prev.classList.add('is-active'); prev.scrollIntoView({ block: 'nearest' }); }
+ }
+ if (e.key === 'Enter' && focused) {
+ e.preventDefault();
+ addTag(focused.textContent);
+ }
+ if (e.key === 'Escape') closeDropdown();
+ }
+ });
+
+ // Click on the pill box focuses text input (UX feel)
+ pillBox.addEventListener('click', function (e) {
+ if (e.target === pillBox) textInput.focus();
+ });
+
+ renderPills();
+ }
+
+ document.querySelectorAll('[data-tag-input]').forEach(initTagInput);
+})();
diff --git a/public/js/theme-toggle.js b/public/js/theme-toggle.js
new file mode 100644
index 0000000..04b5387
--- /dev/null
+++ b/public/js/theme-toggle.js
@@ -0,0 +1,10 @@
+(function () {
+ const root = document.documentElement;
+ document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
+ root.setAttribute('data-theme', next);
+ try { localStorage.setItem('nph-theme', next); } catch (e) {}
+ });
+ });
+})();
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..58a4334
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,9 @@
+User-agent: *
+Allow: /
+Disallow: /admin
+Disallow: /admin/
+Disallow: /bookings/
+Disallow: /api/
+Disallow: /webhooks/
+
+Sitemap: https://www.nationalpaperhangers.com/sitemap.xml
diff --git a/public/vendor/leaflet/images/layers-2x.png b/public/vendor/leaflet/images/layers-2x.png
new file mode 100644
index 0000000..200c333
Binary files /dev/null and b/public/vendor/leaflet/images/layers-2x.png differ
diff --git a/public/vendor/leaflet/images/layers.png b/public/vendor/leaflet/images/layers.png
new file mode 100644
index 0000000..1a72e57
Binary files /dev/null and b/public/vendor/leaflet/images/layers.png differ
diff --git a/public/vendor/leaflet/images/marker-icon-2x.png b/public/vendor/leaflet/images/marker-icon-2x.png
new file mode 100644
index 0000000..88f9e50
Binary files /dev/null and b/public/vendor/leaflet/images/marker-icon-2x.png differ
diff --git a/public/vendor/leaflet/images/marker-icon.png b/public/vendor/leaflet/images/marker-icon.png
new file mode 100644
index 0000000..950edf2
Binary files /dev/null and b/public/vendor/leaflet/images/marker-icon.png differ
diff --git a/public/vendor/leaflet/images/marker-shadow.png b/public/vendor/leaflet/images/marker-shadow.png
new file mode 100644
index 0000000..9fd2979
Binary files /dev/null and b/public/vendor/leaflet/images/marker-shadow.png differ
diff --git a/public/vendor/leaflet/leaflet.css b/public/vendor/leaflet/leaflet.css
new file mode 100644
index 0000000..2961b76
--- /dev/null
+++ b/public/vendor/leaflet/leaflet.css
@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+.leaflet-container {
+ overflow: hidden;
+ }
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+ background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+ image-rendering: -webkit-optimize-contrast;
+ }
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+ width: 1600px;
+ height: 1600px;
+ -webkit-transform-origin: 0 0;
+ }
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ display: block;
+ }
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+ max-width: none !important;
+ max-height: none !important;
+ }
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+ max-width: none !important;
+ max-height: none !important;
+ width: auto;
+ padding: 0;
+ }
+
+.leaflet-container img.leaflet-tile {
+ /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+ mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+ -ms-touch-action: pan-x pan-y;
+ touch-action: pan-x pan-y;
+ }
+.leaflet-container.leaflet-touch-drag {
+ -ms-touch-action: pinch-zoom;
+ /* Fallback for FF which doesn't support pinch-zoom */
+ touch-action: none;
+ touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+ -ms-touch-action: none;
+ touch-action: none;
+}
+.leaflet-container {
+ -webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+ -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+ filter: inherit;
+ visibility: hidden;
+ }
+.leaflet-tile-loaded {
+ visibility: inherit;
+ }
+.leaflet-zoom-box {
+ width: 0;
+ height: 0;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ z-index: 800;
+ }
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+ -moz-user-select: none;
+ }
+
+.leaflet-pane { z-index: 400; }
+
+.leaflet-tile-pane { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane { z-index: 500; }
+.leaflet-marker-pane { z-index: 600; }
+.leaflet-tooltip-pane { z-index: 650; }
+.leaflet-popup-pane { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg { z-index: 200; }
+
+.leaflet-vml-shape {
+ width: 1px;
+ height: 1px;
+ }
+.lvml {
+ behavior: url(#default#VML);
+ display: inline-block;
+ position: absolute;
+ }
+
+
+/* control positioning */
+
+.leaflet-control {
+ position: relative;
+ z-index: 800;
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+.leaflet-top,
+.leaflet-bottom {
+ position: absolute;
+ z-index: 1000;
+ pointer-events: none;
+ }
+.leaflet-top {
+ top: 0;
+ }
+.leaflet-right {
+ right: 0;
+ }
+.leaflet-bottom {
+ bottom: 0;
+ }
+.leaflet-left {
+ left: 0;
+ }
+.leaflet-control {
+ float: left;
+ clear: both;
+ }
+.leaflet-right .leaflet-control {
+ float: right;
+ }
+.leaflet-top .leaflet-control {
+ margin-top: 10px;
+ }
+.leaflet-bottom .leaflet-control {
+ margin-bottom: 10px;
+ }
+.leaflet-left .leaflet-control {
+ margin-left: 10px;
+ }
+.leaflet-right .leaflet-control {
+ margin-right: 10px;
+ }
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ -moz-transition: opacity 0.2s linear;
+ transition: opacity 0.2s linear;
+ }
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+ opacity: 1;
+ }
+.leaflet-zoom-animated {
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ }
+svg.leaflet-zoom-animated {
+ will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+ -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+ -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+ transition: transform 0.25s cubic-bezier(0,0,0.25,1);
+ }
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none;
+ }
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+ visibility: hidden;
+ }
+
+
+/* cursors */
+
+.leaflet-interactive {
+ cursor: pointer;
+ }
+.leaflet-grab {
+ cursor: -webkit-grab;
+ cursor: -moz-grab;
+ cursor: grab;
+ }
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+ cursor: crosshair;
+ }
+.leaflet-popup-pane,
+.leaflet-control {
+ cursor: auto;
+ }
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+ cursor: move;
+ cursor: -webkit-grabbing;
+ cursor: -moz-grabbing;
+ cursor: grabbing;
+ }
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+ pointer-events: none;
+ }
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+
+/* visual tweaks */
+
+.leaflet-container {
+ background: #ddd;
+ outline-offset: 1px;
+ }
+.leaflet-container a {
+ color: #0078A8;
+ }
+.leaflet-zoom-box {
+ border: 2px dotted #38f;
+ background: rgba(255,255,255,0.5);
+ }
+
+
+/* general typography */
+.leaflet-container {
+ font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+ font-size: 12px;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ }
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+ border-radius: 4px;
+ }
+.leaflet-bar a {
+ background-color: #fff;
+ border-bottom: 1px solid #ccc;
+ width: 26px;
+ height: 26px;
+ line-height: 26px;
+ display: block;
+ text-align: center;
+ text-decoration: none;
+ color: black;
+ }
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ display: block;
+ }
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+ background-color: #f4f4f4;
+ }
+.leaflet-bar a:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+.leaflet-bar a:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom: none;
+ }
+.leaflet-bar a.leaflet-disabled {
+ cursor: default;
+ background-color: #f4f4f4;
+ color: #bbb;
+ }
+
+.leaflet-touch .leaflet-bar a {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ }
+.leaflet-touch .leaflet-bar a:first-child {
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ }
+.leaflet-touch .leaflet-bar a:last-child {
+ border-bottom-left-radius: 2px;
+ border-bottom-right-radius: 2px;
+ }
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+ font: bold 18px 'Lucida Console', Monaco, monospace;
+ text-indent: 1px;
+ }
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
+ font-size: 22px;
+ }
+
+
+/* layers control */
+
+.leaflet-control-layers {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+ background: #fff;
+ border-radius: 5px;
+ }
+.leaflet-control-layers-toggle {
+ background-image: url(images/layers.png);
+ width: 36px;
+ height: 36px;
+ }
+.leaflet-retina .leaflet-control-layers-toggle {
+ background-image: url(images/layers-2x.png);
+ background-size: 26px 26px;
+ }
+.leaflet-touch .leaflet-control-layers-toggle {
+ width: 44px;
+ height: 44px;
+ }
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+ display: none;
+ }
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+ display: block;
+ position: relative;
+ }
+.leaflet-control-layers-expanded {
+ padding: 6px 10px 6px 6px;
+ color: #333;
+ background: #fff;
+ }
+.leaflet-control-layers-scrollbar {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ padding-right: 5px;
+ }
+.leaflet-control-layers-selector {
+ margin-top: 2px;
+ position: relative;
+ top: 1px;
+ }
+.leaflet-control-layers label {
+ display: block;
+ font-size: 13px;
+ font-size: 1.08333em;
+ }
+.leaflet-control-layers-separator {
+ height: 0;
+ border-top: 1px solid #ddd;
+ margin: 5px -10px 5px -6px;
+ }
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+ background-image: url(images/marker-icon.png);
+ }
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.8);
+ margin: 0;
+ }
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+ padding: 0 5px;
+ color: #333;
+ line-height: 1.4;
+ }
+.leaflet-control-attribution a {
+ text-decoration: none;
+ }
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+ text-decoration: underline;
+ }
+.leaflet-attribution-flag {
+ display: inline !important;
+ vertical-align: baseline !important;
+ width: 1em;
+ height: 0.6669em;
+ }
+.leaflet-left .leaflet-control-scale {
+ margin-left: 5px;
+ }
+.leaflet-bottom .leaflet-control-scale {
+ margin-bottom: 5px;
+ }
+.leaflet-control-scale-line {
+ border: 2px solid #777;
+ border-top: none;
+ line-height: 1.1;
+ padding: 2px 5px 1px;
+ white-space: nowrap;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.8);
+ text-shadow: 1px 1px #fff;
+ }
+.leaflet-control-scale-line:not(:first-child) {
+ border-top: 2px solid #777;
+ border-bottom: none;
+ margin-top: -2px;
+ }
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+ border-bottom: 2px solid #777;
+ }
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ box-shadow: none;
+ }
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ border: 2px solid rgba(0,0,0,0.2);
+ background-clip: padding-box;
+ }
+
+
+/* popup */
+
+.leaflet-popup {
+ position: absolute;
+ text-align: center;
+ margin-bottom: 20px;
+ }
+.leaflet-popup-content-wrapper {
+ padding: 1px;
+ text-align: left;
+ border-radius: 12px;
+ }
+.leaflet-popup-content {
+ margin: 13px 24px 13px 20px;
+ line-height: 1.3;
+ font-size: 13px;
+ font-size: 1.08333em;
+ min-height: 1px;
+ }
+.leaflet-popup-content p {
+ margin: 17px 0;
+ margin: 1.3em 0;
+ }
+.leaflet-popup-tip-container {
+ width: 40px;
+ height: 20px;
+ position: absolute;
+ left: 50%;
+ margin-top: -1px;
+ margin-left: -20px;
+ overflow: hidden;
+ pointer-events: none;
+ }
+.leaflet-popup-tip {
+ width: 17px;
+ height: 17px;
+ padding: 1px;
+
+ margin: -10px auto 0;
+ pointer-events: auto;
+
+ -webkit-transform: rotate(45deg);
+ -moz-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+ }
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+ background: white;
+ color: #333;
+ box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+ }
+.leaflet-container a.leaflet-popup-close-button {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: none;
+ text-align: center;
+ width: 24px;
+ height: 24px;
+ font: 16px/24px Tahoma, Verdana, sans-serif;
+ color: #757575;
+ text-decoration: none;
+ background: transparent;
+ }
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+ color: #585858;
+ }
+.leaflet-popup-scrolled {
+ overflow: auto;
+ }
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+ -ms-zoom: 1;
+ }
+.leaflet-oldie .leaflet-popup-tip {
+ width: 24px;
+ margin: 0 auto;
+
+ -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+ filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+ }
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+ border: 1px solid #999;
+ }
+
+
+/* div icon */
+
+.leaflet-div-icon {
+ background: #fff;
+ border: 1px solid #666;
+ }
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+ position: absolute;
+ padding: 6px;
+ background-color: #fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ color: #222;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ pointer-events: none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+ }
+.leaflet-tooltip.leaflet-interactive {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ position: absolute;
+ pointer-events: none;
+ border: 6px solid transparent;
+ background: transparent;
+ content: "";
+ }
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+ margin-top: 6px;
+}
+.leaflet-tooltip-top {
+ margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+ left: 50%;
+ margin-left: -6px;
+ }
+.leaflet-tooltip-top:before {
+ bottom: 0;
+ margin-bottom: -12px;
+ border-top-color: #fff;
+ }
+.leaflet-tooltip-bottom:before {
+ top: 0;
+ margin-top: -12px;
+ margin-left: -6px;
+ border-bottom-color: #fff;
+ }
+.leaflet-tooltip-left {
+ margin-left: -6px;
+}
+.leaflet-tooltip-right {
+ margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ top: 50%;
+ margin-top: -6px;
+ }
+.leaflet-tooltip-left:before {
+ right: 0;
+ margin-right: -12px;
+ border-left-color: #fff;
+ }
+.leaflet-tooltip-right:before {
+ left: 0;
+ margin-left: -12px;
+ border-right-color: #fff;
+ }
+
+/* Printing */
+
+@media print {
+ /* Prevent printers from removing background-images of controls. */
+ .leaflet-control {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+ }
diff --git a/public/vendor/leaflet/leaflet.js b/public/vendor/leaflet/leaflet.js
new file mode 100644
index 0000000..a3bf693
--- /dev/null
+++ b/public/vendor/leaflet/leaflet.js
@@ -0,0 +1,6 @@
+/* @preserve
+ * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
+ * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
+ */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(t,e,i))&&(i=n[e],this._firingCount&&(i.fn=u,this._events[t]=n=n.slice()),n.splice(e,1))},fire:function(t,e,i){if(this.listens(t,i)){var n=l({},e,{type:t,target:this,sourceTarget:e&&e.sourceTarget||this});if(this._events){var o=this._events[t];if(o){this._firingCount=this._firingCount+1||1;for(var s=0,r=o.length;s<r;s++){var a=o[s],h=a.fn;a.once&&this.off(t,h,a.ctx),h.call(a.ctx||this,n)}this._firingCount--}}i&&this._propagateEvent(n)}return this},listens:function(t,e,i,n){"string"!=typeof t&&console.warn('"string" type argument expected');var o=e,s=("function"!=typeof e&&(n=!!e,i=o=void 0),this._events&&this._events[t]);if(s&&s.length&&!1!==this._listens(t,o,i))return!0;if(n)for(var r in this._eventParents)if(this._eventParents[r].listens(t,e,i,n))return!0;return!1},_listens:function(t,e,i){if(this._events){var n=this._events[t]||[];if(!e)return!!n.length;i===this&&(i=void 0);for(var o=0,s=n.length;o<s;o++)if(n[o].fn===e&&n[o].ctx===i)return o}return!1},once:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e,!0);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i,!0);return this},addEventParent:function(t){return this._eventParents=this._eventParents||{},this._eventParents[h(t)]=t,this},removeEventParent:function(t){return this._eventParents&&delete this._eventParents[h(t)],this},_propagateEvent:function(t){for(var e in this._eventParents)this._eventParents[e].fire(t.type,l({layer:t.target,propagatedFrom:t.target},t),!0)}},it=(e.addEventListener=e.on,e.removeEventListener=e.clearAllEventListeners=e.off,e.addOneTimeEventListener=e.once,e.fireEvent=e.fire,e.hasEventListeners=e.listens,et.extend(e));function p(t,e,i){this.x=i?Math.round(t):t,this.y=i?Math.round(e):e}var nt=Math.trunc||function(t){return 0<t?Math.floor(t):Math.ceil(t)};function m(t,e,i){return t instanceof p?t:d(t)?new p(t[0],t[1]):null==t?t:"object"==typeof t&&"x"in t&&"y"in t?new p(t.x,t.y):new p(t,e,i)}function f(t,e){if(t)for(var i=e?[t,e]:t,n=0,o=i.length;n<o;n++)this.extend(i[n])}function _(t,e){return!t||t instanceof f?t:new f(t,e)}function s(t,e){if(t)for(var i=e?[t,e]:t,n=0,o=i.length;n<o;n++)this.extend(i[n])}function g(t,e){return t instanceof s?t:new s(t,e)}function v(t,e,i){if(isNaN(t)||isNaN(e))throw new Error("Invalid LatLng object: ("+t+", "+e+")");this.lat=+t,this.lng=+e,void 0!==i&&(this.alt=+i)}function w(t,e,i){return t instanceof v?t:d(t)&&"object"!=typeof t[0]?3===t.length?new v(t[0],t[1],t[2]):2===t.length?new v(t[0],t[1]):null:null==t?t:"object"==typeof t&&"lat"in t?new v(t.lat,"lng"in t?t.lng:t.lon,t.alt):void 0===e?null:new v(t,e,i)}p.prototype={clone:function(){return new p(this.x,this.y)},add:function(t){return this.clone()._add(m(t))},_add:function(t){return this.x+=t.x,this.y+=t.y,this},subtract:function(t){return this.clone()._subtract(m(t))},_subtract:function(t){return this.x-=t.x,this.y-=t.y,this},divideBy:function(t){return this.clone()._divideBy(t)},_divideBy:function(t){return this.x/=t,this.y/=t,this},multiplyBy:function(t){return this.clone()._multiplyBy(t)},_multiplyBy:function(t){return this.x*=t,this.y*=t,this},scaleBy:function(t){return new p(this.x*t.x,this.y*t.y)},unscaleBy:function(t){return new p(this.x/t.x,this.y/t.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=nt(this.x),this.y=nt(this.y),this},distanceTo:function(t){var e=(t=m(t)).x-this.x,t=t.y-this.y;return Math.sqrt(e*e+t*t)},equals:function(t){return(t=m(t)).x===this.x&&t.y===this.y},contains:function(t){return t=m(t),Math.abs(t.x)<=Math.abs(this.x)&&Math.abs(t.y)<=Math.abs(this.y)},toString:function(){return"Point("+i(this.x)+", "+i(this.y)+")"}},f.prototype={extend:function(t){var e,i;if(t){if(t instanceof p||"number"==typeof t[0]||"x"in t)e=i=m(t);else if(e=(t=_(t)).min,i=t.max,!e||!i)return this;this.min||this.max?(this.min.x=Math.min(e.x,this.min.x),this.max.x=Math.max(i.x,this.max.x),this.min.y=Math.min(e.y,this.min.y),this.max.y=Math.max(i.y,this.max.y)):(this.min=e.clone(),this.max=i.clone())}return this},getCenter:function(t){return m((this.min.x+this.max.x)/2,(this.min.y+this.max.y)/2,t)},getBottomLeft:function(){return m(this.min.x,this.max.y)},getTopRight:function(){return m(this.max.x,this.min.y)},getTopLeft:function(){return this.min},getBottomRight:function(){return this.max},getSize:function(){return this.max.subtract(this.min)},contains:function(t){var e,i;return(t=("number"==typeof t[0]||t instanceof p?m:_)(t))instanceof f?(e=t.min,i=t.max):e=i=t,e.x>=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.x<i.x,t=t.y>e.y&&n.y<i.y;return o&&t},isValid:function(){return!(!this.min||!this.max)},pad:function(t){var e=this.min,i=this.max,n=Math.abs(e.x-i.x)*t,t=Math.abs(e.y-i.y)*t;return _(m(e.x-n,e.y-t),m(i.x+n,i.y+t))},equals:function(t){return!!t&&(t=_(t),this.min.equals(t.getTopLeft())&&this.max.equals(t.getBottomRight()))}},s.prototype={extend:function(t){var e,i,n=this._southWest,o=this._northEast;if(t instanceof v)i=e=t;else{if(!(t instanceof s))return t?this.extend(w(t)||g(t)):this;if(e=t._southWest,i=t._northEast,!e||!i)return this}return n||o?(n.lat=Math.min(e.lat,n.lat),n.lng=Math.min(e.lng,n.lng),o.lat=Math.max(i.lat,o.lat),o.lng=Math.max(i.lng,o.lng)):(this._southWest=new v(e.lat,e.lng),this._northEast=new v(i.lat,i.lng)),this},pad:function(t){var e=this._southWest,i=this._northEast,n=Math.abs(e.lat-i.lat)*t,t=Math.abs(e.lng-i.lng)*t;return new s(new v(e.lat-n,e.lng-t),new v(i.lat+n,i.lng+t))},getCenter:function(){return new v((this._southWest.lat+this._northEast.lat)/2,(this._southWest.lng+this._northEast.lng)/2)},getSouthWest:function(){return this._southWest},getNorthEast:function(){return this._northEast},getNorthWest:function(){return new v(this.getNorth(),this.getWest())},getSouthEast:function(){return new v(this.getSouth(),this.getEast())},getWest:function(){return this._southWest.lng},getSouth:function(){return this._southWest.lat},getEast:function(){return this._northEast.lng},getNorth:function(){return this._northEast.lat},contains:function(t){t=("number"==typeof t[0]||t instanceof v||"lat"in t?w:g)(t);var e,i,n=this._southWest,o=this._northEast;return t instanceof s?(e=t.getSouthWest(),i=t.getNorthEast()):e=i=t,e.lat>=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.lat<i.lat,t=t.lng>e.lng&&n.lng<i.lng;return o&&t},toBBoxString:function(){return[this.getWest(),this.getSouth(),this.getEast(),this.getNorth()].join(",")},equals:function(t,e){return!!t&&(t=g(t),this._southWest.equals(t.getSouthWest(),e)&&this._northEast.equals(t.getNorthEast(),e))},isValid:function(){return!(!this._southWest||!this._northEast)}};var ot={latLngToPoint:function(t,e){t=this.projection.project(t),e=this.scale(e);return this.transformation._transform(t,e)},pointToLatLng:function(t,e){e=this.scale(e),t=this.transformation.untransform(t,e);return this.projection.unproject(t)},project:function(t){return this.projection.project(t)},unproject:function(t){return this.projection.unproject(t)},scale:function(t){return 256*Math.pow(2,t)},zoom:function(t){return Math.log(t/256)/Math.LN2},getProjectedBounds:function(t){var e;return this.infinite?null:(e=this.projection.bounds,t=this.scale(t),new f(this.transformation.transform(e.min,t),this.transformation.transform(e.max,t)))},infinite:!(v.prototype={equals:function(t,e){return!!t&&(t=w(t),Math.max(Math.abs(this.lat-t.lat),Math.abs(this.lng-t.lng))<=(void 0===e?1e-9:e))},toString:function(t){return"LatLng("+i(this.lat,t)+", "+i(this.lng,t)+")"},distanceTo:function(t){return st.distance(this,w(t))},wrap:function(){return st.wrapLatLng(this)},toBounds:function(t){var t=180*t/40075017,e=t/Math.cos(Math.PI/180*this.lat);return g([this.lat-t,this.lng-e],[this.lat+t,this.lng+e])},clone:function(){return new v(this.lat,this.lng,this.alt)}}),wrapLatLng:function(t){var e=this.wrapLng?H(t.lng,this.wrapLng,!0):t.lng;return new v(this.wrapLat?H(t.lat,this.wrapLat,!0):t.lat,e,t.alt)},wrapLatLngBounds:function(t){var e=t.getCenter(),i=this.wrapLatLng(e),n=e.lat-i.lat,e=e.lng-i.lng;return 0==n&&0==e?t:(i=t.getSouthWest(),t=t.getNorthEast(),new s(new v(i.lat-n,i.lng-e),new v(t.lat-n,t.lng-e)))}},st=l({},ot,{wrapLng:[-180,180],R:6371e3,distance:function(t,e){var i=Math.PI/180,n=t.lat*i,o=e.lat*i,s=Math.sin((e.lat-t.lat)*i/2),e=Math.sin((e.lng-t.lng)*i/2),t=s*s+Math.cos(n)*Math.cos(o)*e*e,i=2*Math.atan2(Math.sqrt(t),Math.sqrt(1-t));return this.R*i}}),rt=6378137,rt={R:rt,MAX_LATITUDE:85.0511287798,project:function(t){var e=Math.PI/180,i=this.MAX_LATITUDE,i=Math.max(Math.min(i,t.lat),-i),i=Math.sin(i*e);return new p(this.R*t.lng*e,this.R*Math.log((1+i)/(1-i))/2)},unproject:function(t){var e=180/Math.PI;return new v((2*Math.atan(Math.exp(t.y/this.R))-Math.PI/2)*e,t.x*e/this.R)},bounds:new f([-(rt=rt*Math.PI),-rt],[rt,rt])};function at(t,e,i,n){d(t)?(this._a=t[0],this._b=t[1],this._c=t[2],this._d=t[3]):(this._a=t,this._b=e,this._c=i,this._d=n)}function ht(t,e,i,n){return new at(t,e,i,n)}at.prototype={transform:function(t,e){return this._transform(t.clone(),e)},_transform:function(t,e){return t.x=(e=e||1)*(this._a*t.x+this._b),t.y=e*(this._c*t.y+this._d),t},untransform:function(t,e){return new p((t.x/(e=e||1)-this._b)/this._a,(t.y/e-this._d)/this._c)}};var lt=l({},st,{code:"EPSG:3857",projection:rt,transformation:ht(lt=.5/(Math.PI*rt.R),.5,-lt,.5)}),ut=l({},lt,{code:"EPSG:900913"});function ct(t){return document.createElementNS("http://www.w3.org/2000/svg",t)}function dt(t,e){for(var i,n,o,s,r="",a=0,h=t.length;a<h;a++){for(i=0,n=(o=t[a]).length;i<n;i++)r+=(i?"L":"M")+(s=o[i]).x+" "+s.y;r+=e?b.svg?"z":"x":""}return r||"M0 0"}var _t=document.documentElement.style,pt="ActiveXObject"in window,mt=pt&&!document.addEventListener,n="msLaunchUri"in navigator&&!("documentMode"in document),ft=y("webkit"),gt=y("android"),vt=y("android 2")||y("android 3"),yt=parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1],10),yt=gt&&y("Google")&&yt<537&&!("AudioNode"in window),xt=!!window.opera,wt=!n&&y("chrome"),bt=y("gecko")&&!ft&&!xt&&!pt,Pt=!wt&&y("safari"),Lt=y("phantom"),o="OTransition"in _t,Tt=0===navigator.platform.indexOf("Win"),Mt=pt&&"transition"in _t,zt="WebKitCSSMatrix"in window&&"m11"in new window.WebKitCSSMatrix&&!vt,_t="MozPerspective"in _t,Ct=!window.L_DISABLE_3D&&(Mt||zt||_t)&&!o&&!Lt,Zt="undefined"!=typeof orientation||y("mobile"),St=Zt&&ft,Et=Zt&&zt,kt=!window.PointerEvent&&window.MSPointerEvent,Ot=!(!window.PointerEvent&&!kt),At="ontouchstart"in window||!!window.TouchEvent,Bt=!window.L_NO_TOUCH&&(At||Ot),It=Zt&&xt,Rt=Zt&&bt,Nt=1<(window.devicePixelRatio||window.screen.deviceXDPI/window.screen.logicalXDPI),Dt=function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("testPassiveEventSupport",u,e),window.removeEventListener("testPassiveEventSupport",u,e)}catch(t){}return t}(),jt=!!document.createElement("canvas").getContext,Ht=!(!document.createElementNS||!ct("svg").createSVGRect),Wt=!!Ht&&((Wt=document.createElement("div")).innerHTML="<svg/>","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='<v:shape adj="1"/>',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;o<s;o++)t.classList.add(n[o]);else ve(t,e)||ye(t,((i=xe(t))?i+" ":"")+e)}function z(t,e){void 0!==t.classList?t.classList.remove(e):ye(t,W((" "+xe(t)+" ").replace(" "+e+" "," ")))}function ye(t,e){void 0===t.className.baseVal?t.className=e:t.className.baseVal=e}function xe(t){return void 0===(t=t.correspondingElement?t.correspondingElement:t).className.baseVal?t.className:t.className.baseVal}function C(t,e){if("opacity"in t.style)t.style.opacity=e;else if("filter"in t.style){var i=!1,n="DXImageTransform.Microsoft.Alpha";try{i=t.filters.item(n)}catch(t){if(1===e)return}e=Math.round(100*e),i?(i.Enabled=100!==e,i.Opacity=e):t.style.filter+=" progid:"+n+"(opacity="+e+")"}}function we(t){for(var e=document.documentElement.style,i=0;i<t.length;i++)if(t[i]in e)return t[i];return!1}function be(t,e,i){e=e||new p(0,0);t.style[ue]=(b.ie3d?"translate("+e.x+"px,"+e.y+"px)":"translate3d("+e.x+"px,"+e.y+"px,0)")+(i?" scale("+i+")":"")}function Z(t,e){t._leaflet_pos=e,b.any3d?be(t,e):(t.style.left=e.x+"px",t.style.top=e.y+"px")}function Pe(t){return t._leaflet_pos||new p(0,0)}function Le(){S(window,"dragstart",O)}function Te(){k(window,"dragstart",O)}function Me(t){for(;-1===t.tabIndex;)t=t.parentNode;t.style&&(ze(),le=(he=t).style.outlineStyle,t.style.outlineStyle="none",S(window,"keydown",ze))}function ze(){he&&(he.style.outlineStyle=le,le=he=void 0,k(window,"keydown",ze))}function Ce(t){for(;!((t=t.parentNode).offsetWidth&&t.offsetHeight||t===document.body););return t}function Ze(t){var e=t.getBoundingClientRect();return{x:e.width/t.offsetWidth||1,y:e.height/t.offsetHeight||1,boundingClientRect:e}}ae="onselectstart"in document?(re=function(){S(window,"selectstart",O)},function(){k(window,"selectstart",O)}):(se=we(["userSelect","WebkitUserSelect","OUserSelect","MozUserSelect","msUserSelect"]),re=function(){var t;se&&(t=document.documentElement.style,oe=t[se],t[se]="none")},function(){se&&(document.documentElement.style[se]=oe,oe=void 0)});pt={__proto__:null,TRANSFORM:ue,TRANSITION:ce,TRANSITION_END:de,get:_e,getStyle:pe,create:P,remove:T,empty:me,toFront:fe,toBack:ge,hasClass:ve,addClass:M,removeClass:z,setClass:ye,getClass:xe,setOpacity:C,testProp:we,setTransform:be,setPosition:Z,getPosition:Pe,get disableTextSelection(){return re},get enableTextSelection(){return ae},disableImageDrag:Le,enableImageDrag:Te,preventOutline:Me,restoreOutline:ze,getSizedParentNode:Ce,getScale:Ze};function S(t,e,i,n){if(e&&"object"==typeof e)for(var o in e)ke(t,o,e[o],i);else for(var s=0,r=(e=F(e)).length;s<r;s++)ke(t,e[s],i,n);return this}var E="_leaflet_events";function k(t,e,i,n){if(1===arguments.length)Se(t),delete t[E];else if(e&&"object"==typeof e)for(var o in e)Oe(t,o,e[o],i);else if(e=F(e),2===arguments.length)Se(t,function(t){return-1!==G(e,t)});else for(var s=0,r=e.length;s<r;s++)Oe(t,e[s],i,n);return this}function Se(t,e){for(var i in t[E]){var n=i.split(/\d/)[0];e&&!e(n)||Oe(t,n,null,null,i)}}var Ee={mouseenter:"mouseover",mouseleave:"mouseout",wheel:!("onwheel"in window)&&"mousewheel"};function ke(e,t,i,n){var o,s,r=t+h(i)+(n?"_"+h(n):"");e[E]&&e[E][r]||(s=o=function(t){return i.call(n||e,t||window.event)},!b.touchNative&&b.pointer&&0===t.indexOf("touch")?o=Jt(e,t,o):b.touch&&"dblclick"===t?o=ne(e,o):"addEventListener"in e?"touchstart"===t||"touchmove"===t||"wheel"===t||"mousewheel"===t?e.addEventListener(Ee[t]||t,o,!!b.passiveEvents&&{passive:!1}):"mouseenter"===t||"mouseleave"===t?e.addEventListener(Ee[t],o=function(t){t=t||window.event,We(e,t)&&s(t)},!1):e.addEventListener(t,s,!1):e.attachEvent("on"+t,o),e[E]=e[E]||{},e[E][r]=o)}function Oe(t,e,i,n,o){o=o||e+h(i)+(n?"_"+h(n):"");var s,r,i=t[E]&&t[E][o];i&&(!b.touchNative&&b.pointer&&0===e.indexOf("touch")?(n=t,r=i,Gt[s=e]?n.removeEventListener(Gt[s],r,!1):console.warn("wrong event specified:",s)):b.touch&&"dblclick"===e?(n=i,(r=t).removeEventListener("dblclick",n.dblclick),r.removeEventListener("click",n.simDblclick)):"removeEventListener"in t?t.removeEventListener(Ee[e]||e,i,!1):t.detachEvent("on"+e,i),t[E][o]=null)}function Ae(t){return t.stopPropagation?t.stopPropagation():t.originalEvent?t.originalEvent._stopped=!0:t.cancelBubble=!0,this}function Be(t){return ke(t,"wheel",Ae),this}function Ie(t){return S(t,"mousedown touchstart dblclick contextmenu",Ae),t._leaflet_disable_click=!0,this}function O(t){return t.preventDefault?t.preventDefault():t.returnValue=!1,this}function Re(t){return O(t),Ae(t),this}function Ne(t){if(t.composedPath)return t.composedPath();for(var e=[],i=t.target;i;)e.push(i),i=i.parentNode;return e}function De(t,e){var i,n;return e?(n=(i=Ze(e)).boundingClientRect,new p((t.clientX-n.left)/i.x-e.clientLeft,(t.clientY-n.top)/i.y-e.clientTop)):new p(t.clientX,t.clientY)}var je=b.linux&&b.chrome?window.devicePixelRatio:b.mac?3*window.devicePixelRatio:0<window.devicePixelRatio?2*window.devicePixelRatio:1;function He(t){return b.edge?t.wheelDeltaY/2:t.deltaY&&0===t.deltaMode?-t.deltaY/je:t.deltaY&&1===t.deltaMode?20*-t.deltaY:t.deltaY&&2===t.deltaMode?60*-t.deltaY:t.deltaX||t.deltaZ?0:t.wheelDelta?(t.wheelDeltaY||t.wheelDelta)/2:t.detail&&Math.abs(t.detail)<32765?20*-t.detail:t.detail?t.detail/-32765*60:0}function We(t,e){var i=e.relatedTarget;if(!i)return!0;try{for(;i&&i!==t;)i=i.parentNode}catch(t){return!1}return i!==t}var mt={__proto__:null,on:S,off:k,stopPropagation:Ae,disableScrollPropagation:Be,disableClickPropagation:Ie,preventDefault:O,stop:Re,getPropagationPath:Ne,getMousePosition:De,getWheelDelta:He,isExternalTarget:We,addListener:S,removeListener:k},Fe=it.extend({run:function(t,e,i,n){this.stop(),this._el=t,this._inProgress=!0,this._duration=i||.25,this._easeOutPower=1/Math.max(n||.5,.2),this._startPos=Pe(t),this._offset=e.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=x(this._animate,this),this._step()},_step:function(t){var e=+new Date-this._startTime,i=1e3*this._duration;e<i?this._runFrame(this._easeOut(e/i),t):(this._runFrame(1),this._complete())},_runFrame:function(t,e){t=this._startPos.add(this._offset.multiplyBy(t));e&&t._round(),Z(this._el,t),this.fire("step")},_complete:function(){r(this._animId),this._inProgress=!1,this.fire("end")},_easeOut:function(t){return 1-Math.pow(1-t,this._easeOutPower)}}),A=it.extend({options:{crs:lt,center:void 0,zoom:void 0,minZoom:void 0,maxZoom:void 0,layers:[],maxBounds:void 0,renderer:void 0,zoomAnimation:!0,zoomAnimationThreshold:4,fadeAnimation:!0,markerZoomAnimation:!0,transform3DLimit:8388608,zoomSnap:1,zoomDelta:1,trackResize:!0},initialize:function(t,e){e=c(this,e),this._handlers=[],this._layers={},this._zoomBoundLayers={},this._sizeChanged=!0,this._initContainer(t),this._initLayout(),this._onResize=a(this._onResize,this),this._initEvents(),e.maxBounds&&this.setMaxBounds(e.maxBounds),void 0!==e.zoom&&(this._zoom=this._limitZoom(e.zoom)),e.center&&void 0!==e.zoom&&this.setView(w(e.center),e.zoom,{reset:!0}),this.callInitHooks(),this._zoomAnimated=ce&&b.any3d&&!b.mobileOpera&&this.options.zoomAnimation,this._zoomAnimated&&(this._createAnimProxy(),S(this._proxy,de,this._catchTransitionEnd,this)),this._addLayers(this.options.layers)},setView:function(t,e,i){if((e=void 0===e?this._zoom:this._limitZoom(e),t=this._limitCenter(w(t),e,this.options.maxBounds),i=i||{},this._stop(),this._loaded&&!i.reset&&!0!==i)&&(void 0!==i.animate&&(i.zoom=l({animate:i.animate},i.zoom),i.pan=l({animate:i.animate,duration:i.duration},i.pan)),this._zoom!==e?this._tryAnimatedZoom&&this._tryAnimatedZoom(t,e,i.zoom):this._tryAnimatedPan(t,i.pan)))return clearTimeout(this._sizeTimer),this;return this._resetView(t,e,i.pan&&i.pan.noMoveStart),this},setZoom:function(t,e){return this._loaded?this.setView(this.getCenter(),t,{zoom:e}):(this._zoom=t,this)},zoomIn:function(t,e){return t=t||(b.any3d?this.options.zoomDelta:1),this.setZoom(this._zoom+t,e)},zoomOut:function(t,e){return t=t||(b.any3d?this.options.zoomDelta:1),this.setZoom(this._zoom-t,e)},setZoomAround:function(t,e,i){var n=this.getZoomScale(e),o=this.getSize().divideBy(2),t=(t instanceof p?t:this.latLngToContainerPoint(t)).subtract(o).multiplyBy(1-1/n),n=this.containerPointToLatLng(o.add(t));return this.setView(n,e,{zoom:i})},_getBoundsCenterZoom:function(t,e){e=e||{},t=t.getBounds?t.getBounds():g(t);var i=m(e.paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.getBoundsZoom(t,!1,i.add(n));return(o="number"==typeof e.maxZoom?Math.min(e.maxZoom,o):o)===1/0?{center:t.getCenter(),zoom:o}:(e=n.subtract(i).divideBy(2),n=this.project(t.getSouthWest(),o),i=this.project(t.getNorthEast(),o),{center:this.unproject(n.add(i).divideBy(2).add(e),o),zoom:o})},fitBounds:function(t,e){if((t=g(t)).isValid())return t=this._getBoundsCenterZoom(t,e),this.setView(t.center,t.zoom,e);throw new Error("Bounds are not valid.")},fitWorld:function(t){return this.fitBounds([[-90,-180],[90,180]],t)},panTo:function(t,e){return this.setView(t,this._zoom,{pan:e})},panBy:function(t,e){var i;return e=e||{},(t=m(t).round()).x||t.y?(!0===e.animate||this.getSize().contains(t)?(this._panAnim||(this._panAnim=new Fe,this._panAnim.on({step:this._onPanTransitionStep,end:this._onPanTransitionEnd},this)),e.noMoveStart||this.fire("movestart"),!1!==e.animate?(M(this._mapPane,"leaflet-pan-anim"),i=this._getMapPanePos().subtract(t).round(),this._panAnim.run(this._mapPane,i,e.duration||.25,e.easeLinearity)):(this._rawPanBy(t),this.fire("move").fire("moveend"))):this._resetView(this.unproject(this.project(this.getCenter()).add(t)),this.getZoom()),this):this.fire("moveend")},flyTo:function(n,o,t){if(!1===(t=t||{}).animate||!b.any3d)return this.setView(n,o,t);this._stop();var s=this.project(this.getCenter()),r=this.project(n),e=this.getSize(),a=this._zoom,h=(n=w(n),o=void 0===o?a:o,Math.max(e.x,e.y)),i=h*this.getZoomScale(a,o),l=r.distanceTo(s)||1,u=1.42,c=u*u;function d(t){t=(i*i-h*h+(t?-1:1)*c*c*l*l)/(2*(t?i:h)*c*l),t=Math.sqrt(t*t+1)-t;return t<1e-9?-18:Math.log(t)}function _(t){return(Math.exp(t)-Math.exp(-t))/2}function p(t){return(Math.exp(t)+Math.exp(-t))/2}var m=d(0);function f(t){return h*(p(m)*(_(t=m+u*t)/p(t))-_(m))/c}var g=Date.now(),v=(d(1)-m)/u,y=t.duration?1e3*t.duration:1e3*v*.8;return this._moveStart(!0,t.noMoveStart),function t(){var e=(Date.now()-g)/y,i=(1-Math.pow(1-e,1.5))*v;e<=1?(this._flyToFrame=x(t,this),this._move(this.unproject(s.add(r.subtract(s).multiplyBy(f(i)/l)),a),this.getScaleZoom(h/(e=i,h*(p(m)/p(m+u*e))),a),{flyTo:!0})):this._move(n,o)._moveEnd(!0)}.call(this),this},flyToBounds:function(t,e){t=this._getBoundsCenterZoom(t,e);return this.flyTo(t.center,t.zoom,e)},setMaxBounds:function(t){return t=g(t),this.listens("moveend",this._panInsideMaxBounds)&&this.off("moveend",this._panInsideMaxBounds),t.isValid()?(this.options.maxBounds=t,this._loaded&&this._panInsideMaxBounds(),this.on("moveend",this._panInsideMaxBounds)):(this.options.maxBounds=null,this)},setMinZoom:function(t){var e=this.options.minZoom;return this.options.minZoom=t,this._loaded&&e!==t&&(this.fire("zoomlevelschange"),this.getZoom()<this.options.minZoom)?this.setZoom(t):this},setMaxZoom:function(t){var e=this.options.maxZoom;return this.options.maxZoom=t,this._loaded&&e!==t&&(this.fire("zoomlevelschange"),this.getZoom()>this.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;s<i.length;s++)i[s].listens(e,!0)&&o.push(i[s]);n=o.concat(n)}if(n.length){"contextmenu"===e&&O(t);var r,a=n[0],h={originalEvent:t};for("keypress"!==t.type&&"keydown"!==t.type&&"keyup"!==t.type&&(r=a.getLatLng&&(!a._radius||a._radius<=10),h.containerPoint=r?this.latLngToContainerPoint(a.getLatLng()):this.mouseEventToContainerPoint(t),h.layerPoint=this.containerPointToLayerPoint(h.containerPoint),h.latlng=r?a.getLatLng():this.layerPointToLatLng(h.layerPoint)),s=0;s<n.length;s++)if(n[s].fire(e,h,!0),h.originalEvent._stopped||!1===n[s].options.bubblingMouseEvents&&-1!==G(this._mouseEvents,e))return}},_draggableMoved:function(t){return(t=t.dragging&&t.dragging.enabled()?t:this).dragging&&t.dragging.moved()||this.boxZoom&&this.boxZoom.moved()},_clearHandlers:function(){for(var t=0,e=this._handlers.length;t<e;t++)this._handlers[t].disable()},whenReady:function(t,e){return this._loaded?t.call(e||this,{target:this}):this.on("load",t,e),this},_getMapPanePos:function(){return Pe(this._mapPane)||new p(0,0)},_moved:function(){var t=this._getMapPanePos();return t&&!t.equals([0,0])},_getTopLeftPoint:function(t,e){return(t&&void 0!==e?this._getNewPixelOrigin(t,e):this.getPixelOrigin()).subtract(this._getMapPanePos())},_getNewPixelOrigin:function(t,e){var i=this.getSize()._divideBy(2);return this.project(t,e)._subtract(i)._add(this._getMapPanePos())._round()},_latLngToNewLayerPoint:function(t,e,i){i=this._getNewPixelOrigin(i,e);return this.project(t,e)._subtract(i)},_latLngBoundsToNewLayerBounds:function(t,e,i){i=this._getNewPixelOrigin(i,e);return _([this.project(t.getSouthWest(),e)._subtract(i),this.project(t.getNorthWest(),e)._subtract(i),this.project(t.getSouthEast(),e)._subtract(i),this.project(t.getNorthEast(),e)._subtract(i)])},_getCenterLayerPoint:function(){return this.containerPointToLayerPoint(this.getSize()._divideBy(2))},_getCenterOffset:function(t){return this.latLngToLayerPoint(t).subtract(this._getCenterLayerPoint())},_limitCenter:function(t,e,i){var n,o;return!i||(n=this.project(t,e),o=this.getSize().divideBy(2),o=new f(n.subtract(o),n.add(o)),o=this._getBoundsOffset(o,i,e),Math.abs(o.x)<=1&&Math.abs(o.y)<=1)?t:this.unproject(n.add(o),e)},_limitOffset:function(t,e){var i;return e?(i=new f((i=this.getPixelBounds()).min.add(t),i.max.add(t)),t.add(this._getBoundsOffset(i,e))):t},_getBoundsOffset:function(t,e,i){e=_(this.project(e.getNorthEast(),i),this.project(e.getSouthWest(),i)),i=e.min.subtract(t.min),e=e.max.subtract(t.max);return new p(this._rebound(i.x,-e.x),this._rebound(i.y,-e.y))},_rebound:function(t,e){return 0<t+e?Math.round(t-e)/2:Math.max(0,Math.ceil(t))-Math.max(0,Math.floor(e))},_limitZoom:function(t){var e=this.getMinZoom(),i=this.getMaxZoom(),n=b.any3d?this.options.zoomSnap:1;return n&&(t=Math.round(t/n)*n),Math.max(e,Math.min(i,t))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){z(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,e){t=this._getCenterOffset(t)._trunc();return!(!0!==(e&&e.animate)&&!this.getSize().contains(t))&&(this.panBy(t,e),!0)},_createAnimProxy:function(){var t=this._proxy=P("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(t),this.on("zoomanim",function(t){var e=ue,i=this._proxy.style[e];be(this._proxy,this.project(t.center,t.zoom),this.getZoomScale(t.zoom,1)),i===this._proxy.style[e]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",this._animMoveEnd,this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){T(this._proxy),this.off("load moveend",this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var t=this.getCenter(),e=this.getZoom();be(this._proxy,this.project(t,e),this.getZoomScale(e,1))},_catchTransitionEnd:function(t){this._animatingZoom&&0<=t.propertyName.indexOf("transform")&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,e,i){if(!this._animatingZoom){if(i=i||{},!this._zoomAnimated||!1===i.animate||this._nothingToAnimate()||Math.abs(e-this._zoom)>this.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0<t.screenX&&0<t.screenY&&this._map.getContainer().focus()}}),Ve=(A.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){var i=this._controlCorners={},n="leaflet-",o=this._controlContainer=P("div",n+"control-container",this._container);function t(t,e){i[t+e]=P("div",n+t+" "+n+e,o)}t("top","left"),t("top","right"),t("bottom","left"),t("bottom","right")},_clearControlPos:function(){for(var t in this._controlCorners)T(this._controlCorners[t]);T(this._controlContainer),delete this._controlCorners,delete this._controlContainer}}),B.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,e,i,n){return i<n?-1:n<i?1:0}},initialize:function(t,e,i){for(var n in c(this,i),this._layerControlInputs=[],this._layers=[],this._lastZIndex=0,this._handlingClick=!1,this._preventClick=!1,t)this._addLayer(t[n],n);for(n in e)this._addLayer(e[n],n,!0)},onAdd:function(t){this._initLayout(),this._update(),(this._map=t).on("zoomend",this._checkDisabledLayers,this);for(var e=0;e<this._layers.length;e++)this._layers[e].layer.on("add remove",this._onLayerChange,this);return this._container},addTo:function(t){return B.prototype.addTo.call(this,t),this._expandIfNotCollapsed()},onRemove:function(){this._map.off("zoomend",this._checkDisabledLayers,this);for(var t=0;t<this._layers.length;t++)this._layers[t].layer.off("add remove",this._onLayerChange,this)},addBaseLayer:function(t,e){return this._addLayer(t,e),this._map?this._update():this},addOverlay:function(t,e){return this._addLayer(t,e,!0),this._map?this._update():this},removeLayer:function(t){t.off("add remove",this._onLayerChange,this);t=this._getLayer(h(t));return t&&this._layers.splice(this._layers.indexOf(t),1),this._map?this._update():this},expand:function(){M(this._container,"leaflet-control-layers-expanded"),this._section.style.height=null;var t=this._map.getSize().y-(this._container.offsetTop+50);return t<this._section.clientHeight?(M(this._section,"leaflet-control-layers-scrollbar"),this._section.style.height=t+"px"):z(this._section,"leaflet-control-layers-scrollbar"),this._checkDisabledLayers(),this},collapse:function(){return z(this._container,"leaflet-control-layers-expanded"),this},_initLayout:function(){var t="leaflet-control-layers",e=this._container=P("div",t),i=this.options.collapsed,n=(e.setAttribute("aria-haspopup",!0),Ie(e),Be(e),this._section=P("section",t+"-list")),o=(i&&(this._map.on("click",this.collapse,this),S(e,{mouseenter:this._expandSafely,mouseleave:this.collapse},this)),this._layersLink=P("a",t+"-toggle",e));o.href="#",o.title="Layers",o.setAttribute("role","button"),S(o,{keydown:function(t){13===t.keyCode&&this._expandSafely()},click:function(t){O(t),this._expandSafely()}},this),i||this.expand(),this._baseLayersList=P("div",t+"-base",n),this._separator=P("div",t+"-separator",n),this._overlaysList=P("div",t+"-overlays",n),e.appendChild(n)},_getLayer:function(t){for(var e=0;e<this._layers.length;e++)if(this._layers[e]&&h(this._layers[e].layer)===t)return this._layers[e]},_addLayer:function(t,e,i){this._map&&t.on("add remove",this._onLayerChange,this),this._layers.push({layer:t,name:e,overlay:i}),this.options.sortLayers&&this._layers.sort(a(function(t,e){return this.options.sortFunction(t.layer,e.layer,t.name,e.name)},this)),this.options.autoZIndex&&t.setZIndex&&(this._lastZIndex++,t.setZIndex(this._lastZIndex)),this._expandIfNotCollapsed()},_update:function(){if(this._container){me(this._baseLayersList),me(this._overlaysList),this._layerControlInputs=[];for(var t,e,i,n=0,o=0;o<this._layers.length;o++)i=this._layers[o],this._addItem(i),e=e||i.overlay,t=t||!i.overlay,n+=i.overlay?0:1;this.options.hideSingleBase&&(this._baseLayersList.style.display=(t=t&&1<n)?"":"none"),this._separator.style.display=e&&t?"":"none"}return this},_onLayerChange:function(t){this._handlingClick||this._update();var e=this._getLayer(h(t.target)),t=e.overlay?"add"===t.type?"overlayadd":"overlayremove":"add"===t.type?"baselayerchange":null;t&&this._map.fire(t,e)},_createRadioElement:function(t,e){t='<input type="radio" class="leaflet-control-layers-selector" name="'+t+'"'+(e?' checked="checked"':"")+"/>",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;s<o.length;s++)this._map.hasLayer(o[s])&&this._map.removeLayer(o[s]);for(s=0;s<n.length;s++)this._map.hasLayer(n[s])||this._map.addLayer(n[s]);this._handlingClick=!1,this._refocusOnMap()}},_checkDisabledLayers:function(){for(var t,e,i=this._layerControlInputs,n=this._map.getZoom(),o=i.length-1;0<=o;o--)t=i[o],e=this._getLayer(t.layerId).layer,t.disabled=void 0!==e.options.minZoom&&n<e.options.minZoom||void 0!==e.options.maxZoom&&n>e.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'<span aria-hidden="true">+</span>',zoomInTitle:"Zoom in",zoomOutText:'<span aria-hidden="true">−</span>',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoom<this._map.getMaxZoom()&&this._map.zoomIn(this._map.options.zoomDelta*(t.shiftKey?3:1))},_zoomOut:function(t){!this._disabled&&this._map._zoom>this._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280<t?(i=this._getRoundNum(e=t/5280),this._updateScale(this._iScale,i+" mi",i/e)):(i=this._getRoundNum(t),this._updateScale(this._iScale,i+" ft",i/t))},_updateScale:function(t,e,i){t.style.width=Math.round(this.options.maxWidth*i)+"px",t.innerHTML=e},_getRoundNum:function(t){var e=Math.pow(10,(Math.floor(t)+"").length-1),t=t/e;return e*(t=10<=t?10:5<=t?5:3<=t?3:2<=t?2:1)}})),Ke=B.extend({options:{position:"bottomright",prefix:'<a href="https://leafletjs.com" title="A JavaScript library for interactive maps">'+(b.inlineSvg?'<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8" class="leaflet-attribution-flag"><path fill="#4C7BE1" d="M0 0h12v4H0z"/><path fill="#FFD500" d="M0 4h12v3H0z"/><path fill="#E0BC00" d="M0 7h12v1H0z"/></svg> ':"")+"Leaflet</a>"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' <span aria-hidden="true">|</span> ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1<t.touches.length?this._moved=!0:!(e=new p((e=t.touches&&1===t.touches.length?t.touches[0]:t).clientX,e.clientY)._subtract(this._startPoint)).x&&!e.y||Math.abs(e.x)+Math.abs(e.y)<this.options.clickTolerance||(e.x/=this._parentScale.x,e.y/=this._parentScale.y,O(t),this._moved||(this.fire("dragstart"),this._moved=!0,M(document.body,"leaflet-dragging"),this._lastTarget=t.target||t.srcElement,window.SVGElementInstance&&this._lastTarget instanceof window.SVGElementInstance&&(this._lastTarget=this._lastTarget.correspondingUseElement),M(this._lastTarget,"leaflet-drag-target")),this._newPos=this._startPos.add(e),this._moving=!0,this._lastEvent=t,this._updatePosition()))},_updatePosition:function(){var t={originalEvent:this._lastEvent};this.fire("predrag",t),Z(this._element,this._newPos),this.fire("drag",t)},_onUp:function(){this._enabled&&this.finishDrag()},finishDrag:function(t){z(document.body,"leaflet-dragging"),this._lastTarget&&(z(this._lastTarget,"leaflet-drag-target"),this._lastTarget=null),k(document,"mousemove touchmove",this._onMove,this),k(document,"mouseup touchend touchcancel",this._onUp,this),Te(),ae();var e=this._moved&&this._moving;this._moving=!1,Xe._dragging=!1,e&&this.fire("dragend",{noInertia:t,distance:this._newPos.distanceTo(this._startPos)})}});function Je(t,e,i){for(var n,o,s,r,a,h,l,u=[1,4,2,8],c=0,d=t.length;c<d;c++)t[c]._code=si(t[c],e);for(s=0;s<4;s++){for(h=u[s],n=[],c=0,o=(d=t.length)-1;c<d;o=c++)r=t[c],a=t[o],r._code&h?a._code&h||((l=oi(a,r,h,e,i))._code=si(l,e),n.push(l)):(a._code&h&&((l=oi(a,r,h,e,i))._code=si(l,e),n.push(l)),n.push(r));t=n}return t}function $e(t,e){var i,n,o,s,r,a,h;if(!t||0===t.length)throw new Error("latlngs not passed");I(t)||(console.warn("latlngs are not flat! Only the first ring will be used"),t=t[0]);for(var l=w([0,0]),u=g(t),c=(u.getNorthWest().distanceTo(u.getSouthWest())*u.getNorthEast().distanceTo(u.getNorthWest())<1700&&(l=Qe(t)),t.length),d=[],_=0;_<c;_++){var p=w(t[_]);d.push(e.project(w([p.lat-l.lat,p.lng-l.lng])))}for(_=r=a=h=0,i=c-1;_<c;i=_++)n=d[_],o=d[i],s=n.y*o.x-o.y*n.x,a+=(n.x+o.x)*s,h+=(n.y+o.y)*s,r+=3*s;u=0===r?d[0]:[a/r,h/r],u=e.unproject(m(u));return w([u.lat+l.lat,u.lng+l.lng])}function Qe(t){for(var e=0,i=0,n=0,o=0;o<t.length;o++){var s=w(t[o]);e+=s.lat,i+=s.lng,n++}return w([e/n,i/n])}var ti,gt={__proto__:null,clipPolygon:Je,polygonCenter:$e,centroid:Qe};function ei(t,e){if(e&&t.length){var i=t=function(t,e){for(var i=[t[0]],n=1,o=0,s=t.length;n<s;n++)(function(t,e){var i=e.x-t.x,e=e.y-t.y;return i*i+e*e})(t[n],t[o])>e&&(i.push(t[n]),o=n);o<s-1&&i.push(t[s-1]);return i}(t,e=e*e),n=i.length,o=new(typeof Uint8Array!=void 0+""?Uint8Array:Array)(n);o[0]=o[n-1]=1,function t(e,i,n,o,s){var r,a,h,l=0;for(a=o+1;a<=s-1;a++)h=ri(e[a],e[o],e[s],!0),l<h&&(r=a,l=h);n<l&&(i[r]=1,t(e,i,n,o,r),t(e,i,n,r,s))}(i,o,e,0,n-1);var s,r=[];for(s=0;s<n;s++)o[s]&&r.push(i[s]);return r}return t.slice()}function ii(t,e,i){return Math.sqrt(ri(t,e,i,!0))}function ni(t,e,i,n,o){var s,r,a,h=n?ti:si(t,i),l=si(e,i);for(ti=l;;){if(!(h|l))return[t,e];if(h&l)return!1;a=si(r=oi(t,e,s=h||l,i,o),i),s===h?(t=r,h=a):(e=r,l=a)}}function oi(t,e,i,n,o){var s,r,a=e.x-t.x,e=e.y-t.y,h=n.min,n=n.max;return 8&i?(s=t.x+a*(n.y-t.y)/e,r=n.y):4&i?(s=t.x+a*(h.y-t.y)/e,r=h.y):2&i?(s=n.x,r=t.y+e*(n.x-t.x)/a):1&i&&(s=h.x,r=t.y+e*(h.x-t.x)/a),new p(s,r,o)}function si(t,e){var i=0;return t.x<e.min.x?i|=1:t.x>e.max.x&&(i|=2),t.y<e.min.y?i|=4:t.y>e.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0<a&&(1<(a=((t.x-o)*s+(t.y-e)*r)/a)?(o=i.x,e=i.y):0<a&&(o+=s*a,e+=r*a)),s=t.x-o,r=t.y-e,n?s*s+r*r:new p(o,e)}function I(t){return!d(t[0])||"object"!=typeof t[0][0]&&void 0!==t[0][0]}function ai(t){return console.warn("Deprecated use of _flat, please use L.LineUtil.isFlat instead."),I(t)}function hi(t,e){var i,n,o,s,r,a;if(!t||0===t.length)throw new Error("latlngs not passed");I(t)||(console.warn("latlngs are not flat! Only the first ring will be used"),t=t[0]);for(var h=w([0,0]),l=g(t),u=(l.getNorthWest().distanceTo(l.getSouthWest())*l.getNorthEast().distanceTo(l.getNorthWest())<1700&&(h=Qe(t)),t.length),c=[],d=0;d<u;d++){var _=w(t[d]);c.push(e.project(w([_.lat-h.lat,_.lng-h.lng])))}for(i=d=0;d<u-1;d++)i+=c[d].distanceTo(c[d+1])/2;if(0===i)a=c[0];else for(n=d=0;d<u-1;d++)if(o=c[d],s=c[d+1],i<(n+=r=o.distanceTo(s))){a=[s.x-(r=(n-i)/r)*(s.x-o.x),s.y-r*(s.y-o.y)];break}l=e.unproject(m(a));return w([l.lat+h.lat,l.lng+h.lng])}var vt={__proto__:null,simplify:ei,pointToSegmentDistance:ii,closestPointOnSegment:function(t,e,i){return ri(t,e,i)},clipSegment:ni,_getEdgeIntersection:oi,_getBitCode:si,_sqClosestPointOnSegment:ri,isFlat:I,_flat:ai,polylineCenter:hi},yt={project:function(t){return new p(t.lng,t.lat)},unproject:function(t){return new v(t.y,t.x)},bounds:new f([-180,-90],[180,90])},xt={R:6378137,R_MINOR:6356752.314245179,bounds:new f([-20037508.34279,-15496570.73972],[20037508.34279,18764656.23138]),project:function(t){var e=Math.PI/180,i=this.R,n=t.lat*e,o=this.R_MINOR/i,o=Math.sqrt(1-o*o),s=o*Math.sin(n),s=Math.tan(Math.PI/4-n/2)/Math.pow((1-s)/(1+s),o/2),n=-i*Math.log(Math.max(s,1e-10));return new p(t.lng*e*i,n)},unproject:function(t){for(var e,i=180/Math.PI,n=this.R,o=this.R_MINOR/n,s=Math.sqrt(1-o*o),r=Math.exp(-t.y/n),a=Math.PI/2-2*Math.atan(r),h=0,l=.1;h<15&&1e-7<Math.abs(l);h++)e=s*Math.sin(a),e=Math.pow((1-e)/(1+e),s/2),a+=l=Math.PI/2-2*Math.atan(r*e)-a;return new v(a*i,t.x*i/n)}},wt={__proto__:null,LonLat:yt,Mercator:xt,SphericalMercator:rt},Pt=l({},st,{code:"EPSG:3395",projection:xt,transformation:ht(bt=.5/(Math.PI*xt.R),.5,-bt,.5)}),li=l({},st,{code:"EPSG:4326",projection:yt,transformation:ht(1/180,1,-1/180,.5)}),Lt=l({},ot,{projection:yt,transformation:ht(1,0,-1,0),scale:function(t){return Math.pow(2,t)},zoom:function(t){return Math.log(t)/Math.LN2},distance:function(t,e){var i=e.lng-t.lng,e=e.lat-t.lat;return Math.sqrt(i*i+e*e)},infinite:!0}),o=(ot.Earth=st,ot.EPSG3395=Pt,ot.EPSG3857=lt,ot.EPSG900913=ut,ot.EPSG4326=li,ot.Simple=Lt,it.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(t){return t.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(t){return t&&t.removeLayer(this),this},getPane:function(t){return this._map.getPane(t?this.options[t]||t:this.options.pane)},addInteractiveTarget:function(t){return this._map._targets[h(t)]=this},removeInteractiveTarget:function(t){return delete this._map._targets[h(t)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(t){var e,i=t.target;i.hasLayer(this)&&(this._map=i,this._zoomAnimated=i._zoomAnimated,this.getEvents&&(e=this.getEvents(),i.on(e,this),this.once("remove",function(){i.off(e,this)},this)),this.onAdd(i),this.fire("add"),i.fire("layeradd",{layer:this}))}})),ui=(A.include({addLayer:function(t){var e;if(t._layerAdd)return e=h(t),this._layers[e]||((this._layers[e]=t)._mapToAdd=this,t.beforeAdd&&t.beforeAdd(this),this.whenReady(t._layerAdd,t)),this;throw new Error("The provided object is not a Layer.")},removeLayer:function(t){var e=h(t);return this._layers[e]&&(this._loaded&&t.onRemove(this),delete this._layers[e],this._loaded&&(this.fire("layerremove",{layer:t}),t.fire("remove")),t._map=t._mapToAdd=null),this},hasLayer:function(t){return h(t)in this._layers},eachLayer:function(t,e){for(var i in this._layers)t.call(e,this._layers[i]);return this},_addLayers:function(t){for(var e=0,i=(t=t?d(t)?t:[t]:[]).length;e<i;e++)this.addLayer(t[e])},_addZoomLimit:function(t){isNaN(t.options.maxZoom)&&isNaN(t.options.minZoom)||(this._zoomBoundLayers[h(t)]=t,this._updateZoomLevels())},_removeZoomLimit:function(t){t=h(t);this._zoomBoundLayers[t]&&(delete this._zoomBoundLayers[t],this._updateZoomLevels())},_updateZoomLevels:function(){var t,e=1/0,i=-1/0,n=this._getZoomSpan();for(t in this._zoomBoundLayers)var o=this._zoomBoundLayers[t].options,e=void 0===o.minZoom?e:Math.min(e,o.minZoom),i=void 0===o.maxZoom?i:Math.max(i,o.maxZoom);this._layersMaxZoom=i===-1/0?void 0:i,this._layersMinZoom=e===1/0?void 0:e,n!==this._getZoomSpan()&&this.fire("zoomlevelschange"),void 0===this.options.maxZoom&&this._layersMaxZoom&&this.getZoom()>this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()<this._layersMinZoom&&this.setZoom(this._layersMinZoom)}}),o.extend({initialize:function(t,e){var i,n;if(c(this,e),this._layers={},t)for(i=0,n=t.length;i<n;i++)this.addLayer(t[i])},addLayer:function(t){var e=this.getLayerId(t);return this._layers[e]=t,this._map&&this._map.addLayer(t),this},removeLayer:function(t){t=t in this._layers?t:this.getLayerId(t);return this._map&&this._layers[t]&&this._map.removeLayer(this._layers[t]),delete this._layers[t],this},hasLayer:function(t){return("number"==typeof t?t:this.getLayerId(t))in this._layers},clearLayers:function(){return this.eachLayer(this.removeLayer,this)},invoke:function(t){var e,i,n=Array.prototype.slice.call(arguments,1);for(e in this._layers)(i=this._layers[e])[t]&&i[t].apply(i,n);return this},onAdd:function(t){this.eachLayer(t.addLayer,t)},onRemove:function(t){this.eachLayer(t.removeLayer,t)},eachLayer:function(t,e){for(var i in this._layers)t.call(e,this._layers[i]);return this},getLayer:function(t){return this._layers[t]},getLayers:function(){var t=[];return this.eachLayer(t.push,t),t},setZIndex:function(t){return this.invoke("setZIndex",t)},getLayerId:h})),ci=ui.extend({addLayer:function(t){return this.hasLayer(t)?this:(t.addEventParent(this),ui.prototype.addLayer.call(this,t),this.fire("layeradd",{layer:t}))},removeLayer:function(t){return this.hasLayer(t)?((t=t in this._layers?this._layers[t]:t).removeEventParent(this),ui.prototype.removeLayer.call(this,t),this.fire("layerremove",{layer:t})):this},setStyle:function(t){return this.invoke("setStyle",t)},bringToFront:function(){return this.invoke("bringToFront")},bringToBack:function(){return this.invoke("bringToBack")},getBounds:function(){var t,e=new s;for(t in this._layers){var i=this._layers[t];e.extend(i.getBounds?i.getBounds():i.getLatLng())}return e}}),di=et.extend({options:{popupAnchor:[0,0],tooltipAnchor:[0,0],crossOrigin:!1},initialize:function(t){c(this,t)},createIcon:function(t){return this._createIcon("icon",t)},createShadow:function(t){return this._createIcon("shadow",t)},_createIcon:function(t,e){var i=this._getIconUrl(t);if(i)return i=this._createImg(i,e&&"IMG"===e.tagName?e:null),this._setIconStyles(i,t),!this.options.crossOrigin&&""!==this.options.crossOrigin||(i.crossOrigin=!0===this.options.crossOrigin?"":this.options.crossOrigin),i;if("icon"===t)throw new Error("iconUrl not set in Icon options (see the docs).");return null},_setIconStyles:function(t,e){var i=this.options,n=i[e+"Size"],n=m(n="number"==typeof n?[n,n]:n),o=m("shadow"===e&&i.shadowAnchor||i.iconAnchor||n&&n.divideBy(2,!0));t.className="leaflet-marker-"+e+" "+(i.className||""),o&&(t.style.marginLeft=-o.x+"px",t.style.marginTop=-o.y+"px"),n&&(t.style.width=n.x+"px",t.style.height=n.y+"px")},_createImg:function(t,e){return(e=e||document.createElement("img")).src=t,e},_getIconUrl:function(t){return b.retina&&this.options[t+"RetinaUrl"]||this.options[t+"Url"]}});var _i=di.extend({options:{iconUrl:"marker-icon.png",iconRetinaUrl:"marker-icon-2x.png",shadowUrl:"marker-shadow.png",iconSize:[25,41],iconAnchor:[12,41],popupAnchor:[1,-34],tooltipAnchor:[16,-28],shadowSize:[41,41]},_getIconUrl:function(t){return"string"!=typeof _i.imagePath&&(_i.imagePath=this._detectIconPath()),(this.options.imagePath||_i.imagePath)+di.prototype._getIconUrl.call(this,t)},_stripUrl:function(t){function e(t,e,i){return(e=e.exec(t))&&e[i]}return(t=e(t,/^url\((['"])?(.+)\1\)$/,2))&&e(t,/^(.*)marker-icon\.png$/,1)},_detectIconPath:function(){var t=P("div","leaflet-default-icon-path",document.body),e=pe(t,"background-image")||pe(t,"backgroundImage");return document.body.removeChild(t),(e=this._stripUrl(e))?e:(t=document.querySelector('link[href$="leaflet.css"]'))?t.href.substring(0,t.href.length-"leaflet.css".length-1):""}}),pi=n.extend({initialize:function(t){this._marker=t},addHooks:function(){var t=this._marker._icon;this._draggable||(this._draggable=new Xe(t,t,!0)),this._draggable.on({dragstart:this._onDragStart,predrag:this._onPreDrag,drag:this._onDrag,dragend:this._onDragEnd},this).enable(),M(t,"leaflet-marker-draggable")},removeHooks:function(){this._draggable.off({dragstart:this._onDragStart,predrag:this._onPreDrag,drag:this._onDrag,dragend:this._onDragEnd},this).disable(),this._marker._icon&&z(this._marker._icon,"leaflet-marker-draggable")},moved:function(){return this._draggable&&this._draggable._moved},_adjustPan:function(t){var e=this._marker,i=e._map,n=this._marker.options.autoPanSpeed,o=this._marker.options.autoPanPadding,s=Pe(e._icon),r=i.getPixelBounds(),a=i.getPixelOrigin(),a=_(r.min._subtract(a).add(o),r.max._subtract(a).subtract(o));a.contains(s)||(o=m((Math.max(a.max.x,s.x)-a.max.x)/(r.max.x-a.max.x)-(Math.min(a.min.x,s.x)-a.min.x)/(r.min.x-a.min.x),(Math.max(a.max.y,s.y)-a.max.y)/(r.max.y-a.max.y)-(Math.min(a.min.y,s.y)-a.min.y)/(r.min.y-a.min.y)).multiplyBy(n),i.panBy(o,{animate:!1}),this._draggable._newPos._add(o),this._draggable._startPos._add(o),Z(e._icon,this._draggable._newPos),this._onDrag(t),this._panRequest=x(this._adjustPan.bind(this,t)))},_onDragStart:function(){this._oldLatLng=this._marker.getLatLng(),this._marker.closePopup&&this._marker.closePopup(),this._marker.fire("movestart").fire("dragstart")},_onPreDrag:function(t){this._marker.options.autoPan&&(r(this._panRequest),this._panRequest=x(this._adjustPan.bind(this,t)))},_onDrag:function(t){var e=this._marker,i=e._shadow,n=Pe(e._icon),o=e._map.layerPointToLatLng(n);i&&Z(i,n),e._latlng=o,t.latlng=o,t.oldLatLng=this._oldLatLng,e.fire("move",t).fire("drag",t)},_onDragEnd:function(t){r(this._panRequest),delete this._oldLatLng,this._marker.fire("moveend").fire("dragend",t)}}),mi=o.extend({options:{icon:new _i,interactive:!0,keyboard:!0,title:"",alt:"Marker",zIndexOffset:0,opacity:1,riseOnHover:!1,riseOffset:250,pane:"markerPane",shadowPane:"shadowPane",bubblingMouseEvents:!1,autoPanOnFocus:!0,draggable:!1,autoPan:!1,autoPanPadding:[50,50],autoPanSpeed:10},initialize:function(t,e){c(this,e),this._latlng=w(t)},onAdd:function(t){this._zoomAnimated=this._zoomAnimated&&t.options.markerZoomAnimation,this._zoomAnimated&&t.on("zoomanim",this._animateZoom,this),this._initIcon(),this.update()},onRemove:function(t){this.dragging&&this.dragging.enabled()&&(this.options.draggable=!0,this.dragging.removeHooks()),delete this.dragging,this._zoomAnimated&&t.off("zoomanim",this._animateZoom,this),this._removeIcon(),this._removeShadow()},getEvents:function(){return{zoom:this.update,viewreset:this.update}},getLatLng:function(){return this._latlng},setLatLng:function(t){var e=this._latlng;return this._latlng=w(t),this.update(),this.fire("move",{oldLatLng:e,latlng:this._latlng})},setZIndexOffset:function(t){return this.options.zIndexOffset=t,this.update()},getIcon:function(){return this.options.icon},setIcon:function(t){return this.options.icon=t,this._map&&(this._initIcon(),this.update()),this._popup&&this.bindPopup(this._popup,this._popup.options),this},getElement:function(){return this._icon},update:function(){var t;return this._icon&&this._map&&(t=this._map.latLngToLayerPoint(this._latlng).round(),this._setPos(t)),this},_initIcon:function(){var t=this.options,e="leaflet-zoom-"+(this._zoomAnimated?"animated":"hide"),i=t.icon.createIcon(this._icon),n=!1,i=(i!==this._icon&&(this._icon&&this._removeIcon(),n=!0,t.title&&(i.title=t.title),"IMG"===i.tagName&&(i.alt=t.alt||"")),M(i,e),t.keyboard&&(i.tabIndex="0",i.setAttribute("role","button")),this._icon=i,t.riseOnHover&&this.on({mouseover:this._bringToFront,mouseout:this._resetZIndex}),this.options.autoPanOnFocus&&S(i,"focus",this._panOnFocus,this),t.icon.createShadow(this._shadow)),o=!1;i!==this._shadow&&(this._removeShadow(),o=!0),i&&(M(i,e),i.alt=""),this._shadow=i,t.opacity<1&&this._updateOpacity(),n&&this.getPane().appendChild(this._icon),this._initInteraction(),i&&o&&this.getPane(t.shadowPane).appendChild(this._shadow)},_removeIcon:function(){this.options.riseOnHover&&this.off({mouseover:this._bringToFront,mouseout:this._resetZIndex}),this.options.autoPanOnFocus&&k(this._icon,"focus",this._panOnFocus,this),T(this._icon),this.removeInteractiveTarget(this._icon),this._icon=null},_removeShadow:function(){this._shadow&&T(this._shadow),this._shadow=null},_setPos:function(t){this._icon&&Z(this._icon,t),this._shadow&&Z(this._shadow,t),this._zIndex=t.y+this.options.zIndexOffset,this._resetZIndex()},_updateZIndex:function(t){this._icon&&(this._icon.style.zIndex=this._zIndex+t)},_animateZoom:function(t){t=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center).round();this._setPos(t)},_initInteraction:function(){var t;this.options.interactive&&(M(this._icon,"leaflet-interactive"),this.addInteractiveTarget(this._icon),pi&&(t=this.options.draggable,this.dragging&&(t=this.dragging.enabled(),this.dragging.disable()),this.dragging=new pi(this),t&&this.dragging.enable()))},setOpacity:function(t){return this.options.opacity=t,this._map&&this._updateOpacity(),this},_updateOpacity:function(){var t=this.options.opacity;this._icon&&C(this._icon,t),this._shadow&&C(this._shadow,t)},_bringToFront:function(){this._updateZIndex(this.options.riseOffset)},_resetZIndex:function(){this._updateZIndex(0)},_panOnFocus:function(){var t,e,i=this._map;i&&(t=(e=this.options.icon.options).iconSize?m(e.iconSize):m(0,0),e=e.iconAnchor?m(e.iconAnchor):m(0,0),i.panInside(this._latlng,{paddingTopLeft:e,paddingBottomRight:t.subtract(e)}))},_getPopupAnchor:function(){return this.options.icon.options.popupAnchor},_getTooltipAnchor:function(){return this.options.icon.options.tooltipAnchor}});var fi=o.extend({options:{stroke:!0,color:"#3388ff",weight:3,opacity:1,lineCap:"round",lineJoin:"round",dashArray:null,dashOffset:null,fill:!1,fillColor:null,fillOpacity:.2,fillRule:"evenodd",interactive:!0,bubblingMouseEvents:!0},beforeAdd:function(t){this._renderer=t.getRenderer(this)},onAdd:function(){this._renderer._initPath(this),this._reset(),this._renderer._addPath(this)},onRemove:function(){this._renderer._removePath(this)},redraw:function(){return this._map&&this._renderer._updatePath(this),this},setStyle:function(t){return c(this,t),this._renderer&&(this._renderer._updateStyle(this),this.options.stroke&&t&&Object.prototype.hasOwnProperty.call(t,"weight")&&this._updateBounds()),this},bringToFront:function(){return this._renderer&&this._renderer._bringToFront(this),this},bringToBack:function(){return this._renderer&&this._renderer._bringToBack(this),this},getElement:function(){return this._path},_reset:function(){this._project(),this._update()},_clickTolerance:function(){return(this.options.stroke?this.options.weight/2:0)+(this._renderer.options.tolerance||0)}}),gi=fi.extend({options:{fill:!0,radius:10},initialize:function(t,e){c(this,e),this._latlng=w(t),this._radius=this.options.radius},setLatLng:function(t){var e=this._latlng;return this._latlng=w(t),this.redraw(),this.fire("move",{oldLatLng:e,latlng:this._latlng})},getLatLng:function(){return this._latlng},setRadius:function(t){return this.options.radius=this._radius=t,this.redraw()},getRadius:function(){return this._radius},setStyle:function(t){var e=t&&t.radius||this._radius;return fi.prototype.setStyle.call(this,t),this.setRadius(e),this},_project:function(){this._point=this._map.latLngToLayerPoint(this._latlng),this._updateBounds()},_updateBounds:function(){var t=this._radius,e=this._radiusY||t,i=this._clickTolerance(),t=[t+i,e+i];this._pxBounds=new f(this._point.subtract(t),this._point.add(t))},_update:function(){this._map&&this._updatePath()},_updatePath:function(){this._renderer._updateCircle(this)},_empty:function(){return this._radius&&!this._renderer._bounds.intersects(this._pxBounds)},_containsPoint:function(t){return t.distanceTo(this._point)<=this._radius+this._clickTolerance()}});var vi=gi.extend({initialize:function(t,e,i){if(c(this,e="number"==typeof e?l({},i,{radius:e}):e),this._latlng=w(t),isNaN(this.options.radius))throw new Error("Circle radius cannot be NaN");this._mRadius=this.options.radius},setRadius:function(t){return this._mRadius=t,this.redraw()},getRadius:function(){return this._mRadius},getBounds:function(){var t=[this._radius,this._radiusY||this._radius];return new s(this._map.layerPointToLatLng(this._point.subtract(t)),this._map.layerPointToLatLng(this._point.add(t)))},setStyle:fi.prototype.setStyle,_project:function(){var t,e,i,n,o,s=this._latlng.lng,r=this._latlng.lat,a=this._map,h=a.options.crs;h.distance===st.distance?(n=Math.PI/180,o=this._mRadius/st.R/n,t=a.project([r+o,s]),e=a.project([r-o,s]),e=t.add(e).divideBy(2),i=a.unproject(e).lat,n=Math.acos((Math.cos(o*n)-Math.sin(r*n)*Math.sin(i*n))/(Math.cos(r*n)*Math.cos(i*n)))/n,!isNaN(n)&&0!==n||(n=o/Math.cos(Math.PI/180*r)),this._point=e.subtract(a.getPixelOrigin()),this._radius=isNaN(n)?0:e.x-a.project([i,s-n]).x,this._radiusY=e.y-t.y):(o=h.unproject(h.project(this._latlng).subtract([this._mRadius,0])),this._point=a.latLngToLayerPoint(this._latlng),this._radius=this._point.x-a.latLngToLayerPoint(o).x),this._updateBounds()}});var yi=fi.extend({options:{smoothFactor:1,noClip:!1},initialize:function(t,e){c(this,e),this._setLatLngs(t)},getLatLngs:function(){return this._latlngs},setLatLngs:function(t){return this._setLatLngs(t),this.redraw()},isEmpty:function(){return!this._latlngs.length},closestLayerPoint:function(t){for(var e=1/0,i=null,n=ri,o=0,s=this._parts.length;o<s;o++)for(var r=this._parts[o],a=1,h=r.length;a<h;a++){var l,u,c=n(t,l=r[a-1],u=r[a],!0);c<e&&(e=c,i=n(t,l,u))}return i&&(i.distance=Math.sqrt(e)),i},getCenter:function(){if(this._map)return hi(this._defaultShape(),this._map.options.crs);throw new Error("Must add layer to map before using getCenter()")},getBounds:function(){return this._bounds},addLatLng:function(t,e){return e=e||this._defaultShape(),t=w(t),e.push(t),this._bounds.extend(t),this.redraw()},_setLatLngs:function(t){this._bounds=new s,this._latlngs=this._convertLatLngs(t)},_defaultShape:function(){return I(this._latlngs)?this._latlngs:this._latlngs[0]},_convertLatLngs:function(t){for(var e=[],i=I(t),n=0,o=t.length;n<o;n++)i?(e[n]=w(t[n]),this._bounds.extend(e[n])):e[n]=this._convertLatLngs(t[n]);return e},_project:function(){var t=new f;this._rings=[],this._projectLatlngs(this._latlngs,this._rings,t),this._bounds.isValid()&&t.isValid()&&(this._rawPxBounds=t,this._updateBounds())},_updateBounds:function(){var t=this._clickTolerance(),t=new p(t,t);this._rawPxBounds&&(this._pxBounds=new f([this._rawPxBounds.min.subtract(t),this._rawPxBounds.max.add(t)]))},_projectLatlngs:function(t,e,i){var n,o,s=t[0]instanceof v,r=t.length;if(s){for(o=[],n=0;n<r;n++)o[n]=this._map.latLngToLayerPoint(t[n]),i.extend(o[n]);e.push(o)}else for(n=0;n<r;n++)this._projectLatlngs(t[n],e,i)},_clipPoints:function(){var t=this._renderer._bounds;if(this._parts=[],this._pxBounds&&this._pxBounds.intersects(t))if(this.options.noClip)this._parts=this._rings;else for(var e,i,n,o,s=this._parts,r=0,a=0,h=this._rings.length;r<h;r++)for(e=0,i=(o=this._rings[r]).length;e<i-1;e++)(n=ni(o[e],o[e+1],t,e,!0))&&(s[a]=s[a]||[],s[a].push(n[0]),n[1]===o[e+1]&&e!==i-2||(s[a].push(n[1]),a++))},_simplifyPoints:function(){for(var t=this._parts,e=this.options.smoothFactor,i=0,n=t.length;i<n;i++)t[i]=ei(t[i],e)},_update:function(){this._map&&(this._clipPoints(),this._simplifyPoints(),this._updatePath())},_updatePath:function(){this._renderer._updatePoly(this)},_containsPoint:function(t,e){var i,n,o,s,r,a,h=this._clickTolerance();if(this._pxBounds&&this._pxBounds.contains(t))for(i=0,s=this._parts.length;i<s;i++)for(n=0,o=(r=(a=this._parts[i]).length)-1;n<r;o=n++)if((e||0!==n)&&ii(t,a[o],a[n])<=h)return!0;return!1}});yi._flat=ai;var xi=yi.extend({options:{fill:!0},isEmpty:function(){return!this._latlngs.length||!this._latlngs[0].length},getCenter:function(){if(this._map)return $e(this._defaultShape(),this._map.options.crs);throw new Error("Must add layer to map before using getCenter()")},_convertLatLngs:function(t){var t=yi.prototype._convertLatLngs.call(this,t),e=t.length;return 2<=e&&t[0]instanceof v&&t[0].equals(t[e-1])&&t.pop(),t},_setLatLngs:function(t){yi.prototype._setLatLngs.call(this,t),I(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return(I(this._latlngs[0])?this._latlngs:this._latlngs[0])[0]},_clipPoints:function(){var t=this._renderer._bounds,e=this.options.weight,e=new p(e,e),t=new f(t.min.subtract(e),t.max.add(e));if(this._parts=[],this._pxBounds&&this._pxBounds.intersects(t))if(this.options.noClip)this._parts=this._rings;else for(var i,n=0,o=this._rings.length;n<o;n++)(i=Je(this._rings[n],t,!0)).length&&this._parts.push(i)},_updatePath:function(){this._renderer._updatePoly(this,!0)},_containsPoint:function(t){var e,i,n,o,s,r,a,h,l=!1;if(!this._pxBounds||!this._pxBounds.contains(t))return!1;for(o=0,a=this._parts.length;o<a;o++)for(s=0,r=(h=(e=this._parts[o]).length)-1;s<h;r=s++)i=e[s],n=e[r],i.y>t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;e<i;e++)((n=o[e]).geometries||n.geometry||n.features||n.coordinates)&&this.addData(n);return this}var s,r=this.options;return(!r.filter||r.filter(t))&&(s=bi(t,r))?(s.feature=Zi(t),s.defaultOptions=s.options,this.resetStyle(s),r.onEachFeature&&r.onEachFeature(t,s),this.addLayer(s)):this},resetStyle:function(t){return void 0===t?this.eachLayer(this.resetStyle,this):(t.options=l({},t.defaultOptions),this._setLayerStyle(t,this.options.style),this)},setStyle:function(e){return this.eachLayer(function(t){this._setLayerStyle(t,e)},this)},_setLayerStyle:function(t,e){t.setStyle&&("function"==typeof e&&(e=e(t.feature)),t.setStyle(e))}});function bi(t,e){var i,n,o,s,r="Feature"===t.type?t.geometry:t,a=r?r.coordinates:null,h=[],l=e&&e.pointToLayer,u=e&&e.coordsToLatLng||Li;if(!a&&!r)return null;switch(r.type){case"Point":return Pi(l,t,i=u(a),e);case"MultiPoint":for(o=0,s=a.length;o<s;o++)i=u(a[o]),h.push(Pi(l,t,i,e));return new ci(h);case"LineString":case"MultiLineString":return n=Ti(a,"LineString"===r.type?0:1,u),new yi(n,e);case"Polygon":case"MultiPolygon":return n=Ti(a,"Polygon"===r.type?1:2,u),new xi(n,e);case"GeometryCollection":for(o=0,s=r.geometries.length;o<s;o++){var c=bi({geometry:r.geometries[o],type:"Feature",properties:t.properties},e);c&&h.push(c)}return new ci(h);case"FeatureCollection":for(o=0,s=r.features.length;o<s;o++){var d=bi(r.features[o],e);d&&h.push(d)}return new ci(h);default:throw new Error("Invalid GeoJSON object.")}}function Pi(t,e,i,n){return t?t(e,i):new mi(i,n&&n.markersInheritOptions&&n)}function Li(t){return new v(t[1],t[0],t[2])}function Ti(t,e,i){for(var n,o=[],s=0,r=t.length;s<r;s++)n=e?Ti(t[s],e-1,i):(i||Li)(t[s]),o.push(n);return o}function Mi(t,e){return void 0!==(t=w(t)).alt?[i(t.lng,e),i(t.lat,e),i(t.alt,e)]:[i(t.lng,e),i(t.lat,e)]}function zi(t,e,i,n){for(var o=[],s=0,r=t.length;s<r;s++)o.push(e?zi(t[s],I(t[s])?0:e-1,i,n):Mi(t[s],n));return!e&&i&&0<o.length&&o.push(o[0].slice()),o}function Ci(t,e){return t.feature?l({},t.feature,{geometry:e}):Zi(e)}function Zi(t){return"Feature"===t.type||"FeatureCollection"===t.type?t:{type:"Feature",properties:{},geometry:t}}Tt={toGeoJSON:function(t){return Ci(this,{type:"Point",coordinates:Mi(this.getLatLng(),t)})}};function Si(t,e){return new wi(t,e)}mi.include(Tt),vi.include(Tt),gi.include(Tt),yi.include({toGeoJSON:function(t){var e=!I(this._latlngs);return Ci(this,{type:(e?"Multi":"")+"LineString",coordinates:zi(this._latlngs,e?1:0,!1,t)})}}),xi.include({toGeoJSON:function(t){var e=!I(this._latlngs),i=e&&!I(this._latlngs[0]),t=zi(this._latlngs,i?2:e?1:0,!0,t);return Ci(this,{type:(i?"Multi":"")+"Polygon",coordinates:t=e?t:[t]})}}),ui.include({toMultiPoint:function(e){var i=[];return this.eachLayer(function(t){i.push(t.toGeoJSON(e).geometry.coordinates)}),Ci(this,{type:"MultiPoint",coordinates:i})},toGeoJSON:function(e){var i,n,t=this.feature&&this.feature.geometry&&this.feature.geometry.type;return"MultiPoint"===t?this.toMultiPoint(e):(i="GeometryCollection"===t,n=[],this.eachLayer(function(t){t.toGeoJSON&&(t=t.toGeoJSON(e),i?n.push(t.geometry):"FeatureCollection"===(t=Zi(t)).type?n.push.apply(n,t.features):n.push(t))}),i?Ci(this,{geometries:n,type:"GeometryCollection"}):{type:"FeatureCollection",features:n})}});var Mt=Si,Ei=o.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1,errorOverlayUrl:"",zIndex:1,className:""},initialize:function(t,e,i){this._url=t,this._bounds=g(e),c(this,i)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(M(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){T(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(t){return this.options.opacity=t,this._image&&this._updateOpacity(),this},setStyle:function(t){return t.opacity&&this.setOpacity(t.opacity),this},bringToFront:function(){return this._map&&fe(this._image),this},bringToBack:function(){return this._map&&ge(this._image),this},setUrl:function(t){return this._url=t,this._image&&(this._image.src=t),this},setBounds:function(t){return this._bounds=g(t),this._map&&this._reset(),this},getEvents:function(){var t={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var t="IMG"===this._url.tagName,e=this._image=t?this._url:P("img");M(e,"leaflet-image-layer"),this._zoomAnimated&&M(e,"leaflet-zoom-animated"),this.options.className&&M(e,this.options.className),e.onselectstart=u,e.onmousemove=u,e.onload=a(this.fire,this,"load"),e.onerror=a(this._overlayOnError,this,"error"),!this.options.crossOrigin&&""!==this.options.crossOrigin||(e.crossOrigin=!0===this.options.crossOrigin?"":this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),t?this._url=e.src:(e.src=this._url,e.alt=this.options.alt)},_animateZoom:function(t){var e=this._map.getZoomScale(t.zoom),t=this._map._latLngBoundsToNewLayerBounds(this._bounds,t.zoom,t.center).min;be(this._image,t,e)},_reset:function(){var t=this._image,e=new f(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),i=e.getSize();Z(t,e.min),t.style.width=i.x+"px",t.style.height=i.y+"px"},_updateOpacity:function(){C(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&void 0!==this.options.zIndex&&null!==this.options.zIndex&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire("error");var t=this.options.errorOverlayUrl;t&&this._url!==t&&(this._url=t,this._image.src=t)},getCenter:function(){return this._bounds.getCenter()}}),ki=Ei.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var t="VIDEO"===this._url.tagName,e=this._image=t?this._url:P("video");if(M(e,"leaflet-image-layer"),this._zoomAnimated&&M(e,"leaflet-zoom-animated"),this.options.className&&M(e,this.options.className),e.onselectstart=u,e.onmousemove=u,e.onloadeddata=a(this.fire,this,"load"),t){for(var i=e.getElementsByTagName("source"),n=[],o=0;o<i.length;o++)n.push(i[o].src);this._url=0<i.length?n:[e.src]}else{d(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(e.style,"objectFit")&&(e.style.objectFit="fill"),e.autoplay=!!this.options.autoplay,e.loop=!!this.options.loop,e.muted=!!this.options.muted,e.playsInline=!!this.options.playsInline;for(var s=0;s<this._url.length;s++){var r=P("source");r.src=this._url[s],e.appendChild(r)}}}});var Oi=Ei.extend({_initImage:function(){var t=this._image=this._url;M(t,"leaflet-image-layer"),this._zoomAnimated&&M(t,"leaflet-zoom-animated"),this.options.className&&M(t,this.options.className),t.onselectstart=u,t.onmousemove=u}});var Ai=o.extend({options:{interactive:!1,offset:[0,0],className:"",pane:void 0,content:""},initialize:function(t,e){t&&(t instanceof v||d(t))?(this._latlng=w(t),c(this,e)):(c(this,t),this._source=e),this.options.content&&(this._content=this.options.content)},openOn:function(t){return(t=arguments.length?t:this._source._map).hasLayer(this)||t.addLayer(this),this},close:function(){return this._map&&this._map.removeLayer(this),this},toggle:function(t){return this._map?this.close():(arguments.length?this._source=t:t=this._source,this._prepareOpen(),this.openOn(t._map)),this},onAdd:function(t){this._zoomAnimated=t._zoomAnimated,this._container||this._initLayout(),t._fadeAnimated&&C(this._container,0),clearTimeout(this._removeTimeout),this.getPane().appendChild(this._container),this.update(),t._fadeAnimated&&C(this._container,1),this.bringToFront(),this.options.interactive&&(M(this._container,"leaflet-interactive"),this.addInteractiveTarget(this._container))},onRemove:function(t){t._fadeAnimated?(C(this._container,0),this._removeTimeout=setTimeout(a(T,void 0,this._container),200)):T(this._container),this.options.interactive&&(z(this._container,"leaflet-interactive"),this.removeInteractiveTarget(this._container))},getLatLng:function(){return this._latlng},setLatLng:function(t){return this._latlng=w(t),this._map&&(this._updatePosition(),this._adjustPan()),this},getContent:function(){return this._content},setContent:function(t){return this._content=t,this.update(),this},getElement:function(){return this._container},update:function(){this._map&&(this._container.style.visibility="hidden",this._updateContent(),this._updateLayout(),this._updatePosition(),this._container.style.visibility="",this._adjustPan())},getEvents:function(){var t={zoom:this._updatePosition,viewreset:this._updatePosition};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},isOpen:function(){return!!this._map&&this._map.hasLayer(this)},bringToFront:function(){return this._map&&fe(this._container),this},bringToBack:function(){return this._map&&ge(this._container),this},_prepareOpen:function(t){if(!(i=this._source)._map)return!1;if(i instanceof ci){var e,i=null,n=this._source._layers;for(e in n)if(n[e]._map){i=n[e];break}if(!i)return!1;this._source=i}if(!t)if(i.getCenter)t=i.getCenter();else if(i.getLatLng)t=i.getLatLng();else{if(!i.getBounds)throw new Error("Unable to get source layer LatLng.");t=i.getBounds().getCenter()}return this.setLatLng(t),this._map&&this.update(),!0},_updateContent:function(){if(this._content){var t=this._contentNode,e="function"==typeof this._content?this._content(this._source||this):this._content;if("string"==typeof e)t.innerHTML=e;else{for(;t.hasChildNodes();)t.removeChild(t.firstChild);t.appendChild(e)}this.fire("contentupdate")}},_updatePosition:function(){var t,e,i;this._map&&(e=this._map.latLngToLayerPoint(this._latlng),t=m(this.options.offset),i=this._getAnchor(),this._zoomAnimated?Z(this._container,e.add(i)):t=t.add(e).add(i),e=this._containerBottom=-t.y,i=this._containerLeft=-Math.round(this._containerWidth/2)+t.x,this._container.style.bottom=e+"px",this._container.style.left=i+"px")},_getAnchor:function(){return[0,0]}}),Bi=(A.include({_initOverlay:function(t,e,i,n){var o=e;return o instanceof t||(o=new t(n).setContent(e)),i&&o.setLatLng(i),o}}),o.include({_initOverlay:function(t,e,i,n){var o=i;return o instanceof t?(c(o,n),o._source=this):(o=e&&!n?e:new t(n,this)).setContent(i),o}}),Ai.extend({options:{pane:"popupPane",offset:[0,7],maxWidth:300,minWidth:50,maxHeight:null,autoPan:!0,autoPanPaddingTopLeft:null,autoPanPaddingBottomRight:null,autoPanPadding:[5,5],keepInView:!1,closeButton:!0,autoClose:!0,closeOnEscapeKey:!0,className:""},openOn:function(t){return!(t=arguments.length?t:this._source._map).hasLayer(this)&&t._popup&&t._popup.options.autoClose&&t.removeLayer(t._popup),t._popup=this,Ai.prototype.openOn.call(this,t)},onAdd:function(t){Ai.prototype.onAdd.call(this,t),t.fire("popupopen",{popup:this}),this._source&&(this._source.fire("popupopen",{popup:this},!0),this._source instanceof fi||this._source.on("preclick",Ae))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("popupclose",{popup:this}),this._source&&(this._source.fire("popupclose",{popup:this},!0),this._source instanceof fi||this._source.off("preclick",Ae))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return(void 0!==this.options.closeOnClick?this.options.closeOnClick:this._map.options.closePopupOnClick)&&(t.preclick=this.close),this.options.keepInView&&(t.moveend=this._adjustPan),t},_initLayout:function(){var t="leaflet-popup",e=this._container=P("div",t+" "+(this.options.className||"")+" leaflet-zoom-animated"),i=this._wrapper=P("div",t+"-content-wrapper",e);this._contentNode=P("div",t+"-content",i),Ie(e),Be(this._contentNode),S(e,"contextmenu",Ae),this._tipContainer=P("div",t+"-tip-container",e),this._tip=P("div",t+"-tip",this._tipContainer),this.options.closeButton&&((i=this._closeButton=P("a",t+"-close-button",e)).setAttribute("role","button"),i.setAttribute("aria-label","Close popup"),i.href="#close",i.innerHTML='<span aria-hidden="true">×</span>',S(i,"click",function(t){O(t),this.close()},this))},_updateLayout:function(){var t=this._contentNode,e=t.style,i=(e.width="",e.whiteSpace="nowrap",t.offsetWidth),i=Math.min(i,this.options.maxWidth),i=(i=Math.max(i,this.options.minWidth),e.width=i+1+"px",e.whiteSpace="",e.height="",t.offsetHeight),n=this.options.maxHeight,o="leaflet-popup-scrolled";(n&&n<i?(e.height=n+"px",M):z)(t,o),this._containerWidth=this._container.offsetWidth},_animateZoom:function(t){var t=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center),e=this._getAnchor();Z(this._container,t.add(e))},_adjustPan:function(){var t,e,i,n,o,s,r,a;this.options.autoPan&&(this._map._panAnim&&this._map._panAnim.stop(),this._autopanning?this._autopanning=!1:(t=this._map,e=parseInt(pe(this._container,"marginBottom"),10)||0,e=this._container.offsetHeight+e,a=this._containerWidth,(i=new p(this._containerLeft,-e-this._containerBottom))._add(Pe(this._container)),i=t.layerPointToContainerPoint(i),o=m(this.options.autoPanPadding),n=m(this.options.autoPanPaddingTopLeft||o),o=m(this.options.autoPanPaddingBottomRight||o),s=t.getSize(),r=0,i.x+a+o.x>s.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.x<o.x?(s="right",0):(s="left",r+2*(h.x+l.x)),a/2);t=t.subtract(m(e,i,!0)).add(h).add(l),z(n,"leaflet-tooltip-right"),z(n,"leaflet-tooltip-left"),z(n,"leaflet-tooltip-top"),z(n,"leaflet-tooltip-bottom"),M(n,"leaflet-tooltip-"+s),Z(n,t)},_updatePosition:function(){var t=this._map.latLngToLayerPoint(this._latlng);this._setPosition(t)},setOpacity:function(t){this.options.opacity=t,this._container&&C(this._container,t)},_animateZoom:function(t){t=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center);this._setPosition(t)},_getAnchor:function(){return m(this._source&&this._source._getTooltipAnchor&&!this.options.sticky?this._source._getTooltipAnchor():[0,0])}})),Ri=(A.include({openTooltip:function(t,e,i){return this._initOverlay(Ii,t,e,i).openOn(this),this},closeTooltip:function(t){return t.close(),this}}),o.include({bindTooltip:function(t,e){return this._tooltip&&this.isTooltipOpen()&&this.unbindTooltip(),this._tooltip=this._initOverlay(Ii,this._tooltip,t,e),this._initTooltipInteractions(),this._tooltip.options.permanent&&this._map&&this._map.hasLayer(this)&&this.openTooltip(),this},unbindTooltip:function(){return this._tooltip&&(this._initTooltipInteractions(!0),this.closeTooltip(),this._tooltip=null),this},_initTooltipInteractions:function(t){var e,i;!t&&this._tooltipHandlersAdded||(e=t?"off":"on",i={remove:this.closeTooltip,move:this._moveTooltip},this._tooltip.options.permanent?i.add=this._openTooltip:(i.mouseover=this._openTooltip,i.mouseout=this.closeTooltip,i.click=this._openTooltip,this._map?this._addFocusListeners():i.add=this._addFocusListeners),this._tooltip.options.sticky&&(i.mousemove=this._moveTooltip),this[e](i),this._tooltipHandlersAdded=!t)},openTooltip:function(t){return this._tooltip&&(this instanceof ci||(this._tooltip._source=this),this._tooltip._prepareOpen(t)&&(this._tooltip.openOn(this._map),this.getElement?this._setAriaDescribedByOnLayer(this):this.eachLayer&&this.eachLayer(this._setAriaDescribedByOnLayer,this))),this},closeTooltip:function(){if(this._tooltip)return this._tooltip.close()},toggleTooltip:function(){return this._tooltip&&this._tooltip.toggle(this),this},isTooltipOpen:function(){return this._tooltip.isOpen()},setTooltipContent:function(t){return this._tooltip&&this._tooltip.setContent(t),this},getTooltip:function(){return this._tooltip},_addFocusListeners:function(){this.getElement?this._addFocusListenersOnLayer(this):this.eachLayer&&this.eachLayer(this._addFocusListenersOnLayer,this)},_addFocusListenersOnLayer:function(t){var e="function"==typeof t.getElement&&t.getElement();e&&(S(e,"focus",function(){this._tooltip._source=t,this.openTooltip()},this),S(e,"blur",this.closeTooltip,this))},_setAriaDescribedByOnLayer:function(t){t="function"==typeof t.getElement&&t.getElement();t&&t.setAttribute("aria-describedby",this._tooltip._container.id)},_openTooltip:function(t){var e;this._tooltip&&this._map&&(this._map.dragging&&this._map.dragging.moving()&&!this._openOnceFlag?(this._openOnceFlag=!0,(e=this)._map.once("moveend",function(){e._openOnceFlag=!1,e._openTooltip(t)})):(this._tooltip._source=t.layer||t.target,this.openTooltip(this._tooltip.options.sticky?t.latlng:void 0)))},_moveTooltip:function(t){var e=t.latlng;this._tooltip.options.sticky&&t.originalEvent&&(t=this._map.mouseEventToContainerPoint(t.originalEvent),t=this._map.containerPointToLayerPoint(t),e=this._map.layerPointToLatLng(t)),this._tooltip.setLatLng(e)}}),di.extend({options:{iconSize:[12,12],html:!1,bgPos:null,className:"leaflet-div-icon"},createIcon:function(t){var t=t&&"DIV"===t.tagName?t:document.createElement("div"),e=this.options;return e.html instanceof Element?(me(t),t.appendChild(e.html)):t.innerHTML=!1!==e.html?e.html:"",e.bgPos&&(e=m(e.bgPos),t.style.backgroundPosition=-e.x+"px "+-e.y+"px"),this._setIconStyles(t,"icon"),t},createShadow:function(){return null}}));di.Default=_i;var Ni=o.extend({options:{tileSize:256,opacity:1,updateWhenIdle:b.mobile,updateWhenZooming:!0,updateInterval:200,zIndex:1,bounds:null,minZoom:0,maxZoom:void 0,maxNativeZoom:void 0,minNativeZoom:void 0,noWrap:!1,pane:"tilePane",className:"",keepBuffer:2},initialize:function(t){c(this,t)},onAdd:function(){this._initContainer(),this._levels={},this._tiles={},this._resetView()},beforeAdd:function(t){t._addZoomLimit(this)},onRemove:function(t){this._removeAllTiles(),T(this._container),t._removeZoomLimit(this),this._container=null,this._tileZoom=void 0},bringToFront:function(){return this._map&&(fe(this._container),this._setAutoZIndex(Math.max)),this},bringToBack:function(){return this._map&&(ge(this._container),this._setAutoZIndex(Math.min)),this},getContainer:function(){return this._container},setOpacity:function(t){return this.options.opacity=t,this._updateOpacity(),this},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},isLoading:function(){return this._loading},redraw:function(){var t;return this._map&&(this._removeAllTiles(),(t=this._clampZoom(this._map.getZoom()))!==this._tileZoom&&(this._tileZoom=t,this._updateLevels()),this._update()),this},getEvents:function(){var t={viewprereset:this._invalidateAll,viewreset:this._resetView,zoom:this._resetView,moveend:this._onMoveEnd};return this.options.updateWhenIdle||(this._onMove||(this._onMove=j(this._onMoveEnd,this.options.updateInterval,this)),t.move=this._onMove),this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},createTile:function(){return document.createElement("div")},getTileSize:function(){var t=this.options.tileSize;return t instanceof p?t:new p(t,t)},_updateZIndex:function(){this._container&&void 0!==this.options.zIndex&&null!==this.options.zIndex&&(this._container.style.zIndex=this.options.zIndex)},_setAutoZIndex:function(t){for(var e,i=this.getPane().children,n=-t(-1/0,1/0),o=0,s=i.length;o<s;o++)e=i[o].style.zIndex,i[o]!==this._container&&e&&(n=t(n,+e));isFinite(n)&&(this.options.zIndex=n+t(-1,1),this._updateZIndex())},_updateOpacity:function(){if(this._map&&!b.ielt9){C(this._container,this.options.opacity);var t,e=+new Date,i=!1,n=!1;for(t in this._tiles){var o,s=this._tiles[t];s.current&&s.loaded&&(o=Math.min(1,(e-s.loaded)/200),C(s.el,o),o<1?i=!0:(s.active?n=!0:this._onOpaqueTile(s),s.active=!0))}n&&!this._noPrune&&this._pruneTiles(),i&&(r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this))}},_onOpaqueTile:u,_initContainer:function(){this._container||(this._container=P("div","leaflet-layer "+(this.options.className||"")),this._updateZIndex(),this.options.opacity<1&&this._updateOpacity(),this.getPane().appendChild(this._container))},_updateLevels:function(){var t=this._tileZoom,e=this.options.maxZoom;if(void 0!==t){for(var i in this._levels)i=Number(i),this._levels[i].el.children.length||i===t?(this._levels[i].el.style.zIndex=e-Math.abs(t-i),this._onUpdateLevel(i)):(T(this._levels[i].el),this._removeTilesAtZoom(i),this._onRemoveLevel(i),delete this._levels[i]);var n=this._levels[t],o=this._map;return n||((n=this._levels[t]={}).el=P("div","leaflet-tile-container leaflet-zoom-animated",this._container),n.el.style.zIndex=e,n.origin=o.project(o.unproject(o.getPixelOrigin()),t).round(),n.zoom=t,this._setZoomTransform(n,o.getCenter(),o.getZoom()),u(n.el.offsetWidth),this._onCreateLevel(n)),this._level=n}},_onUpdateLevel:u,_onRemoveLevel:u,_onCreateLevel:u,_pruneTiles:function(){if(this._map){var t,e,i,n=this._map.getZoom();if(n>this.options.maxZoom||n<this.options.minZoom)this._removeAllTiles();else{for(t in this._tiles)(i=this._tiles[t]).retain=i.current;for(t in this._tiles)(i=this._tiles[t]).current&&!i.active&&(e=i.coords,this._retainParent(e.x,e.y,e.z,e.z-5)||this._retainChildren(e.x,e.y,e.z,e.z+2));for(t in this._tiles)this._tiles[t].retain||this._removeTile(t)}}},_removeTilesAtZoom:function(t){for(var e in this._tiles)this._tiles[e].coords.z===t&&this._removeTile(e)},_removeAllTiles:function(){for(var t in this._tiles)this._removeTile(t)},_invalidateAll:function(){for(var t in this._levels)T(this._levels[t].el),this._onRemoveLevel(Number(t)),delete this._levels[t];this._removeAllTiles(),this._tileZoom=void 0},_retainParent:function(t,e,i,n){var t=Math.floor(t/2),e=Math.floor(e/2),i=i-1,o=new p(+t,+e),o=(o.z=i,this._tileCoordsToKey(o)),o=this._tiles[o];return o&&o.active?o.retain=!0:(o&&o.loaded&&(o.retain=!0),n<i&&this._retainParent(t,e,i,n))},_retainChildren:function(t,e,i,n){for(var o=2*t;o<2*t+2;o++)for(var s=2*e;s<2*e+2;s++){var r=new p(o,s),r=(r.z=i+1,this._tileCoordsToKey(r)),r=this._tiles[r];r&&r.active?r.retain=!0:(r&&r.loaded&&(r.retain=!0),i+1<n&&this._retainChildren(o,s,i+1,n))}},_resetView:function(t){t=t&&(t.pinch||t.flyTo);this._setView(this._map.getCenter(),this._map.getZoom(),t,t)},_animateZoom:function(t){this._setView(t.center,t.zoom,!0,t.noUpdate)},_clampZoom:function(t){var e=this.options;return void 0!==e.minNativeZoom&&t<e.minNativeZoom?e.minNativeZoom:void 0!==e.maxNativeZoom&&e.maxNativeZoom<t?e.maxNativeZoom:t},_setView:function(t,e,i,n){var o=Math.round(e),o=void 0!==this.options.maxZoom&&o>this.options.maxZoom||void 0!==this.options.minZoom&&o<this.options.minZoom?void 0:this._clampZoom(o),s=this.options.updateWhenZooming&&o!==this._tileZoom;n&&!s||(this._tileZoom=o,this._abortLoading&&this._abortLoading(),this._updateLevels(),this._resetGrid(),void 0!==o&&this._update(t),i||this._pruneTiles(),this._noPrune=!!i),this._setZoomTransforms(t,e)},_setZoomTransforms:function(t,e){for(var i in this._levels)this._setZoomTransform(this._levels[i],t,e)},_setZoomTransform:function(t,e,i){var n=this._map.getZoomScale(i,t.zoom),e=t.origin.multiplyBy(n).subtract(this._map._getNewPixelOrigin(e,i)).round();b.any3d?be(t.el,e,n):Z(t.el,e)},_resetGrid:function(){var t=this._map,e=t.options.crs,i=this._tileSize=this.getTileSize(),n=this._tileZoom,o=this._map.getPixelWorldBounds(this._tileZoom);o&&(this._globalTileRange=this._pxBoundsToTileRange(o)),this._wrapX=e.wrapLng&&!this.options.noWrap&&[Math.floor(t.project([0,e.wrapLng[0]],n).x/i.x),Math.ceil(t.project([0,e.wrapLng[1]],n).x/i.y)],this._wrapY=e.wrapLat&&!this.options.noWrap&&[Math.floor(t.project([e.wrapLat[0],0],n).y/i.x),Math.ceil(t.project([e.wrapLat[1],0],n).y/i.y)]},_onMoveEnd:function(){this._map&&!this._map._animatingZoom&&this._update()},_getTiledPixelBounds:function(t){var e=this._map,i=e._animatingZoom?Math.max(e._animateToZoom,e.getZoom()):e.getZoom(),i=e.getZoomScale(i,this._tileZoom),t=e.project(t,this._tileZoom).floor(),e=e.getSize().divideBy(2*i);return new f(t.subtract(e),t.add(e))},_update:function(t){var e=this._map;if(e){var i=this._clampZoom(e.getZoom());if(void 0===t&&(t=e.getCenter()),void 0!==this._tileZoom){var n,e=this._getTiledPixelBounds(t),o=this._pxBoundsToTileRange(e),s=o.getCenter(),r=[],e=this.options.keepBuffer,a=new f(o.getBottomLeft().subtract([e,-e]),o.getTopRight().add([e,-e]));if(!(isFinite(o.min.x)&&isFinite(o.min.y)&&isFinite(o.max.x)&&isFinite(o.max.y)))throw new Error("Attempted to load an infinite number of tiles");for(n in this._tiles){var h=this._tiles[n].coords;h.z===this._tileZoom&&a.contains(new p(h.x,h.y))||(this._tiles[n].current=!1)}if(1<Math.abs(i-this._tileZoom))this._setView(t,i);else{for(var l=o.min.y;l<=o.max.y;l++)for(var u=o.min.x;u<=o.max.x;u++){var c,d=new p(u,l);d.z=this._tileZoom,this._isValidTile(d)&&((c=this._tiles[this._tileCoordsToKey(d)])?c.current=!0:r.push(d))}if(r.sort(function(t,e){return t.distanceTo(s)-e.distanceTo(s)}),0!==r.length){this._loading||(this._loading=!0,this.fire("loading"));for(var _=document.createDocumentFragment(),u=0;u<r.length;u++)this._addTile(r[u],_);this._level.el.appendChild(_)}}}}},_isValidTile:function(t){var e=this._map.options.crs;if(!e.infinite){var i=this._globalTileRange;if(!e.wrapLng&&(t.x<i.min.x||t.x>i.max.x)||!e.wrapLat&&(t.y<i.min.y||t.y>i.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0<e.maxZoom?(e.tileSize=Math.floor(e.tileSize/2),e.zoomReverse?(e.zoomOffset--,e.minZoom=Math.min(e.maxZoom,e.minZoom+1)):(e.zoomOffset++,e.maxZoom=Math.max(e.minZoom,e.maxZoom-1)),e.minZoom=Math.max(0,e.minZoom)):e.zoomReverse?e.minZoom=Math.min(e.maxZoom,e.minZoom):e.maxZoom=Math.max(e.minZoom,e.maxZoom),"string"==typeof e.subdomains&&(e.subdomains=e.subdomains.split("")),this.on("tileunload",this._onTileRemove)},setUrl:function(t,e){return this._url===t&&void 0===e&&(e=!0),this._url=t,e||this.redraw(),this},createTile:function(t,e){var i=document.createElement("img");return S(i,"load",a(this._tileOnLoad,this,e,i)),S(i,"error",a(this._tileOnError,this,e,i)),!this.options.crossOrigin&&""!==this.options.crossOrigin||(i.crossOrigin=!0===this.options.crossOrigin?"":this.options.crossOrigin),"string"==typeof this.options.referrerPolicy&&(i.referrerPolicy=this.options.referrerPolicy),i.alt="",i.src=this.getTileUrl(t),i},getTileUrl:function(t){var e={r:b.retina?"@2x":"",s:this._getSubdomain(t),x:t.x,y:t.y,z:this._getZoomForUrl()};return this._map&&!this._map.options.crs.infinite&&(t=this._globalTileRange.max.y-t.y,this.options.tms&&(e.y=t),e["-y"]=t),q(this._url,l(e,this.options))},_tileOnLoad:function(t,e){b.ielt9?setTimeout(a(t,this,null,e),0):t(null,e)},_tileOnError:function(t,e,i){var n=this.options.errorTileUrl;n&&e.getAttribute("src")!==n&&(e.src=n),t(i,e)},_onTileRemove:function(t){t.tile.onload=null},_getZoomForUrl:function(){var t=this._tileZoom,e=this.options.maxZoom;return(t=this.options.zoomReverse?e-t:t)+this.options.zoomOffset},_getSubdomain:function(t){t=Math.abs(t.x+t.y)%this.options.subdomains.length;return this.options.subdomains[t]},_abortLoading:function(){var t,e,i;for(t in this._tiles)this._tiles[t].coords.z!==this._tileZoom&&((i=this._tiles[t].el).onload=u,i.onerror=u,i.complete||(i.src=K,e=this._tiles[t].coords,T(i),delete this._tiles[t],this.fire("tileabort",{tile:i,coords:e})))},_removeTile:function(t){var e=this._tiles[t];if(e)return e.el.setAttribute("src",K),Ni.prototype._removeTile.call(this,t)},_tileReady:function(t,e,i){if(this._map&&(!i||i.getAttribute("src")!==K))return Ni.prototype._tileReady.call(this,t,e,i)}});function ji(t,e){return new Di(t,e)}var Hi=Di.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(t,e){this._url=t;var i,n=l({},this.defaultWmsParams);for(i in e)i in this.options||(n[i]=e[i]);var t=(e=c(this,e)).detectRetina&&b.retina?2:1,o=this.getTileSize();n.width=o.x*t,n.height=o.y*t,this.wmsParams=n},onAdd:function(t){this._crs=this.options.crs||t.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var e=1.3<=this._wmsVersion?"crs":"srs";this.wmsParams[e]=this._crs.code,Di.prototype.onAdd.call(this,t)},getTileUrl:function(t){var e=this._tileCoordsToNwSe(t),i=this._crs,i=_(i.project(e[0]),i.project(e[1])),e=i.min,i=i.max,e=(1.3<=this._wmsVersion&&this._crs===li?[e.y,e.x,i.y,i.x]:[e.x,e.y,i.x,i.y]).join(","),i=Di.prototype.getTileUrl.call(this,t);return i+U(this.wmsParams,i,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+e},setParams:function(t,e){return l(this.wmsParams,t),e||this.redraw(),this}});Di.WMS=Hi,ji.wms=function(t,e){return new Hi(t,e)};var Wi=o.extend({options:{padding:.1},initialize:function(t){c(this,t),h(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),M(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var t={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(t.zoomanim=this._onAnimZoom),t},_onAnimZoom:function(t){this._updateTransform(t.center,t.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(t,e){var i=this._map.getZoomScale(e,this._zoom),n=this._map.getSize().multiplyBy(.5+this.options.padding),o=this._map.project(this._center,e),n=n.multiplyBy(-i).add(o).subtract(this._map._getNewPixelOrigin(t,e));b.any3d?be(this._container,n,i):Z(this._container,n)},_reset:function(){for(var t in this._update(),this._updateTransform(this._center,this._zoom),this._layers)this._layers[t]._reset()},_onZoomEnd:function(){for(var t in this._layers)this._layers[t]._project()},_updatePaths:function(){for(var t in this._layers)this._layers[t]._update()},_update:function(){var t=this.options.padding,e=this._map.getSize(),i=this._map.containerPointToLayerPoint(e.multiplyBy(-t)).round();this._bounds=new f(i,i.add(e.multiplyBy(1+2*t)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),Fi=Wi.extend({options:{tolerance:0},getEvents:function(){var t=Wi.prototype.getEvents.call(this);return t.viewprereset=this._onViewPreReset,t},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){Wi.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var t=this._container=document.createElement("canvas");S(t,"mousemove",this._onMouseMove,this),S(t,"click dblclick mousedown mouseup contextmenu",this._onClick,this),S(t,"mouseout",this._handleMouseOut,this),t._leaflet_disable_events=!0,this._ctx=t.getContext("2d")},_destroyContainer:function(){r(this._redrawRequest),delete this._ctx,T(this._container),k(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){for(var t in this._redrawBounds=null,this._layers)this._layers[t]._update();this._redraw()}},_update:function(){var t,e,i,n;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),t=this._bounds,e=this._container,i=t.getSize(),n=b.retina?2:1,Z(e,t.min),e.width=n*i.x,e.height=n*i.y,e.style.width=i.x+"px",e.style.height=i.y+"px",b.retina&&this._ctx.scale(2,2),this._ctx.translate(-t.min.x,-t.min.y),this.fire("update"))},_reset:function(){Wi.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(t){this._updateDashArray(t);t=(this._layers[h(t)]=t)._order={layer:t,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=t),this._drawLast=t,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(t){this._requestRedraw(t)},_removePath:function(t){var e=t._order,i=e.next,e=e.prev;i?i.prev=e:this._drawLast=e,e?e.next=i:this._drawFirst=i,delete t._order,delete this._layers[h(t)],this._requestRedraw(t)},_updatePath:function(t){this._extendRedrawBounds(t),t._project(),t._update(),this._requestRedraw(t)},_updateStyle:function(t){this._updateDashArray(t),this._requestRedraw(t)},_updateDashArray:function(t){if("string"==typeof t.options.dashArray){for(var e,i=t.options.dashArray.split(/[, ]+/),n=[],o=0;o<i.length;o++){if(e=Number(i[o]),isNaN(e))return;n.push(e)}t.options._dashArray=n}else t.options._dashArray=t.options.dashArray},_requestRedraw:function(t){this._map&&(this._extendRedrawBounds(t),this._redrawRequest=this._redrawRequest||x(this._redraw,this))},_extendRedrawBounds:function(t){var e;t._pxBounds&&(e=(t.options.weight||0)+1,this._redrawBounds=this._redrawBounds||new f,this._redrawBounds.extend(t._pxBounds.min.subtract([e,e])),this._redrawBounds.extend(t._pxBounds.max.add([e,e])))},_redraw:function(){this._redrawRequest=null,this._redrawBounds&&(this._redrawBounds.min._floor(),this._redrawBounds.max._ceil()),this._clear(),this._draw(),this._redrawBounds=null},_clear:function(){var t,e=this._redrawBounds;e?(t=e.getSize(),this._ctx.clearRect(e.min.x,e.min.y,t.x,t.y)):(this._ctx.save(),this._ctx.setTransform(1,0,0,1,0,0),this._ctx.clearRect(0,0,this._container.width,this._container.height),this._ctx.restore())},_draw:function(){var t,e,i=this._redrawBounds;this._ctx.save(),i&&(e=i.getSize(),this._ctx.beginPath(),this._ctx.rect(i.min.x,i.min.y,e.x,e.y),this._ctx.clip()),this._drawing=!0;for(var n=this._drawFirst;n;n=n.next)t=n.layer,(!i||t._pxBounds&&t._pxBounds.intersects(i))&&t._updatePath();this._drawing=!1,this._ctx.restore()},_updatePoly:function(t,e){if(this._drawing){var i,n,o,s,r=t._parts,a=r.length,h=this._ctx;if(a){for(h.beginPath(),i=0;i<a;i++){for(n=0,o=r[i].length;n<o;n++)s=r[i][n],h[n?"lineTo":"moveTo"](s.x,s.y);e&&h.closePath()}this._fillStroke(h,t)}}},_updateCircle:function(t){var e,i,n,o;this._drawing&&!t._empty()&&(e=t._point,i=this._ctx,n=Math.max(Math.round(t._radius),1),1!=(o=(Math.max(Math.round(t._radiusY),1)||n)/n)&&(i.save(),i.scale(1,o)),i.beginPath(),i.arc(e.x,e.y/o,n,0,2*Math.PI,!1),1!=o&&i.restore(),this._fillStroke(i,t))},_fillStroke:function(t,e){var i=e.options;i.fill&&(t.globalAlpha=i.fillOpacity,t.fillStyle=i.fillColor||i.color,t.fill(i.fillRule||"evenodd")),i.stroke&&0!==i.weight&&(t.setLineDash&&t.setLineDash(e.options&&e.options._dashArray||[]),t.globalAlpha=i.opacity,t.lineWidth=i.weight,t.strokeStyle=i.color,t.lineCap=i.lineCap,t.lineJoin=i.lineJoin,t.stroke())},_onClick:function(t){for(var e,i,n=this._map.mouseEventToLayerPoint(t),o=this._drawFirst;o;o=o.next)(e=o.layer).options.interactive&&e._containsPoint(n)&&(("click"===t.type||"preclick"===t.type)&&this._map._draggableMoved(e)||(i=e));this._fireEvent(!!i&&[i],t)},_onMouseMove:function(t){var e;!this._map||this._map.dragging.moving()||this._map._animatingZoom||(e=this._map.mouseEventToLayerPoint(t),this._handleMouseHover(t,e))},_handleMouseOut:function(t){var e=this._hoveredLayer;e&&(z(this._container,"leaflet-interactive"),this._fireEvent([e],t,"mouseout"),this._hoveredLayer=null,this._mouseHoverThrottled=!1)},_handleMouseHover:function(t,e){if(!this._mouseHoverThrottled){for(var i,n,o=this._drawFirst;o;o=o.next)(i=o.layer).options.interactive&&i._containsPoint(e)&&(n=i);n!==this._hoveredLayer&&(this._handleMouseOut(t),n&&(M(this._container,"leaflet-interactive"),this._fireEvent([n],t,"mouseover"),this._hoveredLayer=n)),this._fireEvent(!!this._hoveredLayer&&[this._hoveredLayer],t),this._mouseHoverThrottled=!0,setTimeout(a(function(){this._mouseHoverThrottled=!1},this),32)}},_fireEvent:function(t,e,i){this._map._fireDOMEvent(e,i||e.type,t)},_bringToFront:function(t){var e,i,n=t._order;n&&(e=n.next,i=n.prev,e&&((e.prev=i)?i.next=e:e&&(this._drawFirst=e),n.prev=this._drawLast,(this._drawLast.next=n).next=null,this._drawLast=n,this._requestRedraw(t)))},_bringToBack:function(t){var e,i,n=t._order;n&&(e=n.next,(i=n.prev)&&((i.next=e)?e.prev=i:i&&(this._drawLast=i),n.prev=null,n.next=this._drawFirst,this._drawFirst.prev=n,this._drawFirst=n,this._requestRedraw(t)))}});function Ui(t){return b.canvas?new Fi(t):null}var Vi=function(){try{return document.namespaces.add("lvml","urn:schemas-microsoft-com:vml"),function(t){return document.createElement("<lvml:"+t+' class="lvml">')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1<this._positions.length&&50<t-this._times[0];)this._positions.shift(),this._times.shift()},_onZoomEnd:function(){var t=this._map.getSize().divideBy(2),e=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=e.subtract(t).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(t,e){return t-(t-e)*this._viscosity},_onPreDragLimit:function(){var t,e;this._viscosity&&this._offsetLimit&&(t=this._draggable._newPos.subtract(this._draggable._startPos),e=this._offsetLimit,t.x<e.min.x&&(t.x=this._viscousLimit(t.x,e.min.x)),t.y<e.min.y&&(t.y=this._viscousLimit(t.y,e.min.y)),t.x>e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)<Math.abs(n+i)?o:n;this._draggable._absPos=this._draggable._newPos.clone(),this._draggable._newPos.x=t},_onDragEnd:function(t){var e,i,n,o,s=this._map,r=s.options,a=!r.inertia||t.noInertia||this._times.length<2;s.fire("dragend",t),!a&&(this._prunePositions(+new Date),t=this._lastPos.subtract(this._positions[0]),a=(this._lastTime-this._times[0])/1e3,e=r.easeLinearity,a=(t=t.multiplyBy(e/a)).distanceTo([0,0]),i=Math.min(r.inertiaMaxSpeed,a),t=t.multiplyBy(i/a),n=i/(r.inertiaDeceleration*e),(o=t.multiplyBy(-n/2).round()).x||o.y)?(o=s._limitOffset(o,s.options.maxBounds),x(function(){s.panBy(o,{duration:n,easeLinearity:e,noMoveStart:!0,animate:!0})})):s.fire("moveend")}})),St=(A.addInitHook("addHandler","dragging",Zt),A.mergeOptions({keyboard:!0,keyboardPanDelta:80}),n.extend({keyCodes:{left:[37],right:[39],down:[40],up:[38],zoomIn:[187,107,61,171],zoomOut:[189,109,54,173]},initialize:function(t){this._map=t,this._setPanDelta(t.options.keyboardPanDelta),this._setZoomDelta(t.options.zoomDelta)},addHooks:function(){var t=this._map._container;t.tabIndex<=0&&(t.tabIndex="0"),S(t,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.on({focus:this._addHooks,blur:this._removeHooks},this)},removeHooks:function(){this._removeHooks(),k(this._map._container,{focus:this._onFocus,blur:this._onBlur,mousedown:this._onMouseDown},this),this._map.off({focus:this._addHooks,blur:this._removeHooks},this)},_onMouseDown:function(){var t,e,i;this._focused||(i=document.body,t=document.documentElement,e=i.scrollTop||t.scrollTop,i=i.scrollLeft||t.scrollLeft,this._map._container.focus(),window.scrollTo(i,e))},_onFocus:function(){this._focused=!0,this._map.fire("focus")},_onBlur:function(){this._focused=!1,this._map.fire("blur")},_setPanDelta:function(t){for(var e=this._panKeys={},i=this.keyCodes,n=0,o=i.left.length;n<o;n++)e[i.left[n]]=[-1*t,0];for(n=0,o=i.right.length;n<o;n++)e[i.right[n]]=[t,0];for(n=0,o=i.down.length;n<o;n++)e[i.down[n]]=[0,t];for(n=0,o=i.up.length;n<o;n++)e[i.up[n]]=[0,-1*t]},_setZoomDelta:function(t){for(var e=this._zoomKeys={},i=this.keyCodes,n=0,o=i.zoomIn.length;n<o;n++)e[i.zoomIn[n]]=t;for(n=0,o=i.zoomOut.length;n<o;n++)e[i.zoomOut[n]]=-t},_addHooks:function(){S(document,"keydown",this._onKeyDown,this)},_removeHooks:function(){k(document,"keydown",this._onKeyDown,this)},_onKeyDown:function(t){if(!(t.altKey||t.ctrlKey||t.metaKey)){var e,i,n=t.keyCode,o=this._map;if(n in this._panKeys)o._panAnim&&o._panAnim._inProgress||(i=this._panKeys[n],t.shiftKey&&(i=m(i).multiplyBy(3)),o.options.maxBounds&&(i=o._limitOffset(m(i),o.options.maxBounds)),o.options.worldCopyJump?(e=o.wrapLatLng(o.unproject(o.project(o.getCenter()).add(i))),o.panTo(e)):o.panBy(i));else if(n in this._zoomKeys)o.setZoom(o.getZoom()+(t.shiftKey?3:1)*this._zoomKeys[n]);else{if(27!==n||!o._popup||!o._popup.options.closeOnEscapeKey)return;o.closePopup()}Re(t)}}})),Et=(A.addInitHook("addHandler","keyboard",St),A.mergeOptions({scrollWheelZoom:!0,wheelDebounceTime:40,wheelPxPerZoomLevel:60}),n.extend({addHooks:function(){S(this._map._container,"wheel",this._onWheelScroll,this),this._delta=0},removeHooks:function(){k(this._map._container,"wheel",this._onWheelScroll,this)},_onWheelScroll:function(t){var e=He(t),i=this._map.options.wheelDebounceTime,e=(this._delta+=e,this._lastMousePos=this._map.mouseEventToContainerPoint(t),this._startTime||(this._startTime=+new Date),Math.max(i-(+new Date-this._startTime),0));clearTimeout(this._timer),this._timer=setTimeout(a(this._performZoom,this),e),Re(t)},_performZoom:function(){var t=this._map,e=t.getZoom(),i=this._map.options.zoomSnap||0,n=(t._stop(),this._delta/(4*this._map.options.wheelPxPerZoomLevel)),n=4*Math.log(2/(1+Math.exp(-Math.abs(n))))/Math.LN2,i=i?Math.ceil(n/i)*i:n,n=t._limitZoom(e+(0<this._delta?i:-i))-e;this._delta=0,this._startTime=null,n&&("center"===t.options.scrollWheelZoom?t.setZoom(e+n):t.setZoomAround(this._lastMousePos,e+n))}})),kt=(A.addInitHook("addHandler","scrollWheelZoom",Et),A.mergeOptions({tapHold:b.touchNative&&b.safari&&b.mobile,tapTolerance:15}),n.extend({addHooks:function(){S(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){k(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){var e;clearTimeout(this._holdTimeout),1===t.touches.length&&(e=t.touches[0],this._startPos=this._newPos=new p(e.clientX,e.clientY),this._holdTimeout=setTimeout(a(function(){this._cancel(),this._isTapValid()&&(S(document,"touchend",O),S(document,"touchend touchcancel",this._cancelClickPrevent),this._simulateEvent("contextmenu",e))},this),600),S(document,"touchend touchcancel contextmenu",this._cancel,this),S(document,"touchmove",this._onMove,this))},_cancelClickPrevent:function t(){k(document,"touchend",O),k(document,"touchend touchcancel",t)},_cancel:function(){clearTimeout(this._holdTimeout),k(document,"touchend touchcancel contextmenu",this._cancel,this),k(document,"touchmove",this._onMove,this)},_onMove:function(t){t=t.touches[0];this._newPos=new p(t.clientX,t.clientY)},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_simulateEvent:function(t,e){t=new MouseEvent(t,{bubbles:!0,cancelable:!0,view:window,screenX:e.screenX,screenY:e.screenY,clientX:e.clientX,clientY:e.clientY});t._simulated=!0,e.target.dispatchEvent(t)}})),Ot=(A.addInitHook("addHandler","tapHold",kt),A.mergeOptions({touchZoom:b.touch,bounceAtZoomLimits:!0}),n.extend({addHooks:function(){M(this._map._container,"leaflet-touch-zoom"),S(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){z(this._map._container,"leaflet-touch-zoom"),k(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var e,i,n=this._map;!t.touches||2!==t.touches.length||n._animatingZoom||this._zooming||(e=n.mouseEventToContainerPoint(t.touches[0]),i=n.mouseEventToContainerPoint(t.touches[1]),this._centerPoint=n.getSize()._divideBy(2),this._startLatLng=n.containerPointToLatLng(this._centerPoint),"center"!==n.options.touchZoom&&(this._pinchStartLatLng=n.containerPointToLatLng(e.add(i)._divideBy(2))),this._startDist=e.distanceTo(i),this._startZoom=n.getZoom(),this._moved=!1,this._zooming=!0,n._stop(),S(document,"touchmove",this._onTouchMove,this),S(document,"touchend touchcancel",this._onTouchEnd,this),O(t))},_onTouchMove:function(t){if(t.touches&&2===t.touches.length&&this._zooming){var e=this._map,i=e.mouseEventToContainerPoint(t.touches[0]),n=e.mouseEventToContainerPoint(t.touches[1]),o=i.distanceTo(n)/this._startDist;if(this._zoom=e.getScaleZoom(o,this._startZoom),!e.options.bounceAtZoomLimits&&(this._zoom<e.getMinZoom()&&o<1||this._zoom>e.getMaxZoom()&&1<o)&&(this._zoom=e._limitZoom(this._zoom)),"center"===e.options.touchZoom){if(this._center=this._startLatLng,1==o)return}else{i=i._add(n)._divideBy(2)._subtract(this._centerPoint);if(1==o&&0===i.x&&0===i.y)return;this._center=e.unproject(e.project(this._pinchStartLatLng,this._zoom).subtract(i),this._zoom)}this._moved||(e._moveStart(!0,!1),this._moved=!0),r(this._animRequest);n=a(e._move,e,this._center,this._zoom,{pinch:!0,round:!1},void 0);this._animRequest=x(n,this,!0),O(t)}},_onTouchEnd:function(){this._moved&&this._zooming?(this._zooming=!1,r(this._animRequest),k(document,"touchmove",this._onTouchMove,this),k(document,"touchend touchcancel",this._onTouchEnd,this),this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom))):this._zooming=!1}})),Xi=(A.addInitHook("addHandler","touchZoom",Ot),A.BoxZoom=_t,A.DoubleClickZoom=Ct,A.Drag=Zt,A.Keyboard=St,A.ScrollWheelZoom=Et,A.TapHold=kt,A.TouchZoom=Ot,t.Bounds=f,t.Browser=b,t.CRS=ot,t.Canvas=Fi,t.Circle=vi,t.CircleMarker=gi,t.Class=et,t.Control=B,t.DivIcon=Ri,t.DivOverlay=Ai,t.DomEvent=mt,t.DomUtil=pt,t.Draggable=Xe,t.Evented=it,t.FeatureGroup=ci,t.GeoJSON=wi,t.GridLayer=Ni,t.Handler=n,t.Icon=di,t.ImageOverlay=Ei,t.LatLng=v,t.LatLngBounds=s,t.Layer=o,t.LayerGroup=ui,t.LineUtil=vt,t.Map=A,t.Marker=mi,t.Mixin=ft,t.Path=fi,t.Point=p,t.PolyUtil=gt,t.Polygon=xi,t.Polyline=yi,t.Popup=Bi,t.PosAnimation=Fe,t.Projection=wt,t.Rectangle=Yi,t.Renderer=Wi,t.SVG=Gi,t.SVGOverlay=Oi,t.TileLayer=Di,t.Tooltip=Ii,t.Transformation=at,t.Util=tt,t.VideoOverlay=ki,t.bind=a,t.bounds=_,t.canvas=Ui,t.circle=function(t,e,i){return new vi(t,e,i)},t.circleMarker=function(t,e){return new gi(t,e)},t.control=Ue,t.divIcon=function(t){return new Ri(t)},t.extend=l,t.featureGroup=function(t,e){return new ci(t,e)},t.geoJSON=Si,t.geoJson=Mt,t.gridLayer=function(t){return new Ni(t)},t.icon=function(t){return new di(t)},t.imageOverlay=function(t,e,i){return new Ei(t,e,i)},t.latLng=w,t.latLngBounds=g,t.layerGroup=function(t,e){return new ui(t,e)},t.map=function(t,e){return new A(t,e)},t.marker=function(t,e){return new mi(t,e)},t.point=m,t.polygon=function(t,e){return new xi(t,e)},t.polyline=function(t,e){return new yi(t,e)},t.popup=function(t,e){return new Bi(t,e)},t.rectangle=function(t,e){return new Yi(t,e)},t.setOptions=c,t.stamp=h,t.svg=Ki,t.svgOverlay=function(t,e,i){return new Oi(t,e,i)},t.tileLayer=ji,t.tooltip=function(t,e){return new Ii(t,e)},t.transformation=ht,t.version="1.9.4",t.videoOverlay=function(t,e,i){return new ki(t,e,i)},window.L);t.noConflict=function(){return window.L=Xi,this},window.L=t});
+//# sourceMappingURL=leaflet.js.map
\ No newline at end of file
diff --git a/public/vendor/markercluster/MarkerCluster.Default.css b/public/vendor/markercluster/MarkerCluster.Default.css
new file mode 100644
index 0000000..bbc8c9f
--- /dev/null
+++ b/public/vendor/markercluster/MarkerCluster.Default.css
@@ -0,0 +1,60 @@
+.marker-cluster-small {
+ background-color: rgba(181, 226, 140, 0.6);
+ }
+.marker-cluster-small div {
+ background-color: rgba(110, 204, 57, 0.6);
+ }
+
+.marker-cluster-medium {
+ background-color: rgba(241, 211, 87, 0.6);
+ }
+.marker-cluster-medium div {
+ background-color: rgba(240, 194, 12, 0.6);
+ }
+
+.marker-cluster-large {
+ background-color: rgba(253, 156, 115, 0.6);
+ }
+.marker-cluster-large div {
+ background-color: rgba(241, 128, 23, 0.6);
+ }
+
+ /* IE 6-8 fallback colors */
+.leaflet-oldie .marker-cluster-small {
+ background-color: rgb(181, 226, 140);
+ }
+.leaflet-oldie .marker-cluster-small div {
+ background-color: rgb(110, 204, 57);
+ }
+
+.leaflet-oldie .marker-cluster-medium {
+ background-color: rgb(241, 211, 87);
+ }
+.leaflet-oldie .marker-cluster-medium div {
+ background-color: rgb(240, 194, 12);
+ }
+
+.leaflet-oldie .marker-cluster-large {
+ background-color: rgb(253, 156, 115);
+ }
+.leaflet-oldie .marker-cluster-large div {
+ background-color: rgb(241, 128, 23);
+}
+
+.marker-cluster {
+ background-clip: padding-box;
+ border-radius: 20px;
+ }
+.marker-cluster div {
+ width: 30px;
+ height: 30px;
+ margin-left: 5px;
+ margin-top: 5px;
+
+ text-align: center;
+ border-radius: 15px;
+ font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
+ }
+.marker-cluster span {
+ line-height: 30px;
+ }
\ No newline at end of file
diff --git a/public/vendor/markercluster/MarkerCluster.css b/public/vendor/markercluster/MarkerCluster.css
new file mode 100644
index 0000000..c60d71b
--- /dev/null
+++ b/public/vendor/markercluster/MarkerCluster.css
@@ -0,0 +1,14 @@
+.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
+ -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
+ -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
+ -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
+ transition: transform 0.3s ease-out, opacity 0.3s ease-in;
+}
+
+.leaflet-cluster-spider-leg {
+ /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
+ -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
+ -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
+ -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
+ transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
+}
diff --git a/public/vendor/markercluster/leaflet.markercluster.js b/public/vendor/markercluster/leaflet.markercluster.js
new file mode 100644
index 0000000..66fe516
--- /dev/null
+++ b/public/vendor/markercluster/leaflet.markercluster.js
@@ -0,0 +1,2 @@
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t(((e=e||self).Leaflet=e.Leaflet||{},e.Leaflet.markercluster={}))}(this,function(e){"use strict";var t=L.MarkerClusterGroup=L.FeatureGroup.extend({options:{maxClusterRadius:80,iconCreateFunction:null,clusterPane:L.Marker.prototype.options.pane,spiderfyOnEveryZoom:!1,spiderfyOnMaxZoom:!0,showCoverageOnHover:!0,zoomToBoundsOnClick:!0,singleMarkerMode:!1,disableClusteringAtZoom:null,removeOutsideVisibleBounds:!0,animate:!0,animateAddingMarkers:!1,spiderfyShapePositions:null,spiderfyDistanceMultiplier:1,spiderLegPolylineOptions:{weight:1.5,color:"#222",opacity:.5},chunkedLoading:!1,chunkInterval:200,chunkDelay:50,chunkProgress:null,polygonOptions:{}},initialize:function(e){L.Util.setOptions(this,e),this.options.iconCreateFunction||(this.options.iconCreateFunction=this._defaultIconCreateFunction),this._featureGroup=L.featureGroup(),this._featureGroup.addEventParent(this),this._nonPointGroup=L.featureGroup(),this._nonPointGroup.addEventParent(this),this._inZoomAnimation=0,this._needsClustering=[],this._needsRemoving=[],this._currentShownBounds=null,this._queue=[],this._childMarkerEventHandlers={dragstart:this._childMarkerDragStart,move:this._childMarkerMoved,dragend:this._childMarkerDragEnd};var t=L.DomUtil.TRANSITION&&this.options.animate;L.extend(this,t?this._withAnimation:this._noAnimation),this._markerCluster=t?L.MarkerCluster:L.MarkerClusterNonAnimated},addLayer:function(e){if(e instanceof L.LayerGroup)return this.addLayers([e]);if(!e.getLatLng)return this._nonPointGroup.addLayer(e),this.fire("layeradd",{layer:e}),this;if(!this._map)return this._needsClustering.push(e),this.fire("layeradd",{layer:e}),this;if(this.hasLayer(e))return this;this._unspiderfy&&this._unspiderfy(),this._addLayer(e,this._maxZoom),this.fire("layeradd",{layer:e}),this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons();var t=e,i=this._zoom;if(e.__parent)for(;t.__parent._zoom>=i;)t=t.__parent;return this._currentShownBounds.contains(t.getLatLng())&&(this.options.animateAddingMarkers?this._animationAddLayer(e,t):this._animationAddLayerNonAnimated(e,t)),this},removeLayer:function(e){return e instanceof L.LayerGroup?this.removeLayers([e]):(e.getLatLng?this._map?e.__parent&&(this._unspiderfy&&(this._unspiderfy(),this._unspiderfyLayer(e)),this._removeLayer(e,!0),this.fire("layerremove",{layer:e}),this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),e.off(this._childMarkerEventHandlers,this),this._featureGroup.hasLayer(e)&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow())):(!this._arraySplice(this._needsClustering,e)&&this.hasLayer(e)&&this._needsRemoving.push({layer:e,latlng:e._latlng}),this.fire("layerremove",{layer:e})):(this._nonPointGroup.removeLayer(e),this.fire("layerremove",{layer:e})),this)},addLayers:function(n,s){if(!L.Util.isArray(n))return this.addLayer(n);var o,a=this._featureGroup,h=this._nonPointGroup,l=this.options.chunkedLoading,u=this.options.chunkInterval,_=this.options.chunkProgress,d=n.length,p=0,c=!0;if(this._map){var f=(new Date).getTime(),m=L.bind(function(){var e=(new Date).getTime();for(this._map&&this._unspiderfy&&this._unspiderfy();p<d;p++){if(l&&p%200==0){var t=(new Date).getTime()-e;if(u<t)break}if((o=n[p])instanceof L.LayerGroup)c&&(n=n.slice(),c=!1),this._extractNonGroupLayers(o,n),d=n.length;else if(o.getLatLng){if(!this.hasLayer(o)&&(this._addLayer(o,this._maxZoom),s||this.fire("layeradd",{layer:o}),o.__parent&&2===o.__parent.getChildCount())){var i=o.__parent.getAllChildMarkers(),r=i[0]===o?i[1]:i[0];a.removeLayer(r)}}else h.addLayer(o),s||this.fire("layeradd",{layer:o})}_&&_(p,d,(new Date).getTime()-f),p===d?(this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),this._topClusterLevel._recursivelyAddChildrenToMap(null,this._zoom,this._currentShownBounds)):setTimeout(m,this.options.chunkDelay)},this);m()}else for(var e=this._needsClustering;p<d;p++)(o=n[p])instanceof L.LayerGroup?(c&&(n=n.slice(),c=!1),this._extractNonGroupLayers(o,n),d=n.length):o.getLatLng?this.hasLayer(o)||e.push(o):h.addLayer(o);return this},removeLayers:function(e){var t,i,r=e.length,n=this._featureGroup,s=this._nonPointGroup,o=!0;if(!this._map){for(t=0;t<r;t++)(i=e[t])instanceof L.LayerGroup?(o&&(e=e.slice(),o=!1),this._extractNonGroupLayers(i,e),r=e.length):(this._arraySplice(this._needsClustering,i),s.removeLayer(i),this.hasLayer(i)&&this._needsRemoving.push({layer:i,latlng:i._latlng}),this.fire("layerremove",{layer:i}));return this}if(this._unspiderfy){this._unspiderfy();var a=e.slice(),h=r;for(t=0;t<h;t++)(i=a[t])instanceof L.LayerGroup?(this._extractNonGroupLayers(i,a),h=a.length):this._unspiderfyLayer(i)}for(t=0;t<r;t++)(i=e[t])instanceof L.LayerGroup?(o&&(e=e.slice(),o=!1),this._extractNonGroupLayers(i,e),r=e.length):i.__parent?(this._removeLayer(i,!0,!0),this.fire("layerremove",{layer:i}),n.hasLayer(i)&&(n.removeLayer(i),i.clusterShow&&i.clusterShow())):(s.removeLayer(i),this.fire("layerremove",{layer:i}));return this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),this._topClusterLevel._recursivelyAddChildrenToMap(null,this._zoom,this._currentShownBounds),this},clearLayers:function(){return this._map||(this._needsClustering=[],this._needsRemoving=[],delete this._gridClusters,delete this._gridUnclustered),this._noanimationUnspiderfy&&this._noanimationUnspiderfy(),this._featureGroup.clearLayers(),this._nonPointGroup.clearLayers(),this.eachLayer(function(e){e.off(this._childMarkerEventHandlers,this),delete e.__parent},this),this._map&&this._generateInitialClusters(),this},getBounds:function(){var e=new L.LatLngBounds;this._topClusterLevel&&e.extend(this._topClusterLevel._bounds);for(var t=this._needsClustering.length-1;0<=t;t--)e.extend(this._needsClustering[t].getLatLng());return e.extend(this._nonPointGroup.getBounds()),e},eachLayer:function(e,t){var i,r,n,s=this._needsClustering.slice(),o=this._needsRemoving;for(this._topClusterLevel&&this._topClusterLevel.getAllChildMarkers(s),r=s.length-1;0<=r;r--){for(i=!0,n=o.length-1;0<=n;n--)if(o[n].layer===s[r]){i=!1;break}i&&e.call(t,s[r])}this._nonPointGroup.eachLayer(e,t)},getLayers:function(){var t=[];return this.eachLayer(function(e){t.push(e)}),t},getLayer:function(t){var i=null;return t=parseInt(t,10),this.eachLayer(function(e){L.stamp(e)===t&&(i=e)}),i},hasLayer:function(e){if(!e)return!1;var t,i=this._needsClustering;for(t=i.length-1;0<=t;t--)if(i[t]===e)return!0;for(t=(i=this._needsRemoving).length-1;0<=t;t--)if(i[t].layer===e)return!1;return!(!e.__parent||e.__parent._group!==this)||this._nonPointGroup.hasLayer(e)},zoomToShowLayer:function(e,t){var i=this._map;"function"!=typeof t&&(t=function(){});var r=function(){!i.hasLayer(e)&&!i.hasLayer(e.__parent)||this._inZoomAnimation||(this._map.off("moveend",r,this),this.off("animationend",r,this),i.hasLayer(e)?t():e.__parent._icon&&(this.once("spiderfied",t,this),e.__parent.spiderfy()))};e._icon&&this._map.getBounds().contains(e.getLatLng())?t():e.__parent._zoom<Math.round(this._map._zoom)?(this._map.on("moveend",r,this),this._map.panTo(e.getLatLng())):(this._map.on("moveend",r,this),this.on("animationend",r,this),e.__parent.zoomToBounds())},onAdd:function(e){var t,i,r;if(this._map=e,!isFinite(this._map.getMaxZoom()))throw"Map has no maxZoom specified";for(this._featureGroup.addTo(e),this._nonPointGroup.addTo(e),this._gridClusters||this._generateInitialClusters(),this._maxLat=e.options.crs.projection.MAX_LATITUDE,t=0,i=this._needsRemoving.length;t<i;t++)(r=this._needsRemoving[t]).newlatlng=r.layer._latlng,r.layer._latlng=r.latlng;for(t=0,i=this._needsRemoving.length;t<i;t++)r=this._needsRemoving[t],this._removeLayer(r.layer,!0),r.layer._latlng=r.newlatlng;this._needsRemoving=[],this._zoom=Math.round(this._map._zoom),this._currentShownBounds=this._getExpandedVisibleBounds(),this._map.on("zoomend",this._zoomEnd,this),this._map.on("moveend",this._moveEnd,this),this._spiderfierOnAdd&&this._spiderfierOnAdd(),this._bindEvents(),i=this._needsClustering,this._needsClustering=[],this.addLayers(i,!0)},onRemove:function(e){e.off("zoomend",this._zoomEnd,this),e.off("moveend",this._moveEnd,this),this._unbindEvents(),this._map._mapPane.className=this._map._mapPane.className.replace(" leaflet-cluster-anim",""),this._spiderfierOnRemove&&this._spiderfierOnRemove(),delete this._maxLat,this._hideCoverage(),this._featureGroup.remove(),this._nonPointGroup.remove(),this._featureGroup.clearLayers(),this._map=null},getVisibleParent:function(e){for(var t=e;t&&!t._icon;)t=t.__parent;return t||null},_arraySplice:function(e,t){for(var i=e.length-1;0<=i;i--)if(e[i]===t)return e.splice(i,1),!0},_removeFromGridUnclustered:function(e,t){for(var i=this._map,r=this._gridUnclustered,n=Math.floor(this._map.getMinZoom());n<=t&&r[t].removeObject(e,i.project(e.getLatLng(),t));t--);},_childMarkerDragStart:function(e){e.target.__dragStart=e.target._latlng},_childMarkerMoved:function(e){if(!this._ignoreMove&&!e.target.__dragStart){var t=e.target._popup&&e.target._popup.isOpen();this._moveChild(e.target,e.oldLatLng,e.latlng),t&&e.target.openPopup()}},_moveChild:function(e,t,i){e._latlng=t,this.removeLayer(e),e._latlng=i,this.addLayer(e)},_childMarkerDragEnd:function(e){var t=e.target.__dragStart;delete e.target.__dragStart,t&&this._moveChild(e.target,t,e.target._latlng)},_removeLayer:function(e,t,i){var r=this._gridClusters,n=this._gridUnclustered,s=this._featureGroup,o=this._map,a=Math.floor(this._map.getMinZoom());t&&this._removeFromGridUnclustered(e,this._maxZoom);var h,l=e.__parent,u=l._markers;for(this._arraySplice(u,e);l&&(l._childCount--,l._boundsNeedUpdate=!0,!(l._zoom<a));)t&&l._childCount<=1?(h=l._markers[0]===e?l._markers[1]:l._markers[0],r[l._zoom].removeObject(l,o.project(l._cLatLng,l._zoom)),n[l._zoom].addObject(h,o.project(h.getLatLng(),l._zoom)),this._arraySplice(l.__parent._childClusters,l),l.__parent._markers.push(h),h.__parent=l.__parent,l._icon&&(s.removeLayer(l),i||s.addLayer(h))):l._iconNeedsUpdate=!0,l=l.__parent;delete e.__parent},_isOrIsParent:function(e,t){for(;t;){if(e===t)return!0;t=t.parentNode}return!1},fire:function(e,t,i){if(t&&t.layer instanceof L.MarkerCluster){if(t.originalEvent&&this._isOrIsParent(t.layer._icon,t.originalEvent.relatedTarget))return;e="cluster"+e}L.FeatureGroup.prototype.fire.call(this,e,t,i)},listens:function(e,t){return L.FeatureGroup.prototype.listens.call(this,e,t)||L.FeatureGroup.prototype.listens.call(this,"cluster"+e,t)},_defaultIconCreateFunction:function(e){var t=e.getChildCount(),i=" marker-cluster-";return i+=t<10?"small":t<100?"medium":"large",new L.DivIcon({html:"<div><span>"+t+"</span></div>",className:"marker-cluster"+i,iconSize:new L.Point(40,40)})},_bindEvents:function(){var e=this._map,t=this.options.spiderfyOnMaxZoom,i=this.options.showCoverageOnHover,r=this.options.zoomToBoundsOnClick,n=this.options.spiderfyOnEveryZoom;(t||r||n)&&this.on("clusterclick clusterkeypress",this._zoomOrSpiderfy,this),i&&(this.on("clustermouseover",this._showCoverage,this),this.on("clustermouseout",this._hideCoverage,this),e.on("zoomend",this._hideCoverage,this))},_zoomOrSpiderfy:function(e){var t=e.layer,i=t;if("clusterkeypress"!==e.type||!e.originalEvent||13===e.originalEvent.keyCode){for(;1===i._childClusters.length;)i=i._childClusters[0];i._zoom===this._maxZoom&&i._childCount===t._childCount&&this.options.spiderfyOnMaxZoom?t.spiderfy():this.options.zoomToBoundsOnClick&&t.zoomToBounds(),this.options.spiderfyOnEveryZoom&&t.spiderfy(),e.originalEvent&&13===e.originalEvent.keyCode&&this._map._container.focus()}},_showCoverage:function(e){var t=this._map;this._inZoomAnimation||(this._shownPolygon&&t.removeLayer(this._shownPolygon),2<e.layer.getChildCount()&&e.layer!==this._spiderfied&&(this._shownPolygon=new L.Polygon(e.layer.getConvexHull(),this.options.polygonOptions),t.addLayer(this._shownPolygon)))},_hideCoverage:function(){this._shownPolygon&&(this._map.removeLayer(this._shownPolygon),this._shownPolygon=null)},_unbindEvents:function(){var e=this.options.spiderfyOnMaxZoom,t=this.options.showCoverageOnHover,i=this.options.zoomToBoundsOnClick,r=this.options.spiderfyOnEveryZoom,n=this._map;(e||i||r)&&this.off("clusterclick clusterkeypress",this._zoomOrSpiderfy,this),t&&(this.off("clustermouseover",this._showCoverage,this),this.off("clustermouseout",this._hideCoverage,this),n.off("zoomend",this._hideCoverage,this))},_zoomEnd:function(){this._map&&(this._mergeSplitClusters(),this._zoom=Math.round(this._map._zoom),this._currentShownBounds=this._getExpandedVisibleBounds())},_moveEnd:function(){if(!this._inZoomAnimation){var e=this._getExpandedVisibleBounds();this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),this._zoom,e),this._topClusterLevel._recursivelyAddChildrenToMap(null,Math.round(this._map._zoom),e),this._currentShownBounds=e}},_generateInitialClusters:function(){var e=Math.ceil(this._map.getMaxZoom()),t=Math.floor(this._map.getMinZoom()),i=this.options.maxClusterRadius,r=i;"function"!=typeof i&&(r=function(){return i}),null!==this.options.disableClusteringAtZoom&&(e=this.options.disableClusteringAtZoom-1),this._maxZoom=e,this._gridClusters={},this._gridUnclustered={};for(var n=e;t<=n;n--)this._gridClusters[n]=new L.DistanceGrid(r(n)),this._gridUnclustered[n]=new L.DistanceGrid(r(n));this._topClusterLevel=new this._markerCluster(this,t-1)},_addLayer:function(e,t){var i,r,n=this._gridClusters,s=this._gridUnclustered,o=Math.floor(this._map.getMinZoom());for(this.options.singleMarkerMode&&this._overrideMarkerIcon(e),e.on(this._childMarkerEventHandlers,this);o<=t;t--){i=this._map.project(e.getLatLng(),t);var a=n[t].getNearObject(i);if(a)return a._addChild(e),void(e.__parent=a);if(a=s[t].getNearObject(i)){var h=a.__parent;h&&this._removeLayer(a,!1);var l=new this._markerCluster(this,t,a,e);n[t].addObject(l,this._map.project(l._cLatLng,t)),a.__parent=l;var u=e.__parent=l;for(r=t-1;r>h._zoom;r--)u=new this._markerCluster(this,r,u),n[r].addObject(u,this._map.project(a.getLatLng(),r));return h._addChild(u),void this._removeFromGridUnclustered(a,t)}s[t].addObject(e,i)}this._topClusterLevel._addChild(e),e.__parent=this._topClusterLevel},_refreshClustersIcons:function(){this._featureGroup.eachLayer(function(e){e instanceof L.MarkerCluster&&e._iconNeedsUpdate&&e._updateIcon()})},_enqueue:function(e){this._queue.push(e),this._queueTimeout||(this._queueTimeout=setTimeout(L.bind(this._processQueue,this),300))},_processQueue:function(){for(var e=0;e<this._queue.length;e++)this._queue[e].call(this);this._queue.length=0,clearTimeout(this._queueTimeout),this._queueTimeout=null},_mergeSplitClusters:function(){var e=Math.round(this._map._zoom);this._processQueue(),this._zoom<e&&this._currentShownBounds.intersects(this._getExpandedVisibleBounds())?(this._animationStart(),this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),this._zoom,this._getExpandedVisibleBounds()),this._animationZoomIn(this._zoom,e)):this._zoom>e?(this._animationStart(),this._animationZoomOut(this._zoom,e)):this._moveEnd()},_getExpandedVisibleBounds:function(){return this.options.removeOutsideVisibleBounds?L.Browser.mobile?this._checkBoundsMaxLat(this._map.getBounds()):this._checkBoundsMaxLat(this._map.getBounds().pad(1)):this._mapBoundsInfinite},_checkBoundsMaxLat:function(e){var t=this._maxLat;return void 0!==t&&(e.getNorth()>=t&&(e._northEast.lat=1/0),e.getSouth()<=-t&&(e._southWest.lat=-1/0)),e},_animationAddLayerNonAnimated:function(e,t){if(t===e)this._featureGroup.addLayer(e);else if(2===t._childCount){t._addToMap();var i=t.getAllChildMarkers();this._featureGroup.removeLayer(i[0]),this._featureGroup.removeLayer(i[1])}else t._updateIcon()},_extractNonGroupLayers:function(e,t){var i,r=e.getLayers(),n=0;for(t=t||[];n<r.length;n++)(i=r[n])instanceof L.LayerGroup?this._extractNonGroupLayers(i,t):t.push(i);return t},_overrideMarkerIcon:function(e){return e.options.icon=this.options.iconCreateFunction({getChildCount:function(){return 1},getAllChildMarkers:function(){return[e]}})}});L.MarkerClusterGroup.include({_mapBoundsInfinite:new L.LatLngBounds(new L.LatLng(-1/0,-1/0),new L.LatLng(1/0,1/0))}),L.MarkerClusterGroup.include({_noAnimation:{_animationStart:function(){},_animationZoomIn:function(e,t){this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),e),this._topClusterLevel._recursivelyAddChildrenToMap(null,t,this._getExpandedVisibleBounds()),this.fire("animationend")},_animationZoomOut:function(e,t){this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),e),this._topClusterLevel._recursivelyAddChildrenToMap(null,t,this._getExpandedVisibleBounds()),this.fire("animationend")},_animationAddLayer:function(e,t){this._animationAddLayerNonAnimated(e,t)}},_withAnimation:{_animationStart:function(){this._map._mapPane.className+=" leaflet-cluster-anim",this._inZoomAnimation++},_animationZoomIn:function(n,s){var o,a=this._getExpandedVisibleBounds(),h=this._featureGroup,e=Math.floor(this._map.getMinZoom());this._ignoreMove=!0,this._topClusterLevel._recursively(a,n,e,function(e){var t,i=e._latlng,r=e._markers;for(a.contains(i)||(i=null),e._isSingleParent()&&n+1===s?(h.removeLayer(e),e._recursivelyAddChildrenToMap(null,s,a)):(e.clusterHide(),e._recursivelyAddChildrenToMap(i,s,a)),o=r.length-1;0<=o;o--)t=r[o],a.contains(t._latlng)||h.removeLayer(t)}),this._forceLayout(),this._topClusterLevel._recursivelyBecomeVisible(a,s),h.eachLayer(function(e){e instanceof L.MarkerCluster||!e._icon||e.clusterShow()}),this._topClusterLevel._recursively(a,n,s,function(e){e._recursivelyRestoreChildPositions(s)}),this._ignoreMove=!1,this._enqueue(function(){this._topClusterLevel._recursively(a,n,e,function(e){h.removeLayer(e),e.clusterShow()}),this._animationEnd()})},_animationZoomOut:function(e,t){this._animationZoomOutSingle(this._topClusterLevel,e-1,t),this._topClusterLevel._recursivelyAddChildrenToMap(null,t,this._getExpandedVisibleBounds()),this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,Math.floor(this._map.getMinZoom()),e,this._getExpandedVisibleBounds())},_animationAddLayer:function(e,t){var i=this,r=this._featureGroup;r.addLayer(e),t!==e&&(2<t._childCount?(t._updateIcon(),this._forceLayout(),this._animationStart(),e._setPos(this._map.latLngToLayerPoint(t.getLatLng())),e.clusterHide(),this._enqueue(function(){r.removeLayer(e),e.clusterShow(),i._animationEnd()})):(this._forceLayout(),i._animationStart(),i._animationZoomOutSingle(t,this._map.getMaxZoom(),this._zoom)))}},_animationZoomOutSingle:function(t,i,r){var n=this._getExpandedVisibleBounds(),s=Math.floor(this._map.getMinZoom());t._recursivelyAnimateChildrenInAndAddSelfToMap(n,s,i+1,r);var o=this;this._forceLayout(),t._recursivelyBecomeVisible(n,r),this._enqueue(function(){if(1===t._childCount){var e=t._markers[0];this._ignoreMove=!0,e.setLatLng(e.getLatLng()),this._ignoreMove=!1,e.clusterShow&&e.clusterShow()}else t._recursively(n,r,s,function(e){e._recursivelyRemoveChildrenFromMap(n,s,i+1)});o._animationEnd()})},_animationEnd:function(){this._map&&(this._map._mapPane.className=this._map._mapPane.className.replace(" leaflet-cluster-anim","")),this._inZoomAnimation--,this.fire("animationend")},_forceLayout:function(){L.Util.falseFn(document.body.offsetWidth)}}),L.markerClusterGroup=function(e){return new L.MarkerClusterGroup(e)};var i=L.MarkerCluster=L.Marker.extend({options:L.Icon.prototype.options,initialize:function(e,t,i,r){L.Marker.prototype.initialize.call(this,i?i._cLatLng||i.getLatLng():new L.LatLng(0,0),{icon:this,pane:e.options.clusterPane}),this._group=e,this._zoom=t,this._markers=[],this._childClusters=[],this._childCount=0,this._iconNeedsUpdate=!0,this._boundsNeedUpdate=!0,this._bounds=new L.LatLngBounds,i&&this._addChild(i),r&&this._addChild(r)},getAllChildMarkers:function(e,t){e=e||[];for(var i=this._childClusters.length-1;0<=i;i--)this._childClusters[i].getAllChildMarkers(e,t);for(var r=this._markers.length-1;0<=r;r--)t&&this._markers[r].__dragStart||e.push(this._markers[r]);return e},getChildCount:function(){return this._childCount},zoomToBounds:function(e){for(var t,i=this._childClusters.slice(),r=this._group._map,n=r.getBoundsZoom(this._bounds),s=this._zoom+1,o=r.getZoom();0<i.length&&s<n;){s++;var a=[];for(t=0;t<i.length;t++)a=a.concat(i[t]._childClusters);i=a}s<n?this._group._map.setView(this._latlng,s):n<=o?this._group._map.setView(this._latlng,o+1):this._group._map.fitBounds(this._bounds,e)},getBounds:function(){var e=new L.LatLngBounds;return e.extend(this._bounds),e},_updateIcon:function(){this._iconNeedsUpdate=!0,this._icon&&this.setIcon(this)},createIcon:function(){return this._iconNeedsUpdate&&(this._iconObj=this._group.options.iconCreateFunction(this),this._iconNeedsUpdate=!1),this._iconObj.createIcon()},createShadow:function(){return this._iconObj.createShadow()},_addChild:function(e,t){this._iconNeedsUpdate=!0,this._boundsNeedUpdate=!0,this._setClusterCenter(e),e instanceof L.MarkerCluster?(t||(this._childClusters.push(e),e.__parent=this),this._childCount+=e._childCount):(t||this._markers.push(e),this._childCount++),this.__parent&&this.__parent._addChild(e,!0)},_setClusterCenter:function(e){this._cLatLng||(this._cLatLng=e._cLatLng||e._latlng)},_resetBounds:function(){var e=this._bounds;e._southWest&&(e._southWest.lat=1/0,e._southWest.lng=1/0),e._northEast&&(e._northEast.lat=-1/0,e._northEast.lng=-1/0)},_recalculateBounds:function(){var e,t,i,r,n=this._markers,s=this._childClusters,o=0,a=0,h=this._childCount;if(0!==h){for(this._resetBounds(),e=0;e<n.length;e++)i=n[e]._latlng,this._bounds.extend(i),o+=i.lat,a+=i.lng;for(e=0;e<s.length;e++)(t=s[e])._boundsNeedUpdate&&t._recalculateBounds(),this._bounds.extend(t._bounds),i=t._wLatLng,r=t._childCount,o+=i.lat*r,a+=i.lng*r;this._latlng=this._wLatLng=new L.LatLng(o/h,a/h),this._boundsNeedUpdate=!1}},_addToMap:function(e){e&&(this._backupLatlng=this._latlng,this.setLatLng(e)),this._group._featureGroup.addLayer(this)},_recursivelyAnimateChildrenIn:function(e,n,t){this._recursively(e,this._group._map.getMinZoom(),t-1,function(e){var t,i,r=e._markers;for(t=r.length-1;0<=t;t--)(i=r[t])._icon&&(i._setPos(n),i.clusterHide())},function(e){var t,i,r=e._childClusters;for(t=r.length-1;0<=t;t--)(i=r[t])._icon&&(i._setPos(n),i.clusterHide())})},_recursivelyAnimateChildrenInAndAddSelfToMap:function(t,i,r,n){this._recursively(t,n,i,function(e){e._recursivelyAnimateChildrenIn(t,e._group._map.latLngToLayerPoint(e.getLatLng()).round(),r),e._isSingleParent()&&r-1===n?(e.clusterShow(),e._recursivelyRemoveChildrenFromMap(t,i,r)):e.clusterHide(),e._addToMap()})},_recursivelyBecomeVisible:function(e,t){this._recursively(e,this._group._map.getMinZoom(),t,null,function(e){e.clusterShow()})},_recursivelyAddChildrenToMap:function(r,n,s){this._recursively(s,this._group._map.getMinZoom()-1,n,function(e){if(n!==e._zoom)for(var t=e._markers.length-1;0<=t;t--){var i=e._markers[t];s.contains(i._latlng)&&(r&&(i._backupLatlng=i.getLatLng(),i.setLatLng(r),i.clusterHide&&i.clusterHide()),e._group._featureGroup.addLayer(i))}},function(e){e._addToMap(r)})},_recursivelyRestoreChildPositions:function(e){for(var t=this._markers.length-1;0<=t;t--){var i=this._markers[t];i._backupLatlng&&(i.setLatLng(i._backupLatlng),delete i._backupLatlng)}if(e-1===this._zoom)for(var r=this._childClusters.length-1;0<=r;r--)this._childClusters[r]._restorePosition();else for(var n=this._childClusters.length-1;0<=n;n--)this._childClusters[n]._recursivelyRestoreChildPositions(e)},_restorePosition:function(){this._backupLatlng&&(this.setLatLng(this._backupLatlng),delete this._backupLatlng)},_recursivelyRemoveChildrenFromMap:function(e,t,i,r){var n,s;this._recursively(e,t-1,i-1,function(e){for(s=e._markers.length-1;0<=s;s--)n=e._markers[s],r&&r.contains(n._latlng)||(e._group._featureGroup.removeLayer(n),n.clusterShow&&n.clusterShow())},function(e){for(s=e._childClusters.length-1;0<=s;s--)n=e._childClusters[s],r&&r.contains(n._latlng)||(e._group._featureGroup.removeLayer(n),n.clusterShow&&n.clusterShow())})},_recursively:function(e,t,i,r,n){var s,o,a=this._childClusters,h=this._zoom;if(t<=h&&(r&&r(this),n&&h===i&&n(this)),h<t||h<i)for(s=a.length-1;0<=s;s--)(o=a[s])._boundsNeedUpdate&&o._recalculateBounds(),e.intersects(o._bounds)&&o._recursively(e,t,i,r,n)},_isSingleParent:function(){return 0<this._childClusters.length&&this._childClusters[0]._childCount===this._childCount}});L.Marker.include({clusterHide:function(){var e=this.options.opacity;return this.setOpacity(0),this.options.opacity=e,this},clusterShow:function(){return this.setOpacity(this.options.opacity)}}),L.DistanceGrid=function(e){this._cellSize=e,this._sqCellSize=e*e,this._grid={},this._objectPoint={}},L.DistanceGrid.prototype={addObject:function(e,t){var i=this._getCoord(t.x),r=this._getCoord(t.y),n=this._grid,s=n[r]=n[r]||{},o=s[i]=s[i]||[],a=L.Util.stamp(e);this._objectPoint[a]=t,o.push(e)},updateObject:function(e,t){this.removeObject(e),this.addObject(e,t)},removeObject:function(e,t){var i,r,n=this._getCoord(t.x),s=this._getCoord(t.y),o=this._grid,a=o[s]=o[s]||{},h=a[n]=a[n]||[];for(delete this._objectPoint[L.Util.stamp(e)],i=0,r=h.length;i<r;i++)if(h[i]===e)return h.splice(i,1),1===r&&delete a[n],!0},eachObject:function(e,t){var i,r,n,s,o,a,h=this._grid;for(i in h)for(r in o=h[i])for(n=0,s=(a=o[r]).length;n<s;n++)e.call(t,a[n])&&(n--,s--)},getNearObject:function(e){var t,i,r,n,s,o,a,h,l=this._getCoord(e.x),u=this._getCoord(e.y),_=this._objectPoint,d=this._sqCellSize,p=null;for(t=u-1;t<=u+1;t++)if(n=this._grid[t])for(i=l-1;i<=l+1;i++)if(s=n[i])for(r=0,o=s.length;r<o;r++)a=s[r],((h=this._sqDist(_[L.Util.stamp(a)],e))<d||h<=d&&null===p)&&(d=h,p=a);return p},_getCoord:function(e){var t=Math.floor(e/this._cellSize);return isFinite(t)?t:e},_sqDist:function(e,t){var i=t.x-e.x,r=t.y-e.y;return i*i+r*r}},L.QuickHull={getDistant:function(e,t){var i=t[1].lat-t[0].lat;return(t[0].lng-t[1].lng)*(e.lat-t[0].lat)+i*(e.lng-t[0].lng)},findMostDistantPointFromBaseLine:function(e,t){var i,r,n,s=0,o=null,a=[];for(i=t.length-1;0<=i;i--)r=t[i],0<(n=this.getDistant(r,e))&&(a.push(r),s<n&&(s=n,o=r));return{maxPoint:o,newPoints:a}},buildConvexHull:function(e,t){var i=[],r=this.findMostDistantPointFromBaseLine(e,t);return r.maxPoint?i=(i=i.concat(this.buildConvexHull([e[0],r.maxPoint],r.newPoints))).concat(this.buildConvexHull([r.maxPoint,e[1]],r.newPoints)):[e[0]]},getConvexHull:function(e){var t,i=!1,r=!1,n=!1,s=!1,o=null,a=null,h=null,l=null,u=null,_=null;for(t=e.length-1;0<=t;t--){var d=e[t];(!1===i||d.lat>i)&&(i=(o=d).lat),(!1===r||d.lat<r)&&(r=(a=d).lat),(!1===n||d.lng>n)&&(n=(h=d).lng),(!1===s||d.lng<s)&&(s=(l=d).lng)}return u=r!==i?(_=a,o):(_=l,h),[].concat(this.buildConvexHull([_,u],e),this.buildConvexHull([u,_],e))}},L.MarkerCluster.include({getConvexHull:function(){var e,t,i=this.getAllChildMarkers(),r=[];for(t=i.length-1;0<=t;t--)e=i[t].getLatLng(),r.push(e);return L.QuickHull.getConvexHull(r)}}),L.MarkerCluster.include({_2PI:2*Math.PI,_circleFootSeparation:25,_circleStartAngle:0,_spiralFootSeparation:28,_spiralLengthStart:11,_spiralLengthFactor:5,_circleSpiralSwitchover:9,spiderfy:function(){if(this._group._spiderfied!==this&&!this._group._inZoomAnimation){var e,t=this.getAllChildMarkers(null,!0),i=this._group._map.latLngToLayerPoint(this._latlng);this._group._unspiderfy(),e=(this._group._spiderfied=this)._group.options.spiderfyShapePositions?this._group.options.spiderfyShapePositions(t.length,i):t.length>=this._circleSpiralSwitchover?this._generatePointsSpiral(t.length,i):(i.y+=10,this._generatePointsCircle(t.length,i)),this._animationSpiderfy(t,e)}},unspiderfy:function(e){this._group._inZoomAnimation||(this._animationUnspiderfy(e),this._group._spiderfied=null)},_generatePointsCircle:function(e,t){var i,r,n=this._group.options.spiderfyDistanceMultiplier*this._circleFootSeparation*(2+e)/this._2PI,s=this._2PI/e,o=[];for(n=Math.max(n,35),o.length=e,i=0;i<e;i++)r=this._circleStartAngle+i*s,o[i]=new L.Point(t.x+n*Math.cos(r),t.y+n*Math.sin(r))._round();return o},_generatePointsSpiral:function(e,t){var i,r=this._group.options.spiderfyDistanceMultiplier,n=r*this._spiralLengthStart,s=r*this._spiralFootSeparation,o=r*this._spiralLengthFactor*this._2PI,a=0,h=[];for(i=h.length=e;0<=i;i--)i<e&&(h[i]=new L.Point(t.x+n*Math.cos(a),t.y+n*Math.sin(a))._round()),n+=o/(a+=s/n+5e-4*i);return h},_noanimationUnspiderfy:function(){var e,t,i=this._group,r=i._map,n=i._featureGroup,s=this.getAllChildMarkers(null,!0);for(i._ignoreMove=!0,this.setOpacity(1),t=s.length-1;0<=t;t--)e=s[t],n.removeLayer(e),e._preSpiderfyLatlng&&(e.setLatLng(e._preSpiderfyLatlng),delete e._preSpiderfyLatlng),e.setZIndexOffset&&e.setZIndexOffset(0),e._spiderLeg&&(r.removeLayer(e._spiderLeg),delete e._spiderLeg);i.fire("unspiderfied",{cluster:this,markers:s}),i._ignoreMove=!1,i._spiderfied=null}}),L.MarkerClusterNonAnimated=L.MarkerCluster.extend({_animationSpiderfy:function(e,t){var i,r,n,s,o=this._group,a=o._map,h=o._featureGroup,l=this._group.options.spiderLegPolylineOptions;for(o._ignoreMove=!0,i=0;i<e.length;i++)s=a.layerPointToLatLng(t[i]),r=e[i],n=new L.Polyline([this._latlng,s],l),a.addLayer(n),r._spiderLeg=n,r._preSpiderfyLatlng=r._latlng,r.setLatLng(s),r.setZIndexOffset&&r.setZIndexOffset(1e6),h.addLayer(r);this.setOpacity(.3),o._ignoreMove=!1,o.fire("spiderfied",{cluster:this,markers:e})},_animationUnspiderfy:function(){this._noanimationUnspiderfy()}}),L.MarkerCluster.include({_animationSpiderfy:function(e,t){var i,r,n,s,o,a,h=this,l=this._group,u=l._map,_=l._featureGroup,d=this._latlng,p=u.latLngToLayerPoint(d),c=L.Path.SVG,f=L.extend({},this._group.options.spiderLegPolylineOptions),m=f.opacity;for(void 0===m&&(m=L.MarkerClusterGroup.prototype.options.spiderLegPolylineOptions.opacity),c?(f.opacity=0,f.className=(f.className||"")+" leaflet-cluster-spider-leg"):f.opacity=m,l._ignoreMove=!0,i=0;i<e.length;i++)r=e[i],a=u.layerPointToLatLng(t[i]),n=new L.Polyline([d,a],f),u.addLayer(n),r._spiderLeg=n,c&&(o=(s=n._path).getTotalLength()+.1,s.style.strokeDasharray=o,s.style.strokeDashoffset=o),r.setZIndexOffset&&r.setZIndexOffset(1e6),r.clusterHide&&r.clusterHide(),_.addLayer(r),r._setPos&&r._setPos(p);for(l._forceLayout(),l._animationStart(),i=e.length-1;0<=i;i--)a=u.layerPointToLatLng(t[i]),(r=e[i])._preSpiderfyLatlng=r._latlng,r.setLatLng(a),r.clusterShow&&r.clusterShow(),c&&((s=(n=r._spiderLeg)._path).style.strokeDashoffset=0,n.setStyle({opacity:m}));this.setOpacity(.3),l._ignoreMove=!1,setTimeout(function(){l._animationEnd(),l.fire("spiderfied",{cluster:h,markers:e})},200)},_animationUnspiderfy:function(e){var t,i,r,n,s,o,a=this,h=this._group,l=h._map,u=h._featureGroup,_=e?l._latLngToNewLayerPoint(this._latlng,e.zoom,e.center):l.latLngToLayerPoint(this._latlng),d=this.getAllChildMarkers(null,!0),p=L.Path.SVG;for(h._ignoreMove=!0,h._animationStart(),this.setOpacity(1),i=d.length-1;0<=i;i--)(t=d[i])._preSpiderfyLatlng&&(t.closePopup(),t.setLatLng(t._preSpiderfyLatlng),delete t._preSpiderfyLatlng,o=!0,t._setPos&&(t._setPos(_),o=!1),t.clusterHide&&(t.clusterHide(),o=!1),o&&u.removeLayer(t),p&&(s=(n=(r=t._spiderLeg)._path).getTotalLength()+.1,n.style.strokeDashoffset=s,r.setStyle({opacity:0})));h._ignoreMove=!1,setTimeout(function(){var e=0;for(i=d.length-1;0<=i;i--)(t=d[i])._spiderLeg&&e++;for(i=d.length-1;0<=i;i--)(t=d[i])._spiderLeg&&(t.clusterShow&&t.clusterShow(),t.setZIndexOffset&&t.setZIndexOffset(0),1<e&&u.removeLayer(t),l.removeLayer(t._spiderLeg),delete t._spiderLeg);h._animationEnd(),h.fire("unspiderfied",{cluster:a,markers:d})},200)}}),L.MarkerClusterGroup.include({_spiderfied:null,unspiderfy:function(){this._unspiderfy.apply(this,arguments)},_spiderfierOnAdd:function(){this._map.on("click",this._unspiderfyWrapper,this),this._map.options.zoomAnimation&&this._map.on("zoomstart",this._unspiderfyZoomStart,this),this._map.on("zoomend",this._noanimationUnspiderfy,this),L.Browser.touch||this._map.getRenderer(this)},_spiderfierOnRemove:function(){this._map.off("click",this._unspiderfyWrapper,this),this._map.off("zoomstart",this._unspiderfyZoomStart,this),this._map.off("zoomanim",this._unspiderfyZoomAnim,this),this._map.off("zoomend",this._noanimationUnspiderfy,this),this._noanimationUnspiderfy()},_unspiderfyZoomStart:function(){this._map&&this._map.on("zoomanim",this._unspiderfyZoomAnim,this)},_unspiderfyZoomAnim:function(e){L.DomUtil.hasClass(this._map._mapPane,"leaflet-touching")||(this._map.off("zoomanim",this._unspiderfyZoomAnim,this),this._unspiderfy(e))},_unspiderfyWrapper:function(){this._unspiderfy()},_unspiderfy:function(e){this._spiderfied&&this._spiderfied.unspiderfy(e)},_noanimationUnspiderfy:function(){this._spiderfied&&this._spiderfied._noanimationUnspiderfy()},_unspiderfyLayer:function(e){e._spiderLeg&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow(),e.setZIndexOffset&&e.setZIndexOffset(0),this._map.removeLayer(e._spiderLeg),delete e._spiderLeg)}}),L.MarkerClusterGroup.include({refreshClusters:function(e){return e?e instanceof L.MarkerClusterGroup?e=e._topClusterLevel.getAllChildMarkers():e instanceof L.LayerGroup?e=e._layers:e instanceof L.MarkerCluster?e=e.getAllChildMarkers():e instanceof L.Marker&&(e=[e]):e=this._topClusterLevel.getAllChildMarkers(),this._flagParentsIconsNeedUpdate(e),this._refreshClustersIcons(),this.options.singleMarkerMode&&this._refreshSingleMarkerModeMarkers(e),this},_flagParentsIconsNeedUpdate:function(e){var t,i;for(t in e)for(i=e[t].__parent;i;)i._iconNeedsUpdate=!0,i=i.__parent},_refreshSingleMarkerModeMarkers:function(e){var t,i;for(t in e)i=e[t],this.hasLayer(i)&&i.setIcon(this._overrideMarkerIcon(i))}}),L.Marker.include({refreshIconOptions:function(e,t){var i=this.options.icon;return L.setOptions(i,e),this.setIcon(i),t&&this.__parent&&this.__parent._group.refreshClusters(this),this}}),e.MarkerClusterGroup=t,e.MarkerCluster=i,Object.defineProperty(e,"__esModule",{value:!0})});
+//# sourceMappingURL=leaflet.markercluster.js.map
\ No newline at end of file
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..c645c22
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,571 @@
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const multer = require('multer');
+const db = require('../lib/db');
+const { requireInstaller, requirePaidTier } = require('../lib/auth');
+const stripe = require('../lib/stripe');
+const marketplace = require('../lib/services/marketplace');
+const router = express.Router();
+
+router.use(requireInstaller);
+
+// ====== Image uploads (template hero + portfolio drag-drop) =====
+// Files land at public/uploads/{installer_id}/{hash}.{ext} so they're
+// served straight from express.static. Filenames are content-hashed to
+// dedupe and to avoid leaking original filenames.
+const UPLOAD_ROOT = path.join(__dirname, '..', 'public', 'uploads');
+const ALLOWED_MIME = new Set(['image/jpeg','image/png','image/webp','image/avif']);
+const EXT_BY_MIME = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/avif':'.avif' };
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 8 * 1024 * 1024, files: 1 },
+ fileFilter: (req, file, cb) => {
+ if (!ALLOWED_MIME.has(file.mimetype)) return cb(new Error('UNSUPPORTED_TYPE'));
+ cb(null, true);
+ }
+});
+
+// Coarse ops gate — until Phase 2 of the architecture refactor lands real
+// role-based middleware, ops privileges are granted via the env-var
+// allowlist NPH_PLATFORM_ADMIN_INSTALLER_IDS (comma-separated installer IDs).
+// Steve's own installer ID goes in there. Anyone else hitting /admin/ops/* gets a 404
+// (not 403, to avoid leaking the existence of the surface).
+function requireOpsInstaller(req, res, next) {
+ const allowList = String(process.env.NPH_PLATFORM_ADMIN_INSTALLER_IDS || '')
+ .split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite);
+ if (!req.installer || !allowList.includes(req.installer.id)) {
+ return res.status(404).render('public/404', { title: 'Not Found' });
+ }
+ next();
+}
+
+router.get('/', async (req, res, next) => {
+ try {
+ const upcoming = await db.many(
+ `SELECT * FROM bookings
+ WHERE installer_id = $1
+ AND status IN ('pending','confirmed')
+ AND scheduled_end >= now()
+ ORDER BY scheduled_start ASC
+ LIMIT 6`,
+ [req.installer.id]
+ );
+ const stats = await db.one(
+ `SELECT
+ COUNT(*) FILTER (WHERE status='pending') AS pending_count,
+ COUNT(*) FILTER (WHERE status='confirmed' AND scheduled_end >= now()) AS upcoming_count,
+ COUNT(*) FILTER (WHERE status='completed') AS completed_count
+ FROM bookings WHERE installer_id = $1`,
+ [req.installer.id]
+ );
+ const portfolioCount = await db.one('SELECT COUNT(*)::int AS c FROM installer_portfolio WHERE installer_id = $1', [req.installer.id]);
+ const availabilityCount = await db.one('SELECT COUNT(*)::int AS c FROM installer_availability WHERE installer_id = $1 AND active = true', [req.installer.id]);
+ const market = await marketplace.getInstallerMarketplaceTotals(req.installer.id);
+ res.render('admin/dashboard', {
+ title: 'Dashboard · National Paper Hangers',
+ upcoming, stats, portfolioCount: portfolioCount.c,
+ hasAvailability: availabilityCount.c > 0,
+ welcome: req.query.welcome === '1',
+ market,
+ connectFlash: req.query.connect || (req.query.mock === 'connect' ? 'mock' : null)
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/calendar', requirePaidTier, async (req, res, next) => {
+ try {
+ const availability = await db.many(
+ `SELECT * FROM installer_availability WHERE installer_id = $1 ORDER BY day_of_week, start_time`,
+ [req.installer.id]
+ );
+ res.render('admin/calendar', {
+ title: 'Calendar · National Paper Hangers',
+ availability
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/bookings/:id(\\d+)', async (req, res, next) => {
+ try {
+ const booking = await db.one(
+ `SELECT * FROM bookings WHERE id = $1 AND installer_id = $2`,
+ [req.params.id, req.installer.id]
+ );
+ if (!booking) return res.status(404).render('public/404', { title: 'Not Found' });
+ res.render('admin/booking-detail', { title: `Booking · ${booking.customer_name}`, booking });
+ } catch (err) { next(err); }
+});
+
+router.get('/bookings', async (req, res, next) => {
+ try {
+ const filter = req.query.status || 'all';
+ let where = 'installer_id = $1';
+ const params = [req.installer.id];
+ if (filter === 'upcoming') where += ' AND status IN (\'pending\',\'confirmed\') AND scheduled_end >= now()';
+ if (filter === 'past') where += ' AND (status IN (\'completed\',\'no_show\') OR scheduled_end < now())';
+ if (filter === 'pending') where += ' AND status = \'pending\'';
+ if (filter === 'canceled') where += ' AND status IN (\'canceled\',\'declined\')';
+
+ const bookings = await db.many(
+ `SELECT * FROM bookings WHERE ${where} ORDER BY scheduled_start DESC LIMIT 200`,
+ params
+ );
+ const market = await marketplace.getInstallerMarketplaceTotals(req.installer.id);
+ res.render('admin/bookings', {
+ title: 'Bookings · National Paper Hangers',
+ bookings, filter, market
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/bookings/:id/confirm', async (req, res, next) => {
+ try {
+ await db.query(
+ `UPDATE bookings SET status='confirmed', confirmed_at=now() WHERE id=$1 AND installer_id=$2`,
+ [req.params.id, req.installer.id]
+ );
+ res.redirect('/admin/bookings');
+ } catch (err) { next(err); }
+});
+
+router.post('/bookings/:id/decline', async (req, res, next) => {
+ try {
+ await db.query(
+ `UPDATE bookings SET status='declined', canceled_at=now(), cancel_reason=$3 WHERE id=$1 AND installer_id=$2`,
+ [req.params.id, req.installer.id, req.body.reason || null]
+ );
+ res.redirect('/admin/bookings');
+ } catch (err) { next(err); }
+});
+
+router.post('/bookings/:id/complete', async (req, res, next) => {
+ try {
+ await db.query(
+ `UPDATE bookings SET status='completed', completed_at=now() WHERE id=$1 AND installer_id=$2`,
+ [req.params.id, req.installer.id]
+ );
+ res.redirect('/admin/bookings');
+ } catch (err) { next(err); }
+});
+
+router.get('/profile', async (req, res, next) => {
+ try {
+ const credentials = await db.many(
+ `SELECT id, brand, credential_type, year_issued, year_expires, certificate_url,
+ notes, ops_verified, ops_verified_at, display_order
+ FROM installer_credentials
+ WHERE installer_id = $1
+ ORDER BY display_order, year_issued DESC NULLS LAST, id`,
+ [req.installer.id]
+ );
+ res.render('admin/profile', {
+ title: 'Profile · National Paper Hangers',
+ credentials
+ });
+ } catch (err) { next(err); }
+});
+
+const ALLOWED_CREDENTIAL_TYPES = new Set([
+ 'brand_trained','brand_certified','brand_approved','manufacturer_partner','trade_member'
+]);
+
+router.post('/credentials', async (req, res, next) => {
+ try {
+ const f = req.body || {};
+ const brand = clampLen((f.brand || '').trim(), 100);
+ if (!brand) {
+ req.session.flash = { error: 'Brand name is required for a credential.' };
+ return res.redirect('/admin/profile');
+ }
+ const credentialType = ALLOWED_CREDENTIAL_TYPES.has(f.credential_type) ? f.credential_type : 'brand_trained';
+ const yearIssued = f.year_issued && /^\d{4}$/.test(f.year_issued)
+ ? Math.max(1900, Math.min(2100, parseInt(f.year_issued, 10))) : null;
+ const yearExpires = f.year_expires && /^\d{4}$/.test(f.year_expires)
+ ? Math.max(1900, Math.min(2200, parseInt(f.year_expires, 10))) : null;
+ const certUrl = sanitizeWebsite(f.certificate_url);
+ if (f.certificate_url && !certUrl) {
+ req.session.flash = { error: 'Certificate URL must be a valid http(s) link.' };
+ return res.redirect('/admin/profile');
+ }
+ const notes = f.notes ? clampLen(f.notes, 400) : null;
+ await db.query(
+ `INSERT INTO installer_credentials
+ (installer_id, brand, credential_type, year_issued, year_expires, certificate_url, notes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ [req.installer.id, brand, credentialType, yearIssued, yearExpires, certUrl, notes]
+ );
+ req.session.flash = { ok: 'Credential added — pending ops review.' };
+ res.redirect('/admin/profile');
+ } catch (err) { next(err); }
+});
+
+router.post('/credentials/:id(\\d+)/delete', async (req, res, next) => {
+ try {
+ await db.query(
+ `DELETE FROM installer_credentials WHERE id = $1 AND installer_id = $2`,
+ [req.params.id, req.installer.id]
+ );
+ req.session.flash = { ok: 'Credential removed.' };
+ res.redirect('/admin/profile');
+ } catch (err) { next(err); }
+});
+
+function sanitizeWebsite(input) {
+ // Returns a safe http(s) URL string, or null. Strips javascript:/data:/etc.
+ // Length-capped to 500 to bound DB write size.
+ const v = String(input || '').trim();
+ if (!v) return null;
+ if (v.length > 500) return null;
+ let u;
+ try { u = new URL(v); } catch { return null; }
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
+ return u.toString();
+}
+
+function clampLen(v, max) {
+ const s = String(v == null ? '' : v);
+ return s.length > max ? s.slice(0, max) : s;
+}
+
+router.post('/profile', async (req, res, next) => {
+ try {
+ const f = req.body;
+ const arr = (s) => (s || '').split(',').map(x => x.trim()).filter(Boolean).slice(0, 50);
+
+ const website = sanitizeWebsite(f.website);
+ if (f.website && !website) {
+ req.session.flash = { error: 'Website must be a valid http(s) URL' };
+ return res.redirect('/admin/profile');
+ }
+
+ // Equipment Fleet — structured trade-buyer disclosure (UX idea #4).
+ // Build a JSONB blob from form fields; null any blanks so we don't
+ // store empty-string noise.
+ const ALLOWED_LIFT = new Set(['extension_ladder', 'scaffold', 'scissor_lift', 'boom_lift']);
+ const ALLOWED_TABLE = new Set(['none', 'folding', 'dedicated_60', 'dedicated_72_plus']);
+ const ALLOWED_VEHICLE = new Set(['van', 'box_truck', 'trailer']);
+ const equipment = {};
+ if (f.eq_max_reach_ft) {
+ const n = parseInt(f.eq_max_reach_ft, 10);
+ if (Number.isFinite(n) && n > 0 && n < 200) equipment.max_reach_ft = n;
+ }
+ if (f.eq_lift_type && ALLOWED_LIFT.has(f.eq_lift_type)) equipment.lift_type = f.eq_lift_type;
+ if (f.eq_paper_table && ALLOWED_TABLE.has(f.eq_paper_table)) equipment.paper_table = f.eq_paper_table;
+ if (f.eq_vehicle && ALLOWED_VEHICLE.has(f.eq_vehicle)) equipment.vehicle = f.eq_vehicle;
+ if (f.eq_dust_extraction === 'on') equipment.dust_extraction = true;
+ if (f.eq_notes) equipment.notes = clampLen(f.eq_notes, 400);
+ const equipmentJson = Object.keys(equipment).length ? JSON.stringify(equipment) : null;
+
+ await db.query(
+ `UPDATE installers SET
+ business_name = $2, contact_name = $3, phone = $4, headline = $5, bio = $6,
+ city = $7, state = $8, zip = $9, service_radius_miles = $10, travel_available = $11,
+ team_size = $12, founded_year = $13, website = $14,
+ market_segments = $15, materials = $16, brands_handled = $17, accreditations = $18,
+ response_time_hours = $19, equipment = $20,
+ profile_complete = (LENGTH(COALESCE($6,'')) > 40 AND LENGTH(COALESCE($5,'')) > 0)
+ WHERE id = $1`,
+ [
+ req.installer.id,
+ clampLen(f.business_name, 200),
+ clampLen(f.contact_name, 120),
+ clampLen(f.phone, 40),
+ clampLen(f.headline, 200),
+ clampLen(f.bio, 4000),
+ clampLen(f.city, 80),
+ clampLen((f.state || '').toUpperCase(), 2),
+ clampLen(f.zip, 20),
+ parseInt(f.service_radius_miles || '50', 10),
+ f.travel_available === 'on',
+ f.team_size ? parseInt(f.team_size, 10) : null,
+ f.founded_year ? parseInt(f.founded_year, 10) : null,
+ website,
+ arr(f.market_segments), arr(f.materials), arr(f.brands_handled), arr(f.accreditations),
+ parseInt(f.response_time_hours || '24', 10),
+ equipmentJson
+ ]
+ );
+ req.session.flash = { ok: 'Profile saved' };
+ res.redirect('/admin/profile');
+ } catch (err) { next(err); }
+});
+
+// Comma-separated installer IDs that get the platform-wide marketplace
+// roll-up on /admin/billing. Until a real role system lands, this is the
+// kill-switch for "NPH staff sees totals across every studio". Empty = nobody.
+function isPlatformAdmin(installerId) {
+ const list = (process.env.NPH_PLATFORM_ADMIN_INSTALLER_IDS || '')
+ .split(',').map(s => s.trim()).filter(Boolean);
+ return list.includes(String(installerId));
+}
+
+router.get('/billing', async (req, res, next) => {
+ try {
+ const installerMarket = await marketplace.getInstallerMarketplaceTotals(req.installer.id);
+ const platformAdmin = isPlatformAdmin(req.installer.id);
+ const platformMarket = platformAdmin
+ ? await marketplace.getPlatformMarketplaceTotals()
+ : null;
+ res.render('admin/billing', {
+ title: 'Billing · National Paper Hangers',
+ stripeLive: stripe.isLive(),
+ priceTable: stripe.PRICE_TABLE,
+ upgradePrompt: req.query.upgrade === '1',
+ installerMarket,
+ platformMarket,
+ defaultDepositCents: stripe.DEFAULT_DEPOSIT_CENTS,
+ defaultPlatformFeeBps: stripe.DEFAULT_PLATFORM_FEE_BPS
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/billing/checkout', async (req, res, next) => {
+ try {
+ const { tier, cadence } = req.body;
+ const result = await stripe.createCheckoutSession({
+ installer: req.installer,
+ tier, cadence,
+ successUrl: `${process.env.PUBLIC_URL || ''}/admin/billing?ok=1`,
+ cancelUrl: `${process.env.PUBLIC_URL || ''}/admin/billing?canceled=1`
+ });
+ if (result.mocked) {
+ // No real Stripe — toggle subscription locally so UI works end-to-end
+ await db.query(
+ `UPDATE installers SET tier=$2, subscription_status='active' WHERE id=$1`,
+ [req.installer.id, tier]
+ );
+ }
+ res.redirect(result.url);
+ } catch (err) { next(err); }
+});
+
+router.post('/billing/portal', async (req, res, next) => {
+ try {
+ const result = await stripe.createPortalSession({
+ installer: req.installer,
+ returnUrl: `${process.env.PUBLIC_URL || ''}/admin/billing`
+ });
+ res.redirect(result.url);
+ } catch (err) { next(err); }
+});
+
+// ---------- Stripe Connect Express onboarding ----------
+// Idempotent: creates a Connect account if missing, then mints a fresh
+// account_link and redirects to Stripe-hosted onboarding. Mocked path
+// returns to /admin?mock=connect when STRIPE_SECRET_KEY isn't set.
+async function mintConnectOnboardLink(installer) {
+ const baseUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 9765}`;
+ const returnUrl = `${baseUrl}/admin/connect/return`;
+ const refreshUrl = `${baseUrl}/admin/connect/refresh`;
+
+ let accountId = installer.stripe_account_id;
+ if (!accountId) {
+ const created = await stripe.createConnectAccount({ installer });
+ accountId = created.account_id;
+ await db.query(
+ `UPDATE installers
+ SET stripe_account_id = $1,
+ stripe_account_charges_enabled = $2,
+ stripe_account_payouts_enabled = $3
+ WHERE id = $4`,
+ [accountId, !!created.charges_enabled, !!created.payouts_enabled, installer.id]
+ );
+ }
+ return stripe.createConnectAccountLink({ accountId, returnUrl, refreshUrl });
+}
+
+router.post('/connect/onboard', async (req, res, next) => {
+ try {
+ const link = await mintConnectOnboardLink(req.installer);
+ res.redirect(link.url);
+ } catch (err) { next(err); }
+});
+
+router.get('/connect/return', async (req, res, next) => {
+ try {
+ const installer = req.installer;
+ if (installer.stripe_account_id) {
+ const status = await stripe.getConnectAccount({ accountId: installer.stripe_account_id });
+ await db.query(
+ `UPDATE installers
+ SET stripe_account_charges_enabled = $1,
+ stripe_account_payouts_enabled = $2,
+ stripe_account_onboarded_at = CASE
+ WHEN $1 = true AND stripe_account_onboarded_at IS NULL THEN now()
+ ELSE stripe_account_onboarded_at
+ END
+ WHERE id = $3`,
+ [!!status.charges_enabled, !!status.payouts_enabled, installer.id]
+ );
+ }
+ res.redirect('/admin?connect=ok');
+ } catch (err) { next(err); }
+});
+
+router.get('/connect/refresh', async (req, res, next) => {
+ // Stripe redirects here via GET when an onboarding link expires. Mint a
+ // fresh link and bounce. Idempotent + no destructive state.
+ try {
+ const link = await mintConnectOnboardLink(req.installer);
+ res.redirect(link.url);
+ } catch (err) { next(err); }
+});
+
+// =======================================================================
+// OPS — credential review queue (gated by NPH_PLATFORM_ADMIN_INSTALLER_IDS)
+// =======================================================================
+//
+// Studios self-add Brand-Trained credentials in /admin/profile. Until ops
+// flips ops_verified=true, the credential stays out of the public sidebar
+// (prevents spam — anyone could claim "de Gournay-trained"). This queue
+// is where Steve reviews submissions and approves/rejects.
+
+router.get('/ops/credentials', requireOpsInstaller, async (req, res, next) => {
+ try {
+ const pending = await db.many(
+ `SELECT c.id, c.installer_id, c.brand, c.credential_type,
+ c.year_issued, c.year_expires, c.certificate_url, c.notes,
+ c.created_at,
+ i.business_name, i.slug, i.city, i.state, i.website
+ FROM installer_credentials c
+ JOIN installers i ON i.id = c.installer_id
+ WHERE c.ops_verified = false
+ ORDER BY c.created_at DESC
+ LIMIT 200`
+ );
+ const verifiedCount = await db.one(
+ `SELECT COUNT(*)::int AS c FROM installer_credentials WHERE ops_verified = true`
+ );
+ res.render('admin/ops-credentials', {
+ title: 'Ops · Credential review · National Paper Hangers',
+ pending,
+ verifiedCount: verifiedCount.c
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/ops/credentials/:id(\\d+)/verify', requireOpsInstaller, async (req, res, next) => {
+ try {
+ await db.query(
+ `UPDATE installer_credentials
+ SET ops_verified = true,
+ ops_verified_at = now(),
+ ops_verified_by = $2
+ WHERE id = $1`,
+ [req.params.id, req.installer.email || ('installer:' + req.installer.id)]
+ );
+ req.session.flash = { ok: 'Credential verified — now visible on the studio profile.' };
+ res.redirect('/admin/ops/credentials');
+ } catch (err) { next(err); }
+});
+
+router.post('/ops/credentials/:id(\\d+)/reject', requireOpsInstaller, async (req, res, next) => {
+ try {
+ // Reject = delete. Studio can re-submit with corrected info. We don't
+ // persist a "rejected" state to keep the schema lean.
+ await db.query(`DELETE FROM installer_credentials WHERE id = $1`, [req.params.id]);
+ req.session.flash = { ok: 'Credential rejected and removed.' };
+ res.redirect('/admin/ops/credentials');
+ } catch (err) { next(err); }
+});
+
+// ===================================================================
+// Template chooser (the 6 page designs the studio can pick from)
+// ===================================================================
+
+const TEMPLATE_SLUGS = ['editorial','trade-pro','concierge','studio','heritage','bilingue'];
+
+router.get('/template', async (req, res, next) => {
+ try {
+ const flash = req.session.flash;
+ req.session.flash = null;
+ res.render('admin/template', {
+ title: 'Page design · National Paper Hangers',
+ installer: req.installer,
+ flash
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/template', async (req, res, next) => {
+ try {
+ const slug = String(req.body.template_slug || '').toLowerCase();
+ if (!TEMPLATE_SLUGS.includes(slug)) {
+ req.session.flash = { error: 'Invalid template choice.' };
+ return res.redirect('/admin/template');
+ }
+ // template_settings: keep what's there, overlay our editable fields.
+ const existing = req.installer.template_settings || {};
+ const settings = {
+ ...existing,
+ hero_url: String(req.body.hero_url || '').slice(0, 500) || undefined,
+ accent_color: /^#[0-9a-f]{6}$/i.test(String(req.body.accent_color || '')) ? req.body.accent_color : undefined
+ };
+ // Strip undefineds.
+ Object.keys(settings).forEach(k => settings[k] === undefined && delete settings[k]);
+
+ await db.query(
+ `UPDATE installers SET template_slug = $2, template_settings = $3 WHERE id = $1`,
+ [req.installer.id, slug, JSON.stringify(settings)]
+ );
+ req.session.flash = { ok: 'Page design saved. View your live profile to see it.' };
+ res.redirect('/admin/template');
+ } catch (err) { next(err); }
+});
+
+// ===================================================================
+// Drag-drop image upload (used by /admin/template hero + portfolio)
+// ===================================================================
+
+router.post('/uploads', (req, res, next) => {
+ upload.single('file')(req, res, async (err) => {
+ if (err) {
+ const msg = err.message === 'UNSUPPORTED_TYPE'
+ ? 'Image must be JPG, PNG, WebP, or AVIF.'
+ : (err.code === 'LIMIT_FILE_SIZE' ? 'Image too large (max 8 MB).' : 'Upload failed.');
+ return res.status(400).json({ ok: false, error: msg });
+ }
+ if (!req.file) return res.status(400).json({ ok: false, error: 'No file received.' });
+ try {
+ const role = String(req.body.role || 'portfolio').toLowerCase();
+ const installerId = req.installer.id;
+ const dir = path.join(UPLOAD_ROOT, String(installerId));
+ fs.mkdirSync(dir, { recursive: true });
+ const hash = crypto.createHash('sha256').update(req.file.buffer).digest('hex').slice(0, 16);
+ const ext = EXT_BY_MIME[req.file.mimetype] || '.bin';
+ const fname = `${role}-${hash}${ext}`;
+ const fpath = path.join(dir, fname);
+ fs.writeFileSync(fpath, req.file.buffer);
+ const url = `/uploads/${installerId}/${fname}`;
+
+ // Portfolio uploads also persist a row so they show up on the public page
+ // immediately. Hero uploads are stored only on installers.template_settings.hero_url.
+ let portfolioId = null;
+ if (role === 'portfolio') {
+ const order = await db.one(
+ 'SELECT COALESCE(MAX(display_order), 0) + 1 AS next_order FROM installer_portfolio WHERE installer_id = $1',
+ [installerId]
+ );
+ const row = await db.one(
+ `INSERT INTO installer_portfolio (installer_id, title, image_url, city, state, year, display_order)
+ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
+ [
+ installerId,
+ 'Untitled project',
+ url,
+ req.installer.city || null,
+ req.installer.state || null,
+ new Date().getFullYear(),
+ order.next_order
+ ]
+ );
+ portfolioId = row.id;
+ }
+ res.json({ ok: true, url, role, id: portfolioId, size: req.file.size, mime: req.file.mimetype });
+ } catch (e) { next(e); }
+ });
+});
+
+module.exports = router;
diff --git a/routes/api.js b/routes/api.js
new file mode 100644
index 0000000..487586c
--- /dev/null
+++ b/routes/api.js
@@ -0,0 +1,312 @@
+const express = require('express');
+const db = require('../lib/db');
+const slots = require('../lib/slots');
+const email = require('../lib/email');
+const bookingToken = require('../lib/booking-token');
+const stripe = require('../lib/stripe');
+const { requireInstaller } = require('../lib/auth');
+const router = express.Router();
+
+// =======================================================================
+// PUBLIC API
+// =======================================================================
+
+// GET /api/installers/:slug/slots?from=YYYY-MM-DD&to=YYYY-MM-DD&duration=60
+router.get('/installers/:slug/slots', async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT id, tier FROM installers WHERE slug = $1 AND status = 'active'`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).json({ error: 'not_found' });
+
+ const calendarEnabled = ['pro','signature','enterprise'].includes(installer.tier);
+ if (!calendarEnabled) return res.json({ slots: [], calendarEnabled: false });
+
+ const from = req.query.from || new Date().toISOString().slice(0,10);
+ const toDate = new Date(); toDate.setDate(toDate.getDate() + 30);
+ const to = req.query.to || toDate.toISOString().slice(0,10);
+ const duration = parseInt(req.query.duration || '60', 10);
+
+ const list = await slots.availableSlots(installer.id, {
+ startDate: from, endDate: to,
+ slotMinutes: duration, bufferMinutes: 15
+ });
+ res.json({ slots: list, calendarEnabled: true });
+ } catch (err) { next(err); }
+});
+
+// POST /api/installers/:slug/book
+//
+// Race-safe: opens a transaction, takes a row-level lock on the installer
+// (`SELECT … FOR UPDATE`), re-checks slot availability, then inserts. Two
+// concurrent POSTs serialize on that lock, so they cannot both pass the
+// conflict check.
+router.post('/installers/:slug/book', async (req, res, next) => {
+ const client = await db.pool.connect();
+ let booked = null;
+ let installer = null;
+ try {
+ // Read installer (no transaction yet) just to validate existence + tier.
+ installer = await db.one(
+ `SELECT id, slug, business_name, email, tier, website,
+ stripe_account_id, stripe_account_charges_enabled
+ FROM installers WHERE slug = $1 AND status = 'active'`,
+ [req.params.slug]
+ );
+ if (!installer) {
+ client.release();
+ return res.status(404).json({ error: 'not_found' });
+ }
+
+ const calendarEnabled = ['pro','signature','enterprise'].includes(installer.tier);
+ if (!calendarEnabled) {
+ client.release();
+ return res.status(402).json({ error: 'installer_not_on_calendar' });
+ }
+
+ const f = req.body || {};
+ const required = ['customer_name','customer_email','scheduled_start','scheduled_end'];
+ for (const k of required) {
+ if (!f[k]) { client.release(); return res.status(400).json({ error: 'missing_' + k }); }
+ }
+
+ // Basic email shape check (fuller validation deferred to send-side).
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(f.customer_email))) {
+ client.release();
+ return res.status(400).json({ error: 'invalid_customer_email' });
+ }
+
+ const start = new Date(f.scheduled_start);
+ const end = new Date(f.scheduled_end);
+ if (!(end > start)) { client.release(); return res.status(400).json({ error: 'invalid_range' }); }
+ if (start < new Date()) { client.release(); return res.status(400).json({ error: 'past_slot' }); }
+
+ await client.query('BEGIN');
+
+ // Serialize booking creates for this installer on the installer row.
+ await client.query('SELECT id FROM installers WHERE id = $1 FOR UPDATE', [installer.id]);
+
+ const conflict = await client.query(
+ `SELECT 1 FROM bookings
+ WHERE installer_id = $1
+ AND status IN ('pending','confirmed')
+ AND tstzrange(scheduled_start, scheduled_end) && tstzrange($2::timestamptz, $3::timestamptz)
+ LIMIT 1`,
+ [installer.id, start.toISOString(), end.toISOString()]
+ );
+ if (conflict.rowCount > 0) {
+ await client.query('ROLLBACK');
+ client.release();
+ return res.status(409).json({ error: 'slot_taken' });
+ }
+
+ // Structured booking brief (UX idea #6) — validate enums, coerce numerics.
+ const ALLOWED_SURFACE = new Set([
+ 'new_plaster','painted_drywall','wallpaper_to_remove','brick','wood_panel','other'
+ ]);
+ const ALLOWED_ACCESS = new Set([
+ 'ground_floor','second_floor','atrium_double_height','high_rise','restricted_hours'
+ ]);
+ const surfaceState = (f.surface_state && ALLOWED_SURFACE.has(f.surface_state)) ? f.surface_state : null;
+ const accessCon = (f.access_constraints && ALLOWED_ACCESS.has(f.access_constraints)) ? f.access_constraints : null;
+ const ceilingHeightFt = f.ceiling_height_ft ? Math.max(6, Math.min(40, parseFloat(f.ceiling_height_ft))) : null;
+ const rollCountEst = f.roll_count_estimate ? Math.max(0, parseInt(f.roll_count_estimate, 10)) : null;
+ const brandSku = f.brand_sku ? String(f.brand_sku).slice(0, 200) : null;
+
+ // 5-question intake additions: who is ordering, has wallpaper been selected,
+ // and the buyer's signed-in Google account (if they used /auth/google).
+ const ALLOWED_ROLES = new Set(['homeowner','designer','architect','contractor','property_mgr','other']);
+ const customerRole = (f.customer_role && ALLOWED_ROLES.has(String(f.customer_role).toLowerCase()))
+ ? String(f.customer_role).toLowerCase() : null;
+ const productSourced = f.product_sourced === 'true' || f.product_sourced === true ? true
+ : f.product_sourced === 'false' || f.product_sourced === false ? false
+ : null;
+ const consumerAccountId = (req.session && req.session.consumerAccountId) || null;
+
+ const ins = await client.query(
+ `INSERT INTO bookings
+ (installer_id, customer_name, customer_email, customer_phone,
+ project_type, market_segment, material, brand, brand_sku,
+ surfaces, rooms, square_feet, roll_count_estimate, budget_band,
+ ceiling_height_ft, surface_state, access_constraints,
+ address_line1, address_line2, city, state, zip,
+ customer_role, product_sourced, consumer_account_id,
+ scheduled_start, scheduled_end, customer_notes, status, source)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,'pending','web')
+ RETURNING id, uuid`,
+ [
+ installer.id,
+ f.customer_name, f.customer_email, f.customer_phone || null,
+ f.project_type || 'consultation', f.market_segment || null, f.material || null, f.brand || null, brandSku,
+ f.surfaces || null, f.rooms || null, f.square_feet ? parseInt(f.square_feet,10) : null, rollCountEst, f.budget_band || null,
+ ceilingHeightFt, surfaceState, accessCon,
+ f.address_line1 || null, f.address_line2 || null, f.city || null, (f.state || '').toUpperCase() || null, f.zip || null,
+ customerRole, productSourced, consumerAccountId,
+ start.toISOString(), end.toISOString(), f.customer_notes || null
+ ]
+ );
+
+ await client.query('COMMIT');
+ booked = ins.rows[0];
+ } catch (err) {
+ try { await client.query('ROLLBACK'); } catch {}
+ client.release();
+ return next(err);
+ }
+ client.release();
+
+ const publicUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 9765}`;
+ const sig = bookingToken.sign(booked.uuid);
+ // `f` was declared inside the try block above; it's out of scope here,
+ // but req.body has the same fields. Build booking directly from req.body.
+ const booking = {
+ ...req.body, id: booked.id, uuid: booked.uuid,
+ scheduled_start: new Date(req.body.scheduled_start),
+ scheduled_end: new Date(req.body.scheduled_end),
+ view_token: sig
+ };
+
+ // Mint deposit Payment Intent. Marketplace cut: NPH retains
+ // application_fee_amount; balance routes to installer's Connect account
+ // when present, else holds in platform balance for manual transfer at claim.
+ // Mocked-mode-safe so dev works without STRIPE_SECRET_KEY.
+ let deposit = null;
+ try {
+ deposit = await stripe.createBookingDepositIntent({
+ booking, installer,
+ amountCents: undefined, // env-default
+ platformFeeBps: undefined
+ });
+ await db.query(
+ `UPDATE bookings
+ SET stripe_payment_intent_id = $1,
+ deposit_amount_cents = $2,
+ deposit_status = 'requires_payment'
+ WHERE id = $3`,
+ [deposit.intent_id, deposit.amount_cents, booked.id]
+ );
+ } catch (e) {
+ console.warn('[stripe] deposit intent failed', e.message);
+ // Booking row stays with NULL deposit. Customer can retry payment from
+ // the booking page; admin sees deposit_status NULL as a failure indicator.
+ }
+
+ // Fire-and-forget emails. URL carries an HMAC token so the booking page
+ // isn't browse-by-UUID.
+ Promise.all([
+ email.sendEmail({ to: req.body.customer_email, ...email.bookingConfirmationCustomer({ booking, installer, publicUrl }) }),
+ email.sendEmail({ to: installer.email, ...email.bookingNotificationInstaller({ booking, installer, publicUrl }) })
+ ]).catch(e => console.warn('[email] post-book send failed', e.message));
+
+ res.status(201).json({
+ ok: true,
+ uuid: booked.uuid,
+ t: sig,
+ deposit: deposit ? {
+ client_secret: deposit.client_secret,
+ amount_cents: deposit.amount_cents,
+ application_fee_cents: deposit.application_fee_cents,
+ mode: deposit.mode,
+ mocked: !!deposit.mocked
+ } : null
+ });
+});
+
+// =======================================================================
+// INSTALLER-AUTHENTICATED API (calendar editing)
+// =======================================================================
+
+router.get('/admin/availability', requireInstaller, async (req, res, next) => {
+ try {
+ const rows = await db.many(
+ `SELECT id, day_of_week, start_time::text AS start_time, end_time::text AS end_time, active
+ FROM installer_availability WHERE installer_id = $1 ORDER BY day_of_week, start_time`,
+ [req.installer.id]
+ );
+ res.json({ availability: rows });
+ } catch (err) { next(err); }
+});
+
+router.post('/admin/availability', requireInstaller, async (req, res, next) => {
+ try {
+ const { day_of_week, start_time, end_time } = req.body;
+ const dow = parseInt(day_of_week, 10);
+ if (!(dow >= 0 && dow <= 6)) return res.status(400).json({ error: 'invalid_day' });
+ if (!start_time || !end_time) return res.status(400).json({ error: 'missing_time' });
+ const r = await db.one(
+ `INSERT INTO installer_availability (installer_id, day_of_week, start_time, end_time)
+ VALUES ($1, $2, $3::time, $4::time)
+ RETURNING id, day_of_week, start_time::text AS start_time, end_time::text AS end_time, active`,
+ [req.installer.id, dow, start_time, end_time]
+ );
+ res.status(201).json(r);
+ } catch (err) { next(err); }
+});
+
+router.delete('/admin/availability/:id', requireInstaller, async (req, res, next) => {
+ try {
+ await db.query(
+ `DELETE FROM installer_availability WHERE id = $1 AND installer_id = $2`,
+ [req.params.id, req.installer.id]
+ );
+ res.json({ ok: true });
+ } catch (err) { next(err); }
+});
+
+router.get('/admin/time-off', requireInstaller, async (req, res, next) => {
+ try {
+ const rows = await db.many(
+ `SELECT id, start_at, end_at, reason, all_day
+ FROM installer_time_off WHERE installer_id = $1 AND end_at >= now() ORDER BY start_at`,
+ [req.installer.id]
+ );
+ res.json({ timeOff: rows });
+ } catch (err) { next(err); }
+});
+
+router.post('/admin/time-off', requireInstaller, async (req, res, next) => {
+ try {
+ const { start_at, end_at, reason, all_day } = req.body;
+ if (!start_at || !end_at) return res.status(400).json({ error: 'missing_range' });
+ const s = new Date(start_at), e = new Date(end_at);
+ if (isNaN(+s) || isNaN(+e)) return res.status(400).json({ error: 'invalid_date' });
+ if (!(e > s)) return res.status(400).json({ error: 'invalid_range' });
+ const r = await db.one(
+ `INSERT INTO installer_time_off (installer_id, start_at, end_at, reason, all_day)
+ VALUES ($1, $2, $3, $4, $5) RETURNING *`,
+ [req.installer.id, s.toISOString(), e.toISOString(), reason || null, !!all_day]
+ );
+ res.status(201).json(r);
+ } catch (err) { next(err); }
+});
+
+router.delete('/admin/time-off/:id', requireInstaller, async (req, res, next) => {
+ try {
+ await db.query(
+ `DELETE FROM installer_time_off WHERE id = $1 AND installer_id = $2`,
+ [req.params.id, req.installer.id]
+ );
+ res.json({ ok: true });
+ } catch (err) { next(err); }
+});
+
+router.get('/admin/bookings.json', requireInstaller, async (req, res, next) => {
+ try {
+ const from = req.query.from || new Date().toISOString();
+ const to = req.query.to || new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString();
+ const rows = await db.many(
+ `SELECT id, uuid, customer_name, customer_email, project_type, status,
+ scheduled_start, scheduled_end, city, state
+ FROM bookings
+ WHERE installer_id = $1
+ AND scheduled_end >= $2
+ AND scheduled_start <= $3
+ ORDER BY scheduled_start`,
+ [req.installer.id, from, to]
+ );
+ res.json({ bookings: rows });
+ } catch (err) { next(err); }
+});
+
+module.exports = router;
diff --git a/routes/auth-google.js b/routes/auth-google.js
new file mode 100644
index 0000000..64f2c5b
--- /dev/null
+++ b/routes/auth-google.js
@@ -0,0 +1,181 @@
+// Buyer-side Google OAuth (sign-in only).
+//
+// Distinct from installer auth (lib/auth.js) — buyers are *not* installers.
+// We persist them in consumer_accounts and stash req.session.consumerAccountId.
+//
+// Flow:
+// GET /auth/google/start?next=/foo → redirect to Google
+// GET /auth/google/callback?code=… → exchange code, upsert account, redirect to `next`
+//
+// Uses NPH_GOOGLE_OAUTH_CLIENT_ID / NPH_GOOGLE_OAUTH_CLIENT_SECRET. If those
+// are missing we render a friendly degraded page instead of erroring.
+
+const express = require('express');
+const crypto = require('crypto');
+const https = require('https');
+const querystring = require('querystring');
+const db = require('../lib/db');
+
+const router = express.Router();
+
+const CLIENT_ID = process.env.NPH_GOOGLE_OAUTH_CLIENT_ID || '';
+const CLIENT_SECRET = process.env.NPH_GOOGLE_OAUTH_CLIENT_SECRET || '';
+const SCOPE = 'openid email profile';
+
+function publicBase(req) {
+ return process.env.PUBLIC_URL || (req.protocol + '://' + req.get('host'));
+}
+function callbackUrl(req) {
+ return publicBase(req).replace(/\/+$/, '') + '/auth/google/callback';
+}
+
+function safeNext(n) {
+ if (!n || typeof n !== 'string') return '/';
+ // Only allow same-origin paths.
+ if (!n.startsWith('/') || n.startsWith('//')) return '/';
+ return n.slice(0, 500);
+}
+
+function postForm(host, path, formObj) {
+ const body = querystring.stringify(formObj);
+ return new Promise((resolve, reject) => {
+ const req = https.request({
+ method: 'POST',
+ host, path,
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'content-length': Buffer.byteLength(body)
+ }
+ }, res => {
+ let chunks = '';
+ res.on('data', c => chunks += c);
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
+ catch (e) { reject(new Error('OAUTH_TOKEN_BAD_JSON')); }
+ });
+ });
+ req.on('error', reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+function getJson(host, path, accessToken) {
+ return new Promise((resolve, reject) => {
+ const req = https.request({
+ method: 'GET',
+ host, path,
+ headers: { 'authorization': 'Bearer ' + accessToken }
+ }, res => {
+ let chunks = '';
+ res.on('data', c => chunks += c);
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
+ catch (e) { reject(new Error('OAUTH_USERINFO_BAD_JSON')); }
+ });
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+router.get('/auth/google/start', (req, res) => {
+ if (!CLIENT_ID) {
+ return res.status(503).render('public/error', {
+ title: 'Sign-in unavailable',
+ message: 'Google sign-in is not yet configured. Please continue without sign-in, or email info@nationalpaperhangers.com.',
+ path: req.path
+ });
+ }
+ const state = crypto.randomBytes(24).toString('base64url');
+ const nonce = crypto.randomBytes(24).toString('base64url');
+ req.session.gOAuth = { state, nonce, next: safeNext(req.query.next) };
+ const params = querystring.stringify({
+ client_id: CLIENT_ID,
+ redirect_uri: callbackUrl(req),
+ response_type: 'code',
+ scope: SCOPE,
+ state,
+ nonce,
+ prompt: 'select_account',
+ access_type: 'online'
+ });
+ res.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + params);
+});
+
+router.get('/auth/google/callback', async (req, res, next) => {
+ try {
+ if (!CLIENT_ID || !CLIENT_SECRET) {
+ return res.status(503).render('public/error', {
+ title: 'Sign-in unavailable', message: 'Google sign-in is not configured.', path: req.path
+ });
+ }
+ const sessionState = req.session.gOAuth && req.session.gOAuth.state;
+ const sessionNext = (req.session.gOAuth && req.session.gOAuth.next) || '/';
+ if (!sessionState || sessionState !== req.query.state) {
+ return res.status(400).render('public/error', { title: 'Sign-in error', message: 'Invalid state.', path: req.path });
+ }
+ if (req.query.error) {
+ return res.status(400).render('public/error', {
+ title: 'Sign-in canceled',
+ message: 'Sign-in was canceled. You can try again or continue without an account.',
+ path: req.path
+ });
+ }
+ const code = String(req.query.code || '');
+ if (!code) {
+ return res.status(400).render('public/error', { title: 'Sign-in error', message: 'No code returned.', path: req.path });
+ }
+
+ // Token exchange.
+ const tokenRes = await postForm('oauth2.googleapis.com', '/token', {
+ code,
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ redirect_uri: callbackUrl(req),
+ grant_type: 'authorization_code'
+ });
+ if (tokenRes.status !== 200 || !tokenRes.json.access_token) {
+ return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not exchange Google code.', path: req.path });
+ }
+
+ // Userinfo (we trust openid sub from this endpoint since it's a server-to-server fetch).
+ const ui = await getJson('openidconnect.googleapis.com', '/v1/userinfo', tokenRes.json.access_token);
+ if (ui.status !== 200 || !ui.json.sub) {
+ return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not read Google profile.', path: req.path });
+ }
+ const profile = ui.json;
+
+ // Upsert by google_sub.
+ const existing = await db.one('SELECT id FROM consumer_accounts WHERE google_sub = $1', [profile.sub]);
+ let id;
+ if (existing) {
+ await db.query(
+ `UPDATE consumer_accounts
+ SET email = $2, email_verified = $3, name = $4, picture_url = $5, last_login_at = now()
+ WHERE id = $1`,
+ [existing.id, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
+ );
+ id = existing.id;
+ } else {
+ const ins = await db.one(
+ `INSERT INTO consumer_accounts (google_sub, email, email_verified, name, picture_url, last_login_at)
+ VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`,
+ [profile.sub, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
+ );
+ id = ins.id;
+ }
+ req.session.consumerAccountId = id;
+ req.session.consumer = { id, email: profile.email, name: profile.name || null, picture_url: profile.picture || null };
+ req.session.gOAuth = null;
+ res.redirect(sessionNext);
+ } catch (err) { next(err); }
+});
+
+router.post('/auth/google/logout', (req, res) => {
+ req.session.consumerAccountId = null;
+ req.session.consumer = null;
+ res.redirect(req.body.next && req.body.next.startsWith('/') ? req.body.next : '/');
+});
+
+module.exports = router;
diff --git a/routes/auth-linkedin.js b/routes/auth-linkedin.js
new file mode 100644
index 0000000..96ca322
--- /dev/null
+++ b/routes/auth-linkedin.js
@@ -0,0 +1,174 @@
+// Buyer-side LinkedIn OAuth sign-in.
+//
+// Parallels routes/auth-google.js — same consumer_accounts table, same
+// session shape (req.session.consumer / req.session.consumerAccountId).
+// Useful for trade buyers (designers, architects, hospitality FF&E) who
+// live in LinkedIn more than they live in Gmail.
+//
+// LinkedIn supports OIDC since 2023:
+// authorize: https://www.linkedin.com/oauth/v2/authorization
+// token: https://www.linkedin.com/oauth/v2/accessToken
+// userinfo: https://api.linkedin.com/v2/userinfo
+// scopes: openid profile email
+//
+// The DW LinkedIn Developer App ("For Claude - 5-6-26", 4eeb836b-…) was
+// verified to the Designer Wallcoverings Company Page on 2026-05-06.
+
+const express = require('express');
+const crypto = require('crypto');
+const https = require('https');
+const querystring = require('querystring');
+const db = require('../lib/db');
+
+const router = express.Router();
+
+const CLIENT_ID = process.env.LINKEDIN_CLIENT_ID || '';
+const CLIENT_SECRET = process.env.LINKEDIN_CLIENT_SECRET || '';
+const SCOPE = 'openid profile email';
+
+function publicBase(req) {
+ return process.env.PUBLIC_URL || (req.protocol + '://' + req.get('host'));
+}
+function callbackUrl(req) {
+ return publicBase(req).replace(/\/+$/, '') + '/auth/linkedin/callback';
+}
+function safeNext(n) {
+ if (!n || typeof n !== 'string') return '/';
+ if (!n.startsWith('/') || n.startsWith('//')) return '/';
+ return n.slice(0, 500);
+}
+
+function postForm(host, path, formObj) {
+ const body = querystring.stringify(formObj);
+ return new Promise((resolve, reject) => {
+ const req = https.request({
+ method: 'POST', host, path,
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'content-length': Buffer.byteLength(body),
+ 'accept': 'application/json'
+ }
+ }, res => {
+ let chunks = '';
+ res.on('data', c => chunks += c);
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
+ catch (e) { reject(new Error('LINKEDIN_TOKEN_BAD_JSON')); }
+ });
+ });
+ req.on('error', reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+function getJson(host, path, accessToken) {
+ return new Promise((resolve, reject) => {
+ const req = https.request({
+ method: 'GET', host, path,
+ headers: { 'authorization': 'Bearer ' + accessToken, 'accept': 'application/json' }
+ }, res => {
+ let chunks = '';
+ res.on('data', c => chunks += c);
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
+ catch (e) { reject(new Error('LINKEDIN_USERINFO_BAD_JSON')); }
+ });
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+router.get('/auth/linkedin/start', (req, res) => {
+ if (!CLIENT_ID) {
+ return res.status(503).render('public/error', {
+ title: 'LinkedIn sign-in unavailable',
+ message: 'LinkedIn sign-in is not yet configured.',
+ path: req.path
+ });
+ }
+ const state = crypto.randomBytes(24).toString('base64url');
+ req.session.liOAuth = { state, next: safeNext(req.query.next) };
+ const params = querystring.stringify({
+ response_type: 'code',
+ client_id: CLIENT_ID,
+ redirect_uri: callbackUrl(req),
+ state,
+ scope: SCOPE
+ });
+ res.redirect('https://www.linkedin.com/oauth/v2/authorization?' + params);
+});
+
+router.get('/auth/linkedin/callback', async (req, res, next) => {
+ try {
+ if (!CLIENT_ID || !CLIENT_SECRET) {
+ return res.status(503).render('public/error', {
+ title: 'LinkedIn sign-in unavailable', message: 'Not configured.', path: req.path
+ });
+ }
+ const sessionState = req.session.liOAuth && req.session.liOAuth.state;
+ const sessionNext = (req.session.liOAuth && req.session.liOAuth.next) || '/';
+ if (!sessionState || sessionState !== req.query.state) {
+ return res.status(400).render('public/error', { title: 'Sign-in error', message: 'Invalid state.', path: req.path });
+ }
+ if (req.query.error) {
+ return res.status(400).render('public/error', {
+ title: 'Sign-in canceled',
+ message: 'LinkedIn sign-in was canceled. Try again or continue without an account.',
+ path: req.path
+ });
+ }
+ const code = String(req.query.code || '');
+ if (!code) {
+ return res.status(400).render('public/error', { title: 'Sign-in error', message: 'No code returned.', path: req.path });
+ }
+
+ const tokenRes = await postForm('www.linkedin.com', '/oauth/v2/accessToken', {
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: callbackUrl(req),
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET
+ });
+ if (tokenRes.status !== 200 || !tokenRes.json.access_token) {
+ return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not exchange LinkedIn code.', path: req.path });
+ }
+
+ const ui = await getJson('api.linkedin.com', '/v2/userinfo', tokenRes.json.access_token);
+ if (ui.status !== 200 || !ui.json.sub) {
+ return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not read LinkedIn profile.', path: req.path });
+ }
+ const profile = ui.json;
+
+ // We share the consumer_accounts table with Google — distinguish providers
+ // by prefixing the sub with "li:" so a LinkedIn sub never collides with a
+ // Google sub. (Google subs are decimal strings; LinkedIn subs are short
+ // alphanumeric. Prefixing is defensive.)
+ const sub = 'li:' + profile.sub;
+ const existing = await db.one('SELECT id FROM consumer_accounts WHERE google_sub = $1', [sub]);
+ let id;
+ if (existing) {
+ await db.query(
+ `UPDATE consumer_accounts
+ SET email = $2, email_verified = $3, name = $4, picture_url = $5, last_login_at = now()
+ WHERE id = $1`,
+ [existing.id, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
+ );
+ id = existing.id;
+ } else {
+ const ins = await db.one(
+ `INSERT INTO consumer_accounts (google_sub, email, email_verified, name, picture_url, last_login_at)
+ VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`,
+ [sub, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
+ );
+ id = ins.id;
+ }
+ req.session.consumerAccountId = id;
+ req.session.consumer = { id, email: profile.email, name: profile.name || null, picture_url: profile.picture || null, provider: 'linkedin' };
+ req.session.liOAuth = null;
+ res.redirect(sessionNext);
+ } catch (err) { next(err); }
+});
+
+module.exports = router;
diff --git a/routes/auth.js b/routes/auth.js
new file mode 100644
index 0000000..36ffc51
--- /dev/null
+++ b/routes/auth.js
@@ -0,0 +1,115 @@
+const express = require('express');
+const slugify = require('slugify');
+const db = require('../lib/db');
+const { hashPassword, verifyPassword } = require('../lib/auth');
+const router = express.Router();
+
+const GENERIC_LOGIN_ERROR = 'Invalid email or password';
+
+function safeNext(input) {
+ // Only allow same-origin paths; reject //evil.com, /\\foo, full URLs.
+ const v = String(input || '').trim();
+ if (!v.startsWith('/')) return '/admin';
+ if (v.startsWith('//') || v.startsWith('/\\')) return '/admin';
+ return v;
+}
+
+function regenerateSession(req) {
+ return new Promise((resolve, reject) => {
+ req.session.regenerate(err => err ? reject(err) : resolve());
+ });
+}
+
+router.get('/login', (req, res) => {
+ res.render('auth/login', {
+ title: 'Installer login · National Paper Hangers',
+ error: null,
+ next: safeNext(req.query.next || '/admin')
+ });
+});
+
+router.post('/login', async (req, res, next) => {
+ try {
+ const email = (req.body.email || '').toLowerCase().trim();
+ const password = req.body.password || '';
+ const nextUrl = safeNext(req.body.next || '/admin');
+
+ const installer = await db.one('SELECT id, password_hash FROM installers WHERE email = $1', [email]);
+ // Constant-time comparison even on miss: run bcrypt against a dummy hash.
+ const hash = installer && installer.password_hash
+ ? installer.password_hash
+ : '$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidinv';
+ const ok = await verifyPassword(password, hash);
+
+ if (!installer || !ok) {
+ return res.status(401).render('auth/login', {
+ title: 'Installer login',
+ error: GENERIC_LOGIN_ERROR,
+ next: nextUrl
+ });
+ }
+
+ await regenerateSession(req);
+ req.session.installerId = installer.id;
+ await db.query('UPDATE installers SET last_login_at = now() WHERE id = $1', [installer.id]);
+ res.redirect(nextUrl);
+ } catch (err) { next(err); }
+});
+
+router.get('/signup', (req, res) => {
+ res.render('auth/signup', { title: 'Apply to list · National Paper Hangers', error: null, form: {} });
+});
+
+router.post('/signup', async (req, res, next) => {
+ try {
+ const f = req.body;
+ const email = (f.email || '').toLowerCase().trim();
+ const password = f.password || '';
+ const businessName = (f.business_name || '').trim();
+ const contactName = (f.contact_name || '').trim();
+ const city = (f.city || '').trim();
+ const stateAbbr = (f.state || '').trim().toUpperCase();
+ const zip = (f.zip || '').trim();
+
+ const errs = [];
+ if (!email || !email.includes('@')) errs.push('Valid email required');
+ if (password.length < 8) errs.push('Password must be at least 8 characters');
+ if (!businessName) errs.push('Business name required');
+ if (!city) errs.push('City required');
+ if (!stateAbbr || stateAbbr.length !== 2) errs.push('State (2-letter) required');
+
+ if (errs.length) {
+ return res.status(400).render('auth/signup', { title: 'Apply to list', error: errs.join('; '), form: f });
+ }
+
+ const exists = await db.one('SELECT id FROM installers WHERE email = $1', [email]);
+ if (exists) {
+ return res.status(400).render('auth/signup', { title: 'Apply to list', error: 'An account already exists for that email', form: f });
+ }
+
+ const baseSlug = slugify(businessName, { lower: true, strict: true }).slice(0, 60) || 'installer';
+ let slug = baseSlug;
+ let n = 2;
+ while (await db.one('SELECT id FROM installers WHERE slug = $1', [slug])) {
+ slug = `${baseSlug}-${n++}`;
+ }
+
+ const hash = await hashPassword(password);
+ const r = await db.one(
+ `INSERT INTO installers (slug, email, password_hash, business_name, contact_name, city, state, zip, status, tier, subscription_status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending','basic','inactive')
+ RETURNING id`,
+ [slug, email, hash, businessName, contactName, city, stateAbbr, zip]
+ );
+
+ await regenerateSession(req);
+ req.session.installerId = r.id;
+ res.redirect('/admin?welcome=1');
+ } catch (err) { next(err); }
+});
+
+router.post('/logout', (req, res) => {
+ req.session.destroy(() => res.redirect('/'));
+});
+
+module.exports = router;
diff --git a/routes/claim.js b/routes/claim.js
new file mode 100644
index 0000000..6123c07
--- /dev/null
+++ b/routes/claim.js
@@ -0,0 +1,198 @@
+// Claim flow for unclaimed listings.
+//
+// Per DATA_POLICY.md §6, three claim mechanisms are supported:
+// 1. Email verification — token sent to a public info@/hello@/contact@ on
+// the studio's website domain. Studio clicks → claim_status='claimed',
+// redirect to /signup.
+// 2. Domain TXT record — studio adds nph-verify=<token> to their DNS.
+// A daily cron (not implemented in this file) checks and flips status.
+// 3. Manual — staff approval, also flips status.
+//
+// In this MVP we wire flow #1 (email) and a manual /admin path. DNS proof is
+// stubbed for now.
+
+const express = require('express');
+const crypto = require('crypto');
+const db = require('../lib/db');
+const email = require('../lib/email');
+const { escapeHtml } = require('../lib/utils');
+
+const router = express.Router();
+
+const ALLOWED_LOCAL_PARTS = new Set(['info', 'hello', 'contact', 'admin', 'support', 'office']);
+
+function regenerateSession(req) {
+ return new Promise((resolve, reject) => {
+ req.session.regenerate(err => err ? reject(err) : resolve());
+ });
+}
+
+function timingSafeEqualStr(a, b) {
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
+ if (a.length !== b.length) return false;
+ try { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } catch { return false; }
+}
+
+// GET /installer/:slug/claim — claim landing
+router.get('/installer/:slug/claim', async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT id, slug, business_name, city, state, country, website, instagram_handle,
+ claim_status, source_name, source_url
+ FROM installers WHERE slug = $1`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+ if (installer.claim_status === 'claimed') {
+ return res.redirect('/installer/' + installer.slug);
+ }
+ res.render('public/claim', {
+ title: `Claim ${installer.business_name} · National Paper Hangers`,
+ installer, error: null, sent: false
+ });
+ } catch (err) { next(err); }
+});
+
+// POST /installer/:slug/claim — start verification by email
+router.post('/installer/:slug/claim', async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT id, slug, business_name, website, claim_status FROM installers WHERE slug=$1`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+ if (installer.claim_status === 'claimed') return res.redirect('/installer/' + installer.slug);
+
+ const claimerEmail = (req.body.email || '').toLowerCase().trim();
+ const [localPart, emailDomain] = claimerEmail.split('@');
+
+ let websiteDomain = null;
+ if (installer.website) {
+ try { websiteDomain = new URL(installer.website).hostname.replace(/^www\./, ''); } catch {}
+ }
+
+ const domainOK = !!(websiteDomain && emailDomain && emailDomain.toLowerCase() === websiteDomain.toLowerCase());
+ const localPartOK = !!(localPart && ALLOWED_LOCAL_PARTS.has(localPart.toLowerCase()));
+
+ if (!domainOK || !localPartOK) {
+ return res.status(400).render('public/claim', {
+ title: `Claim ${installer.business_name} · National Paper Hangers`,
+ installer,
+ error: websiteDomain
+ ? `Please use a public business email at @${websiteDomain} — accepted: ${[...ALLOWED_LOCAL_PARTS].map(p => p + '@').join(', ')}. If you don't have one, contact info@nationalpaperhangers.com for manual review.`
+ : `No website is on file for this listing — please email info@nationalpaperhangers.com to claim manually.`,
+ sent: false
+ });
+ }
+
+ const token = crypto.randomBytes(32).toString('base64url');
+ await db.query(
+ `UPDATE installers SET claim_status='pending_claim', claim_token=$2, claim_token_at=now() WHERE id=$1`,
+ [installer.id, token]
+ );
+
+ const publicUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 9765}`;
+ const verifyUrl = `${publicUrl}/installer/${installer.slug}/claim/verify?token=${token}`;
+
+ await email.sendEmail({
+ to: claimerEmail,
+ subject: `Verify your claim of ${installer.business_name}`,
+ html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <h1 style="font-size:22px">Confirm your claim</h1>
+ <p>You requested to claim the directory listing for <strong>${escapeHtml(installer.business_name)}</strong> on National Paper Hangers.</p>
+ <p>Click below to confirm and start setting up your studio account:</p>
+ <p><a href="${verifyUrl}" style="display:inline-block;padding:14px 22px;background:#0e0e0e;color:#fff;text-decoration:none">Confirm claim</a></p>
+ <p style="font-size:12px;color:#666">If you did not request this, you can ignore this email — the listing will remain unclaimed.</p>
+ </div>`
+ });
+
+ res.render('public/claim', {
+ title: `Claim ${installer.business_name} · National Paper Hangers`,
+ installer, error: null, sent: true, sentTo: claimerEmail
+ });
+ } catch (err) { next(err); }
+});
+
+// GET /installer/:slug/claim/verify?token=...
+//
+// Single-use: the moment we accept a token we clear it from the row, so the
+// 24h TTL link can't be replayed. The session then carries claimingInstallerId
+// through to /claim/complete.
+router.get('/installer/:slug/claim/verify', async (req, res, next) => {
+ try {
+ const token = (req.query.token || '').trim();
+ if (!token) return res.status(400).render('public/error', { title: 'Invalid claim link', message: 'Missing token.' });
+
+ const installer = await db.one(
+ `SELECT id, slug, business_name, claim_status, claim_token, claim_token_at FROM installers WHERE slug=$1`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+ if (installer.claim_status === 'claimed') return res.redirect('/installer/' + installer.slug);
+
+ const issuedAt = installer.claim_token_at ? new Date(installer.claim_token_at).getTime() : 0;
+ const expired = Date.now() - issuedAt > 24 * 3600 * 1000;
+ const tokenOK = installer.claim_token && timingSafeEqualStr(installer.claim_token, token);
+
+ if (!tokenOK || expired) {
+ return res.status(400).render('public/error', { title: 'Claim link invalid or expired', message: 'Request a new claim link from the listing page.' });
+ }
+
+ // Single-use: clear the token now so the link can't be replayed.
+ // claim_status stays 'pending_claim' until the user finishes /complete.
+ await db.query(`UPDATE installers SET claim_token=NULL WHERE id=$1`, [installer.id]);
+
+ req.session.claimingInstallerId = installer.id;
+ res.redirect(`/installer/${installer.slug}/claim/complete`);
+ } catch (err) { next(err); }
+});
+
+// GET /installer/:slug/claim/complete — set password + finish
+router.get('/installer/:slug/claim/complete', async (req, res, next) => {
+ try {
+ if (!req.session.claimingInstallerId) return res.redirect('/installer/' + req.params.slug + '/claim');
+ const installer = await db.one(
+ 'SELECT id, slug, business_name, email FROM installers WHERE id=$1 AND slug=$2',
+ [req.session.claimingInstallerId, req.params.slug]
+ );
+ if (!installer) return res.redirect('/');
+ res.render('public/claim-complete', { title: `Finish claiming ${installer.business_name}`, installer, error: null });
+ } catch (err) { next(err); }
+});
+
+router.post('/installer/:slug/claim/complete', async (req, res, next) => {
+ try {
+ if (!req.session.claimingInstallerId) return res.redirect('/installer/' + req.params.slug + '/claim');
+ const installer = await db.one(
+ 'SELECT id, slug FROM installers WHERE id=$1 AND slug=$2',
+ [req.session.claimingInstallerId, req.params.slug]
+ );
+ if (!installer) return res.redirect('/');
+
+ const newEmail = (req.body.email || '').toLowerCase().trim();
+ const password = req.body.password || '';
+ if (!newEmail.includes('@') || password.length < 8) {
+ return res.status(400).render('public/claim-complete', { title: 'Finish claim', installer, error: 'Email and 8+ char password required' });
+ }
+ const dupe = await db.one('SELECT id FROM installers WHERE email=$1 AND id<>$2', [newEmail, installer.id]);
+ if (dupe) return res.status(400).render('public/claim-complete', { title: 'Finish claim', installer, error: 'That email already has an account.' });
+
+ const { hashPassword } = require('../lib/auth');
+ const hash = await hashPassword(password);
+ await db.query(
+ `UPDATE installers SET email=$2, password_hash=$3, claim_status='claimed', claimed_at=now(), status='pending', claim_token=NULL WHERE id=$1`,
+ [installer.id, newEmail, hash]
+ );
+
+ // Session-fixation defense: regenerate before assigning the authenticated identity.
+ await regenerateSession(req);
+ req.session.installerId = installer.id;
+ // Surface a one-time prompt on the dashboard nudging the new owner to
+ // set up Stripe Connect payouts. Survives the regenerate above because
+ // it's set after the new session is established.
+ req.session.flash = { connect_prompt: true };
+ res.redirect('/admin?welcome=1&claimed=1');
+ } catch (err) { next(err); }
+});
+
+module.exports = router;
diff --git a/routes/public.js b/routes/public.js
new file mode 100644
index 0000000..cf24feb
--- /dev/null
+++ b/routes/public.js
@@ -0,0 +1,351 @@
+const express = require('express');
+const db = require('../lib/db');
+const bookingToken = require('../lib/booking-token');
+const { requireInstaller } = require('../lib/auth');
+const router = express.Router();
+
+router.get('/', async (req, res, next) => {
+ try {
+ const featured = await db.many(
+ `SELECT id, slug, business_name, headline, city, state, market_segments, materials, accreditations, verified, claim_status
+ FROM installers
+ WHERE status = 'active' AND verified = true
+ ORDER BY tier DESC, updated_at DESC
+ LIMIT 6`
+ );
+ const stats = await db.one(
+ `SELECT
+ COUNT(*) FILTER (WHERE status='active' OR claim_status='unclaimed') AS total_listings,
+ COUNT(DISTINCT state) FILTER (WHERE state IS NOT NULL AND (status='active' OR claim_status='unclaimed')) AS state_count,
+ COUNT(*) FILTER (WHERE 'WIA Certified Installer' = ANY(accreditations)) AS accredited_count
+ FROM installers`
+ );
+ res.render('public/home', {
+ title: 'National Paper Hangers · Verified luxury wallcovering installers',
+ metaDescription: `Find a verified luxury wallcovering installer for your project. ${stats.total_listings || ''}+ studios across ${stats.state_count || ''} states. Concierge matching, scheduling, and project oversight for hand-painted, silk, grasscloth, and mural installs.`,
+ canonicalPath: '/',
+ featured, stats
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/find', async (req, res, next) => {
+ try {
+ const q = (req.query.q || '').trim();
+ const segment = (req.query.segment || '').trim();
+ const material = (req.query.material || '').trim();
+ const zip = (req.query.zip || '').trim();
+ const state = (req.query.state || '').trim();
+
+ // Show active (claimed + verified) AND unclaimed listings (the seeded ones).
+ // Unclaimed are visible so trade buyers can discover studios; the listing
+ // gets a "Claim this listing" badge in the UI per DATA_POLICY §6.
+ const where = [`(status = 'active' OR claim_status = 'unclaimed')`];
+ const params = [];
+ let i = 1;
+
+ if (q) {
+ params.push(`%${q.toLowerCase()}%`);
+ where.push(`(LOWER(business_name) LIKE $${i} OR LOWER(headline) LIKE $${i} OR LOWER(bio) LIKE $${i} OR LOWER(city) LIKE $${i})`);
+ i++;
+ }
+ if (segment) {
+ params.push(segment);
+ where.push(`$${i} = ANY(market_segments)`);
+ i++;
+ }
+ if (material) {
+ params.push(material);
+ where.push(`$${i} = ANY(materials)`);
+ i++;
+ }
+ if (zip) {
+ params.push(zip);
+ where.push(`zip = $${i}`);
+ i++;
+ }
+ if (state) {
+ params.push(state.toUpperCase());
+ where.push(`state = $${i}`);
+ i++;
+ }
+
+ const sql = `
+ SELECT i.id, i.slug, i.business_name, i.headline, i.bio, i.city, i.state, i.zip,
+ i.market_segments, i.materials, i.brands_handled, i.accreditations,
+ i.verified, i.tier, i.response_time_hours, i.claim_status, i.instagram_handle, i.website,
+ COALESCE((
+ SELECT COUNT(*) FROM installer_credentials c
+ WHERE c.installer_id = i.id AND c.ops_verified = true
+ ), 0)::int AS verified_brands_count,
+ COALESCE((
+ SELECT array_agg(c.brand ORDER BY c.display_order, c.year_issued DESC NULLS LAST)
+ FROM installer_credentials c
+ WHERE c.installer_id = i.id AND c.ops_verified = true
+ LIMIT 3
+ ), ARRAY[]::text[]) AS verified_brands
+ FROM installers i
+ WHERE ${where.join(' AND ')}
+ ORDER BY (CASE WHEN claim_status = 'claimed' OR claim_status = 'self' THEN 0 ELSE 1 END),
+ (CASE tier WHEN 'enterprise' THEN 0 WHEN 'signature' THEN 1 WHEN 'pro' THEN 2 ELSE 3 END),
+ verified DESC, updated_at DESC
+ LIMIT 200`;
+
+ const installers = await db.many(sql, params);
+
+ // Dynamic title reflects active filters for SEO long-tail.
+ const titleParts = [];
+ if (state) titleParts.push(state.toUpperCase());
+ if (material) titleParts.push(material.replace(/_/g, ' '));
+ if (segment) titleParts.push(segment.replace(/_/g, ' '));
+ const title = titleParts.length
+ ? `${titleParts.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' · ')} wallpaper installers · National Paper Hangers`
+ : 'Find a verified wallpaper installer · National Paper Hangers';
+ const metaDescription = `Browse ${installers.length}+ verified wallcovering installers${state ? ' in ' + state.toUpperCase() : ' across the United States'}. Filter by city, ZIP, market segment, and material. Luxury residential, hospitality, and commercial.`;
+
+ res.render('public/find', {
+ title, metaDescription,
+ canonicalPath: '/find',
+ installers,
+ q, segment, material, zip, state
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/installer/:slug', async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT * FROM installers WHERE slug = $1 AND (status = 'active' OR claim_status = 'unclaimed')`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+ const portfolio = await db.many(
+ `SELECT * FROM installer_portfolio WHERE installer_id = $1 ORDER BY display_order, id`,
+ [installer.id]
+ );
+ const reviews = await db.many(
+ `SELECT * FROM installer_reviews WHERE installer_id = $1 AND published = true ORDER BY published_at DESC LIMIT 8`,
+ [installer.id]
+ );
+
+ // Brand-Trained credentials (UX idea #2) — only ops-verified entries are
+ // shown publicly. Studios can self-add via /admin/profile but must be
+ // reviewed before badges appear on the public profile (prevents spam).
+ const credentials = await db.many(
+ `SELECT brand, credential_type, year_issued, year_expires, certificate_url
+ FROM installer_credentials
+ WHERE installer_id = $1 AND ops_verified = true
+ ORDER BY display_order, year_issued DESC NULLS LAST`,
+ [installer.id]
+ );
+
+ // Acceptance-rate signal (UX idea #7) — rolling 365 days. Hidden when
+ // total < 10 to avoid noisy small-sample numbers on new studios.
+ let acceptance = null;
+ const acc = await db.one(
+ `SELECT
+ COUNT(*) FILTER (WHERE status IN ('confirmed','completed'))::int AS accepted,
+ COUNT(*) FILTER (WHERE status = 'declined')::int AS declined
+ FROM bookings
+ WHERE installer_id = $1
+ AND created_at >= now() - interval '365 days'
+ AND status IN ('confirmed','completed','declined')`,
+ [installer.id]
+ );
+ if (acc) {
+ const total = (acc.accepted || 0) + (acc.declined || 0);
+ if (total >= 10) {
+ acceptance = {
+ rate: Math.round((acc.accepted / total) * 100),
+ accepted: acc.accepted,
+ declined: acc.declined,
+ total
+ };
+ }
+ }
+ const cityState = [installer.city, installer.state].filter(Boolean).join(', ');
+ const matSummary = (installer.materials || []).slice(0, 3).map(m => m.replace(/_/g, ' ')).join(', ');
+ // Pick the template the studio chose (defaults to 'editorial' via SQL default).
+ // Fallback to the legacy 'public/installer' view if template_slug isn't set
+ // or names something we don't ship — so existing rows never 500.
+ var TEMPLATES = ['editorial','trade-pro','concierge','studio','heritage','bilingue'];
+ // ?_preview=<slug> lets the /admin/template chooser iframe render any of
+ // the 6 layouts against this studio's data without saving the choice.
+ var previewSlug = String(req.query._preview || '').toLowerCase();
+ var chosen = TEMPLATES.indexOf(previewSlug) !== -1 ? previewSlug : installer.template_slug;
+ var tpl = (chosen && TEMPLATES.indexOf(chosen) !== -1)
+ ? 'public/installer-tpl-' + chosen
+ : 'public/installer';
+ res.render(tpl, {
+ title: cityState
+ ? `${installer.business_name} — Wallpaper Installer in ${cityState} · National Paper Hangers`
+ : `${installer.business_name} · National Paper Hangers`,
+ metaDescription: `${installer.business_name}${cityState ? ' is a verified wallcovering installer in ' + cityState : ' — wallcovering installer'}.${matSummary ? ' Specializing in ' + matSummary + '.' : ''} Book a consultation on National Paper Hangers.`,
+ canonicalPath: `/installer/${installer.slug}`,
+ installer, portfolio, reviews, acceptance, credentials
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/installer/:slug/book', async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT id, slug, business_name, city, state, response_time_hours, tier,
+ website, instagram_handle, phone
+ FROM installers WHERE slug = $1 AND status = 'active'`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+
+ // Free-tier installers don't get the calendar; route to a contact-only page
+ const calendarEnabled = ['pro','signature','enterprise'].includes(installer.tier);
+ res.render('public/book', {
+ title: `Book ${installer.business_name} · National Paper Hangers`,
+ metaDescription: `Schedule a consultation with ${installer.business_name}${installer.city ? ' in ' + installer.city + ', ' + installer.state : ''}. Pick from open slots in their live calendar.`,
+ canonicalPath: `/installer/${installer.slug}/book`,
+ installer, calendarEnabled,
+ // Stripe publishable key for the booking-deposit UI. Null in mock mode
+ // — book.ejs renders a "test mode" callout instead of the card element.
+ stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || null
+ });
+ } catch (err) { next(err); }
+});
+
+// /bookings/:uuid — booking detail page with PII (customer email, phone,
+// address). Access is gated three ways:
+// (a) ?t=<HMAC sig> — the link emailed in the booking confirmation
+// (b) authenticated installer who owns the booking
+// (c) future: a customer login flow, not built yet
+//
+// Without one of those, return 404 (don't leak existence).
+router.get('/bookings/:uuid', async (req, res, next) => {
+ try {
+ const booking = await db.one(
+ `SELECT b.*, i.business_name, i.slug AS installer_slug, i.phone AS installer_phone, i.id AS installer_id_join
+ FROM bookings b
+ JOIN installers i ON i.id = b.installer_id
+ WHERE b.uuid = $1`,
+ [req.params.uuid]
+ );
+ if (!booking) return res.status(404).render('public/404', { title: 'Not Found' });
+
+ const tokenOK = bookingToken.verify(booking.uuid, req.query.t || '');
+ const ownerOK = !!(req.installer && req.installer.id === booking.installer_id_join);
+ if (!tokenOK && !ownerOK) {
+ return res.status(404).render('public/404', { title: 'Not Found' });
+ }
+
+ res.render('public/booking', { title: 'Your booking · National Paper Hangers', booking });
+ } catch (err) { next(err); }
+});
+
+// XML sitemap. Lists home + key static pages + every installer slug that's
+// either active (claimed) or unclaimed (publicly visible per directory policy).
+router.get('/sitemap.xml', async (req, res, next) => {
+ try {
+ const rows = await db.many(
+ `SELECT slug, GREATEST(updated_at, created_at) AS lastmod
+ FROM installers
+ WHERE status = 'active' OR claim_status = 'unclaimed'
+ ORDER BY lastmod DESC`
+ );
+ const base = (process.env.PUBLIC_URL || 'https://www.nationalpaperhangers.com').replace(/\/+$/, '');
+ const fmt = d => (d ? new Date(d).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10));
+ const urls = [
+ `<url><loc>${base}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>`,
+ `<url><loc>${base}/find</loc><changefreq>daily</changefreq><priority>0.9</priority></url>`,
+ `<url><loc>${base}/map</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`,
+ `<url><loc>${base}/for-installers</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>`,
+ `<url><loc>${base}/about</loc><changefreq>monthly</changefreq><priority>0.5</priority></url>`,
+ ...rows.map(r =>
+ `<url><loc>${base}/installer/${encodeURIComponent(r.slug)}</loc><lastmod>${fmt(r.lastmod)}</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>`
+ )
+ ].join('\n ');
+ res.set('Content-Type', 'application/xml; charset=utf-8');
+ res.send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n ${urls}\n</urlset>\n`);
+ } catch (err) { next(err); }
+});
+
+// Map view: pin every geocoded installer on a Leaflet+OSM map. Pins cluster
+// at low zoom; click → popup with mini-card + links to profile and book/visit.
+router.get('/map', async (req, res, next) => {
+ try {
+ const stats = await db.one(
+ `SELECT
+ COUNT(*) FILTER (WHERE latitude IS NOT NULL AND longitude IS NOT NULL) AS pinned,
+ COUNT(*) FILTER (WHERE status='active' OR claim_status='unclaimed') AS total
+ FROM installers`
+ );
+ res.render('public/map', {
+ title: 'Map of installers · National Paper Hangers',
+ metaDescription: `Browse ${stats.pinned || 0}+ verified luxury wallcovering installers on the map. Click any pin to view the studio profile, request a quote, or book an on-site visit.`,
+ canonicalPath: '/map',
+ path: '/map',
+ stats
+ });
+ } catch (err) { next(err); }
+});
+
+// Lightweight JSON for the map. Only public, non-PII fields. Cached 60s
+// at the edge — pins barely change between requests.
+router.get('/api/installers.geo', async (req, res, next) => {
+ try {
+ const rows = await db.many(
+ `SELECT i.id, i.slug, i.business_name, i.city, i.state,
+ i.latitude::float8 AS lat, i.longitude::float8 AS lng,
+ i.tier, i.verified, i.claim_status,
+ i.market_segments, i.materials,
+ COALESCE((
+ SELECT COUNT(*) FROM installer_credentials c
+ WHERE c.installer_id = i.id AND c.ops_verified = true
+ ), 0)::int AS verified_brands_count,
+ COALESCE((
+ SELECT array_agg(c.brand ORDER BY c.display_order, c.year_issued DESC NULLS LAST)
+ FROM installer_credentials c
+ WHERE c.installer_id = i.id AND c.ops_verified = true
+ LIMIT 2
+ ), ARRAY[]::text[]) AS verified_brands
+ FROM installers i
+ WHERE i.latitude IS NOT NULL AND i.longitude IS NOT NULL
+ AND (i.status='active' OR i.claim_status='unclaimed')
+ ORDER BY i.tier DESC, i.verified DESC, i.id`
+ );
+ const segmentImage = require('../lib/segment-image');
+ for (const r of rows) {
+ const img = segmentImage.pickSegmentImage(r);
+ r.thumb = img ? img.file : null;
+ }
+ res.set('cache-control', 'public, max-age=60');
+ res.json({ count: rows.length, installers: rows });
+ } catch (err) { next(err); }
+});
+
+// Healthcheck. Used by nginx upstream + uptime monitors after Kamatera deploy.
+// Returns 200 + JSON status when DB ping succeeds, 503 otherwise. No locals
+// needed (no template render) so it stays cheap on every poll.
+router.get('/healthz', async (req, res) => {
+ try {
+ await db.query('SELECT 1');
+ res.set('cache-control', 'no-store').json({ ok: true, ts: Date.now() });
+ } catch (err) {
+ res.status(503).set('cache-control', 'no-store').json({ ok: false, error: 'db_unreachable' });
+ }
+});
+
+router.get('/about', (req, res) => {
+ res.render('public/about', { title: 'About · National Paper Hangers' });
+});
+
+router.get('/for-installers', (req, res) => {
+ res.render('public/for-installers', { title: 'For installers · National Paper Hangers' });
+});
+
+router.get('/privacy', (req, res) => {
+ res.render('public/legal', { title: 'Privacy · National Paper Hangers', kind: 'privacy' });
+});
+
+router.get('/terms', (req, res) => {
+ res.render('public/legal', { title: 'Terms · National Paper Hangers', kind: 'terms' });
+});
+
+module.exports = router;
diff --git a/routes/unsubscribe.js b/routes/unsubscribe.js
new file mode 100644
index 0000000..7e4fdbf
--- /dev/null
+++ b/routes/unsubscribe.js
@@ -0,0 +1,70 @@
+// Public unsubscribe endpoint. CAN-SPAM requires that opt-out be functional
+// without requiring login or a per-message reply.
+//
+// GET /unsubscribe?token=… → render confirmation page (and pre-process the opt-out
+// so the user sees the result on first paint).
+// POST /unsubscribe?token=… → idempotent — adds the recipient to comms_suppression.
+// Also handles RFC 8058 "List-Unsubscribe-Post" 1-click flow.
+//
+// Skips CSRF since the One-Click flow can't include a CSRF token (it's a
+// mailclient-initiated POST). The HMAC token IS the auth.
+
+const express = require('express');
+const compliance = require('../lib/compliance');
+
+const router = express.Router();
+
+router.use((req, res, next) => { req.skipCsrf = true; next(); });
+
+router.get('/', async (req, res, next) => {
+ try {
+ const token = (req.query.token || '').trim();
+ if (!token) {
+ return res.status(400).render('public/error', {
+ title: 'Invalid unsubscribe link',
+ message: 'Missing token. If you got an unwanted email from us, reply to it and we will remove you manually.'
+ });
+ }
+ const row = await compliance.consumeUnsubscribeToken(token);
+ if (!row) {
+ return res.status(400).render('public/error', {
+ title: 'Unsubscribe link invalid',
+ message: 'This unsubscribe link is unrecognized. Email info@nationalpaperhangers.com and we will remove you within 24 hours.'
+ });
+ }
+ await compliance.addSuppression({
+ channel: row.channel,
+ identifier: row.identifier,
+ reason: 'unsubscribe',
+ source: 'unsub_link',
+ installerId: row.installer_id,
+ notes: row.campaign ? `campaign=${row.campaign}` : null
+ });
+ res.render('public/unsubscribed', {
+ title: 'Unsubscribed · National Paper Hangers',
+ identifier: row.identifier,
+ campaign: row.campaign
+ });
+ } catch (err) { next(err); }
+});
+
+// RFC 8058 1-click: mail client POSTs with body `List-Unsubscribe=One-Click`.
+router.post('/', async (req, res, next) => {
+ try {
+ const token = (req.query.token || req.body.token || '').trim();
+ if (!token) return res.status(400).json({ error: 'missing_token' });
+ const row = await compliance.consumeUnsubscribeToken(token);
+ if (!row) return res.status(400).json({ error: 'invalid_token' });
+ await compliance.addSuppression({
+ channel: row.channel,
+ identifier: row.identifier,
+ reason: 'unsubscribe',
+ source: 'list_unsubscribe_post',
+ installerId: row.installer_id,
+ notes: row.campaign ? `campaign=${row.campaign}` : null
+ });
+ return res.status(200).json({ ok: true });
+ } catch (err) { next(err); }
+});
+
+module.exports = router;
diff --git a/routes/webhooks.js b/routes/webhooks.js
new file mode 100644
index 0000000..d64762f
--- /dev/null
+++ b/routes/webhooks.js
@@ -0,0 +1,209 @@
+const express = require('express');
+const db = require('../lib/db');
+const stripe = require('../lib/stripe');
+const router = express.Router();
+
+const IS_PROD = process.env.NODE_ENV === 'production';
+// Explicit dev escape hatch — only honored when NODE_ENV !== 'production'.
+const ACCEPT_UNSIGNED_DEV = process.env.STRIPE_DEV_ACCEPT_UNSIGNED === '1' && !IS_PROD;
+
+router.post('/', async (req, res) => {
+ const sig = req.headers['stripe-signature'];
+
+ let event;
+ try {
+ event = stripe.constructWebhookEvent(req.body, sig);
+ } catch (err) {
+ console.warn('[webhook] sig fail', err.message);
+ return res.status(400).send(`bad signature: ${err.message}`);
+ }
+
+ if (!event) {
+ // Stripe SDK not configured — fail closed in production, accept ack only
+ // in dev with the explicit STRIPE_DEV_ACCEPT_UNSIGNED=1 flag set.
+ if (!ACCEPT_UNSIGNED_DEV) {
+ console.warn('[webhook] not_configured', { isProd: IS_PROD });
+ return res.status(503).json({ error: 'webhook_not_configured' });
+ }
+ return res.json({ received: true, mocked: true });
+ }
+
+ // Single transaction wrapping the audit-log insert + installer update.
+ // The audit-log UNIQUE on stripe_event_id acts as the idempotency lock:
+ // if the INSERT conflicts, we know we've already processed this event and
+ // skip the side-effects.
+ const client = await db.pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ const audit = await client.query(
+ `INSERT INTO subscription_events (stripe_event_id, event_type, payload)
+ VALUES ($1, $2, $3) ON CONFLICT (stripe_event_id) DO NOTHING
+ RETURNING id`,
+ [event.id, event.type, event]
+ );
+
+ if (audit.rowCount === 0) {
+ // Already processed — short-circuit cleanly. Stripe will see 200 and stop retrying.
+ await client.query('COMMIT');
+ client.release();
+ return res.json({ received: true, idempotent: true });
+ }
+
+ const obj = event.data && event.data.object;
+ const meta = (obj && obj.metadata) || {};
+ const installerId = meta.installer_id ? parseInt(meta.installer_id, 10) : null;
+
+ switch (event.type) {
+ case 'checkout.session.completed': {
+ if (installerId && obj.subscription) {
+ // Tier from metadata only on first checkout — subsequent
+ // subscription.updated events derive from price.id (see below).
+ const tier = meta.tier || 'pro';
+ await client.query(
+ `UPDATE installers
+ SET stripe_customer_id=$2, stripe_subscription_id=$3,
+ subscription_status='active', tier=$4
+ WHERE id=$1`,
+ [installerId, obj.customer, obj.subscription, tier]
+ );
+ }
+ break;
+ }
+
+ case 'customer.subscription.created':
+ case 'customer.subscription.updated': {
+ const sub = obj;
+ const status = sub.status || 'unknown';
+ const periodEnd = sub.current_period_end ? new Date(sub.current_period_end * 1000) : null;
+
+ // Derive tier from the price ID on the subscription's first item.
+ // Subscription.updated events don't carry our metadata, so the price
+ // is the only canonical signal.
+ let tier = null;
+ try {
+ const priceId = sub.items && sub.items.data && sub.items.data[0] && sub.items.data[0].price && sub.items.data[0].price.id;
+ const m = stripe.tierFromPriceId(priceId);
+ if (m) tier = m.tier;
+ } catch (e) { /* fall through with tier=null */ }
+
+ if (sub.customer) {
+ if (tier) {
+ await client.query(
+ `UPDATE installers
+ SET subscription_status=$2, current_period_end=$3, tier=$4
+ WHERE stripe_customer_id=$1`,
+ [sub.customer, status, periodEnd, tier]
+ );
+ } else {
+ // Couldn't map price → tier (price ID not in env). Update status
+ // but don't blindly flip the tier.
+ await client.query(
+ `UPDATE installers
+ SET subscription_status=$2, current_period_end=$3
+ WHERE stripe_customer_id=$1`,
+ [sub.customer, status, periodEnd]
+ );
+ console.warn('[webhook] no tier match for price', { event: event.id, customer: sub.customer });
+ }
+ }
+ break;
+ }
+
+ case 'customer.subscription.deleted': {
+ const sub = obj;
+ if (sub.customer) {
+ await client.query(
+ `UPDATE installers
+ SET subscription_status='canceled', tier='basic'
+ WHERE stripe_customer_id=$1`,
+ [sub.customer]
+ );
+ }
+ break;
+ }
+
+ // ---------- Booking deposit lifecycle ----------
+ case 'payment_intent.succeeded':
+ case 'payment_intent.payment_failed':
+ case 'payment_intent.canceled': {
+ const pi = obj;
+ const bookingUuid = (pi.metadata && pi.metadata.booking_uuid) || null;
+ const piInstallerId = (pi.metadata && pi.metadata.installer_id)
+ ? parseInt(pi.metadata.installer_id, 10) : null;
+
+ // Mirror to payment_events audit table (separate from subscription_events).
+ await client.query(
+ `INSERT INTO payment_events
+ (stripe_event_id, event_type, payment_intent_id, booking_uuid,
+ installer_id, amount_cents, application_fee_cents, payload)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+ ON CONFLICT (stripe_event_id) DO NOTHING`,
+ [
+ event.id, event.type, pi.id, bookingUuid, piInstallerId,
+ pi.amount || null, pi.application_fee_amount || null, event
+ ]
+ );
+
+ if (bookingUuid) {
+ if (event.type === 'payment_intent.succeeded') {
+ await client.query(
+ `UPDATE bookings
+ SET deposit_status = 'paid',
+ status = CASE WHEN status='pending' THEN 'confirmed' ELSE status END,
+ confirmed_at = COALESCE(confirmed_at, now())
+ WHERE uuid = $1`,
+ [bookingUuid]
+ );
+ } else if (event.type === 'payment_intent.payment_failed') {
+ await client.query(
+ `UPDATE bookings SET deposit_status = 'failed' WHERE uuid = $1`,
+ [bookingUuid]
+ );
+ } else if (event.type === 'payment_intent.canceled') {
+ await client.query(
+ `UPDATE bookings SET deposit_status = 'canceled' WHERE uuid = $1`,
+ [bookingUuid]
+ );
+ }
+ }
+ break;
+ }
+
+ // ---------- Connect account status mirror ----------
+ case 'account.updated': {
+ const acct = obj;
+ if (acct.id) {
+ await client.query(
+ `UPDATE installers
+ SET stripe_account_charges_enabled = $2,
+ stripe_account_payouts_enabled = $3,
+ stripe_account_onboarded_at = CASE
+ WHEN $2 = true AND stripe_account_onboarded_at IS NULL THEN now()
+ ELSE stripe_account_onboarded_at
+ END
+ WHERE stripe_account_id = $1`,
+ [acct.id, !!acct.charges_enabled, !!acct.payouts_enabled]
+ );
+ }
+ break;
+ }
+
+ default:
+ break;
+ }
+
+ await client.query('COMMIT');
+ client.release();
+ return res.json({ received: true });
+
+ } catch (err) {
+ try { await client.query('ROLLBACK'); } catch {}
+ client.release();
+ // Return 500 so Stripe retries — fail-open on idempotency, not on errors.
+ console.error('[webhook] handler error', err);
+ return res.status(500).json({ error: 'handler_failed' });
+ }
+});
+
+module.exports = router;
diff --git a/scripts/deploy-kamatera-https.sh b/scripts/deploy-kamatera-https.sh
new file mode 100755
index 0000000..190d626
--- /dev/null
+++ b/scripts/deploy-kamatera-https.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+#
+# One-shot prod deploy for nationalpaperhangers.com on Kamatera (45.61.58.125).
+#
+# What it does, in order:
+# 1. Diagnoses why pm2 `national-paper-hangers` is stuck (env / port / DB)
+# 2. Syncs the local code tree to /root/Projects/NationalPaperHangers
+# 3. Runs migrations 012 + 013 against the prod PG
+# 4. Installs / reuses an nginx vhost for nationalpaperhangers.com proxying
+# to 127.0.0.1:9765 with /uploads served direct from disk
+# 5. Issues a Let's Encrypt cert via certbot --nginx (with --redirect)
+# 6. Reloads nginx and restarts the pm2 process under the new env
+# 7. Smoke-tests the live URLs
+#
+# Run this from the LOCAL Mac after SSH access is approved:
+# bash ~/Projects/NationalPaperHangers/scripts/deploy-kamatera-https.sh
+#
+# Env knobs you can override at the top:
+# NPH_REMOTE_HOST default: root@45.61.58.125
+# NPH_REMOTE_DIR default: /root/Projects/NationalPaperHangers
+# NPH_PORT default: 9765
+# NPH_DOMAIN default: nationalpaperhangers.com
+#
+# This script is intentionally idempotent — re-running is safe.
+
+set -euo pipefail
+
+REMOTE="${NPH_REMOTE_HOST:-root@45.61.58.125}"
+RDIR="${NPH_REMOTE_DIR:-/root/Projects/NationalPaperHangers}"
+PORT="${NPH_PORT:-9765}"
+DOMAIN="${NPH_DOMAIN:-nationalpaperhangers.com}"
+EMAIL="${NPH_LE_EMAIL:-steve@designerwallcoverings.com}"
+LOCAL_DIR="$HOME/Projects/NationalPaperHangers"
+
+say() { printf '\n[deploy] %s\n' "$*"; }
+
+# --- 1. Diagnose ----------------------------------------------------------
+say "Diagnosing pm2 + nginx state on $REMOTE..."
+ssh "$REMOTE" "pm2 describe national-paper-hangers 2>&1 | head -25; echo '---'; pm2 logs national-paper-hangers --lines 30 --nostream 2>&1 | tail -40 || true; echo '---NGINX VHOSTS---'; ls /etc/nginx/sites-enabled/ | grep -iE 'paper|nph' || echo '(no nph vhost)'"
+
+# --- 2. Sync code ---------------------------------------------------------
+say "Rsync'ing local code → $REMOTE:$RDIR (excluding node_modules + .env)..."
+rsync -avz --delete \
+ --exclude node_modules --exclude .env --exclude .env.local \
+ --exclude tmp --exclude '*.log' --exclude .git \
+ "$LOCAL_DIR/" "$REMOTE:$RDIR/"
+
+# --- 3. Migrations --------------------------------------------------------
+say "Running migrations 012 + 013 against prod PG..."
+ssh "$REMOTE" "cd $RDIR && \
+ source .env 2>/dev/null || true; \
+ psql \"\$DATABASE_URL\" -f db/migrations/012_installer_templates.sql; \
+ psql \"\$DATABASE_URL\" -f db/migrations/013_consumer_accounts_and_brief.sql"
+
+# --- 4. nginx vhost -------------------------------------------------------
+say "Installing nginx vhost for $DOMAIN → 127.0.0.1:$PORT..."
+ssh "$REMOTE" "cat > /etc/nginx/sites-available/$DOMAIN <<'CONF'
+server {
+ listen 80;
+ listen [::]:80;
+ server_name $DOMAIN www.$DOMAIN;
+
+ # Let's Encrypt webroot challenge (used on first issuance only).
+ location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
+
+ # Static uploads served directly (avoid passing image bytes through Node).
+ location /uploads/ {
+ alias $RDIR/public/uploads/;
+ access_log off;
+ expires 30d;
+ add_header Cache-Control \"public, immutable\";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:$PORT;
+ proxy_set_header Host \$host;
+ proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \$scheme;
+ client_max_body_size 12m;
+ }
+}
+CONF
+mkdir -p /var/www/letsencrypt
+ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/$DOMAIN
+# Defensive: remove any *.bak / *.old that might shadow this (per Steve's rule).
+find /etc/nginx/sites-enabled/ -maxdepth 1 -type f \( -name '*.bak' -o -name '*.old' \) -delete
+nginx -t && systemctl reload nginx"
+
+# --- 5. Restart pm2 (so the new code is live before certbot probes /) -----
+say "Restarting pm2 national-paper-hangers under updated env..."
+ssh "$REMOTE" "cd $RDIR && pm2 restart national-paper-hangers --update-env || pm2 start ecosystem.kamatera.config.js --only national-paper-hangers --update-env"
+
+sleep 2
+ssh "$REMOTE" "curl -s -o /dev/null -w '127.0.0.1:$PORT/ → %{http_code}\n' http://127.0.0.1:$PORT/"
+
+# --- 6. Issue cert --------------------------------------------------------
+say "Issuing Let's Encrypt cert for $DOMAIN + www.$DOMAIN..."
+ssh "$REMOTE" "certbot --nginx -d $DOMAIN -d www.$DOMAIN \
+ --non-interactive --agree-tos -m $EMAIL --redirect"
+
+# --- 7. Smoke test --------------------------------------------------------
+say "Smoke testing live URLs..."
+sleep 1
+for path in / /find /installer/atelier-bond-nyc /installer/atelier-bond-nyc/book; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' "https://$DOMAIN$path" --max-time 10 || echo "TIMEOUT")
+ printf ' https://%s%s → %s\n' "$DOMAIN" "$path" "$code"
+done
+say "Done."
diff --git a/scripts/deploy-kamatera-v2.sh b/scripts/deploy-kamatera-v2.sh
new file mode 100755
index 0000000..ae66cdf
--- /dev/null
+++ b/scripts/deploy-kamatera-v2.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env bash
+#
+# v2 — fixes the two failures from v1 (SESSION_SECRET missing, DATABASE_URL
+# missing). Idempotent and non-destructive: if a value already exists in the
+# remote .env, we keep it; we only ADD missing keys.
+#
+# What v2 adds over v1:
+# • Generates a strong SESSION_SECRET if absent
+# • Probes Kamatera PG for a national_paper_hangers DB; creates DB + role
+# if absent (uses local-trust postgres superuser, no exposed password)
+# • Writes a non-clobbering .env merge using a small awk pass on the host
+# • Then runs the same nginx + cert + restart sequence as v1
+#
+# Usage (after Steve says "go"):
+# bash ~/Projects/NationalPaperHangers/scripts/deploy-kamatera-v2.sh
+
+set -euo pipefail
+
+REMOTE="${NPH_REMOTE_HOST:-root@45.61.58.125}"
+RDIR="${NPH_REMOTE_DIR:-/root/Projects/NationalPaperHangers}"
+PORT="${NPH_PORT:-9765}"
+DOMAIN="${NPH_DOMAIN:-nationalpaperhangers.com}"
+EMAIL="${NPH_LE_EMAIL:-steve@designerwallcoverings.com}"
+LOCAL_DIR="$HOME/Projects/NationalPaperHangers"
+
+# Generated locally (never echoed); pushed to Kamatera only if .env doesn't
+# already have a SESSION_SECRET.
+SESSION_SECRET="$(openssl rand -hex 48)"
+
+# DB password: same pattern — generate once locally, push only if absent.
+DB_PASSWORD="$(openssl rand -hex 24)"
+
+say() { printf '\n[deploy] %s\n' "$*"; }
+
+# --- 1. Sync code (idempotent — same as v1) -------------------------------
+say "Sync code → $REMOTE:$RDIR ..."
+rsync -avz --delete \
+ --exclude node_modules --exclude .env --exclude .env.local \
+ --exclude tmp --exclude '*.log' --exclude .git \
+ "$LOCAL_DIR/" "$REMOTE:$RDIR/" >/dev/null
+echo " done"
+
+# --- 2. Provision PG role + database if absent ----------------------------
+say "Probing PG for national_paper_hangers DB + role ..."
+# Generated DB_PASSWORD pushed via stdin so it never lands in argv.
+ssh "$REMOTE" "cd $RDIR && \
+ ROLE_EXISTS=\$(sudo -u postgres psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='nph'\" 2>/dev/null); \
+ DB_EXISTS=\$(sudo -u postgres psql -tAc \"SELECT 1 FROM pg_database WHERE datname='national_paper_hangers'\" 2>/dev/null); \
+ if [ \"\$ROLE_EXISTS\" != \"1\" ]; then \
+ read -r PWD; \
+ sudo -u postgres psql -v ON_ERROR_STOP=1 -c \"CREATE ROLE nph LOGIN PASSWORD '\$PWD';\" >/dev/null; \
+ echo 'created role nph'; \
+ else echo 'role nph already exists, keeping existing password'; fi; \
+ if [ \"\$DB_EXISTS\" != \"1\" ]; then \
+ sudo -u postgres createdb --owner=nph national_paper_hangers; \
+ sudo -u postgres psql -d national_paper_hangers -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;'; \
+ echo 'created database'; \
+ else echo 'database already exists'; fi" <<< "$DB_PASSWORD"
+
+# --- 3. Merge .env on Kamatera (non-clobbering) ---------------------------
+say "Merging .env on Kamatera (only adds missing keys) ..."
+# Build the desired-key block locally; ship via stdin; awk merges in-place.
+ENV_BLOCK="$(cat <<EOF
+NODE_ENV=production
+PORT=$PORT
+PUBLIC_URL=https://$DOMAIN
+SESSION_SECRET=$SESSION_SECRET
+PGHOST=127.0.0.1
+PGPORT=5432
+PGUSER=nph
+PGPASSWORD=$DB_PASSWORD
+PGDATABASE=national_paper_hangers
+DATABASE_URL=postgres://nph:$DB_PASSWORD@127.0.0.1:5432/national_paper_hangers
+EMAIL_FROM=info@$DOMAIN
+EMAIL_FROM_NAME=National Paper Hangers
+EOF
+)"
+
+ssh "$REMOTE" "cd $RDIR && \
+ touch .env && \
+ awk -v new=\"\$(cat)\" 'BEGIN{n=split(new,arr,\"\\n\"); for(i=1;i<=n;i++){split(arr[i],kv,\"=\"); if(kv[1]) want[kv[1]]=arr[i]}} { for(k in want){ if(\$0 ~ \"^\"k\"=\"){ delete want[k]; break } } print } END { for(k in want) print want[k] }' .env > .env.new && \
+ mv .env.new .env && \
+ chmod 600 .env && \
+ echo \"keys now in .env: \$(grep -c '^[A-Z]' .env)\"" <<< "$ENV_BLOCK"
+
+# --- 4. Migrations --------------------------------------------------------
+say "Running all migrations against fresh DB ..."
+ssh "$REMOTE" "cd $RDIR && \
+ set -a && . .env && set +a && \
+ for f in db/schema.sql db/migrations/*.sql; do \
+ echo \" applying \$f\"; \
+ psql \"\$DATABASE_URL\" -v ON_ERROR_STOP=1 -q -f \"\$f\" 2>&1 | tail -3; \
+ done"
+
+# --- 5. nginx vhost -------------------------------------------------------
+say "Installing nginx vhost for $DOMAIN ..."
+ssh "$REMOTE" "cat > /etc/nginx/sites-available/$DOMAIN <<'CONF'
+server {
+ listen 80;
+ listen [::]:80;
+ server_name $DOMAIN www.$DOMAIN;
+ location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
+ location /uploads/ {
+ alias $RDIR/public/uploads/;
+ access_log off; expires 30d;
+ add_header Cache-Control \"public, immutable\";
+ }
+ location / {
+ proxy_pass http://127.0.0.1:$PORT;
+ proxy_set_header Host \$host;
+ proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \$scheme;
+ client_max_body_size 12m;
+ }
+}
+CONF
+mkdir -p /var/www/letsencrypt
+ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/$DOMAIN
+find /etc/nginx/sites-enabled/ -maxdepth 1 -type f \( -name '*.bak' -o -name '*.old' \) -delete
+nginx -t && systemctl reload nginx"
+
+# --- 6. Install node deps + restart pm2 -----------------------------------
+say "npm install + pm2 restart ..."
+ssh "$REMOTE" "cd $RDIR && npm install --omit=dev --silent 2>&1 | tail -3 && \
+ (pm2 restart national-paper-hangers --update-env 2>/dev/null || \
+ pm2 start ecosystem.kamatera.config.js --env production --update-env)"
+
+sleep 3
+say "Local probe via 127.0.0.1 ..."
+ssh "$REMOTE" "curl -s -o /dev/null -w '127.0.0.1:$PORT/ → %{http_code}\\n' http://127.0.0.1:$PORT/" || true
+
+# --- 7. Issue cert --------------------------------------------------------
+say "certbot --nginx for $DOMAIN + www.$DOMAIN ..."
+ssh "$REMOTE" "certbot --nginx -d $DOMAIN -d www.$DOMAIN \
+ --non-interactive --agree-tos -m $EMAIL --redirect --keep-until-expiring"
+
+# --- 8. Smoke test --------------------------------------------------------
+say "Live URLs ..."
+sleep 2
+for path in / /find /installer/atelier-bond-nyc /installer/atelier-bond-nyc/book; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' "https://$DOMAIN$path" --max-time 12 || echo "TIMEOUT")
+ printf ' https://%s%s → %s\n' "$DOMAIN" "$path" "$code"
+done
+say "Done."
diff --git a/scripts/deploy-kamatera-v3-data.sh b/scripts/deploy-kamatera-v3-data.sh
new file mode 100755
index 0000000..f1e2bc0
--- /dev/null
+++ b/scripts/deploy-kamatera-v3-data.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+#
+# v3 — post-deploy data + LinkedIn env push.
+#
+# After v2 stood up the prod stack, the Kamatera DB is empty and .env
+# doesn't yet have the LinkedIn OAuth creds. This finishes both:
+# 1. Merges LINKEDIN_CLIENT_ID + LINKEDIN_CLIENT_SECRET into Kamatera .env
+# (idempotent — won't clobber existing values)
+# 2. pg_dump's the studio data tables from local Mac PG → ship over SSH →
+# pg_restore into Kamatera DB
+# 3. pm2 restart to pick up new env
+# 4. Smoke test installer pages
+#
+# Tables migrated: installers, installer_portfolio, installer_credentials,
+# installer_reviews, installer_availability, installer_time_off.
+# (consumer_accounts, bookings, consumer_leads stay empty on prod — those
+# are user-generated and shouldn't carry over.)
+
+set -euo pipefail
+
+REMOTE="${NPH_REMOTE_HOST:-root@45.61.58.125}"
+RDIR="${NPH_REMOTE_DIR:-/root/Projects/NationalPaperHangers}"
+DOMAIN="${NPH_DOMAIN:-nationalpaperhangers.com}"
+LOCAL_DB="${NPH_LOCAL_DB:-national_paper_hangers}"
+
+say() { printf '\n[deploy-v3] %s\n' "$*"; }
+
+# --- 1. LinkedIn env push (read from local .env, merge into prod .env) ---
+say "Merging LINKEDIN_* into Kamatera .env (non-clobbering) ..."
+LI_BLOCK="$(grep -E '^LINKEDIN_(CLIENT_ID|CLIENT_SECRET)=' "$HOME/Projects/NationalPaperHangers/.env" || true)"
+if [ -z "$LI_BLOCK" ]; then
+ echo " (no LINKEDIN_* values in local .env — skipping)"
+else
+ ssh "$REMOTE" "cd $RDIR && \
+ awk -v new=\"\$(cat)\" 'BEGIN{n=split(new,arr,\"\\n\"); for(i=1;i<=n;i++){split(arr[i],kv,\"=\"); if(kv[1]) want[kv[1]]=arr[i]}} { for(k in want){ if(\$0 ~ \"^\"k\"=\"){ delete want[k]; break } } print } END { for(k in want) print want[k] }' .env > .env.new && \
+ mv .env.new .env && chmod 600 .env && \
+ echo \" .env now has \$(grep -c '^LINKEDIN_' .env) LINKEDIN_* keys\"" <<< "$LI_BLOCK"
+fi
+
+# --- 2. pg_dump → Kamatera ------------------------------------------------
+say "Dumping studio tables from local PG ..."
+DUMP_FILE="$(mktemp -t nph-data-XXXXXX.sql)"
+pg_dump --data-only --no-owner --no-privileges \
+ --table=installers \
+ --table=installer_portfolio \
+ --table=installer_credentials \
+ --table=installer_reviews \
+ --table=installer_availability \
+ --table=installer_time_off \
+ "$LOCAL_DB" > "$DUMP_FILE"
+ROWS=$(grep -c '^COPY ' "$DUMP_FILE" || true)
+SIZE=$(wc -c <"$DUMP_FILE" | tr -d ' ')
+echo " $ROWS COPY blocks, $SIZE bytes"
+
+say "Loading dump into Kamatera DB ..."
+# Truncate target tables first so re-running is idempotent and we don't get
+# PK collisions from the fresh-after-v2 row count of zero (defensive).
+ssh "$REMOTE" "cd $RDIR && set -a && . .env && set +a && \
+ psql \"\$DATABASE_URL\" -v ON_ERROR_STOP=1 -c 'TRUNCATE installers, installer_portfolio, installer_credentials, installer_reviews, installer_availability, installer_time_off RESTART IDENTITY CASCADE;'"
+
+ssh "$REMOTE" "cd $RDIR && set -a && . .env && set +a && \
+ psql \"\$DATABASE_URL\" -v ON_ERROR_STOP=1" < "$DUMP_FILE" 2>&1 | tail -8
+rm -f "$DUMP_FILE"
+
+# --- 3. Verify counts -----------------------------------------------------
+say "Row counts on prod after restore ..."
+ssh "$REMOTE" "cd $RDIR && set -a && . .env && set +a && \
+ psql \"\$DATABASE_URL\" -At -c \"SELECT 'installers',COUNT(*) FROM installers
+ UNION ALL SELECT 'portfolio',COUNT(*) FROM installer_portfolio
+ UNION ALL SELECT 'credentials',COUNT(*) FROM installer_credentials
+ UNION ALL SELECT 'reviews',COUNT(*) FROM installer_reviews;\""
+
+# --- 4. Restart pm2 to pick up new env ------------------------------------
+say "pm2 restart with --update-env ..."
+ssh "$REMOTE" "pm2 restart national-paper-hangers --update-env" 2>&1 | tail -3
+sleep 2
+
+# --- 5. Smoke ------------------------------------------------------------
+say "Smoke testing installer pages on https://$DOMAIN ..."
+SLUG=$(ssh "$REMOTE" "cd $RDIR && set -a && . .env && set +a && psql \"\$DATABASE_URL\" -tAc \"SELECT slug FROM installers WHERE claim_status='unclaimed' ORDER BY id LIMIT 1\"")
+echo " test slug: $SLUG"
+for path in "/installer/$SLUG" "/installer/$SLUG/book" "/find?segment=luxury_residential"; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' "https://$DOMAIN$path" --max-time 10 || echo "TIMEOUT")
+ printf ' https://%s%s → %s\n' "$DOMAIN" "$path" "$code"
+done
+
+say "Done."
diff --git a/scripts/deploy-kamatera.sh b/scripts/deploy-kamatera.sh
new file mode 100755
index 0000000..e4944a7
--- /dev/null
+++ b/scripts/deploy-kamatera.sh
@@ -0,0 +1,213 @@
+#!/usr/bin/env bash
+# deploy-kamatera.sh
+# Syncs the National Paper Hangers app from Mac2 to Kamatera.
+# Covers steps B–E of DEPLOY_KAMATERA.md (PG dump → scp → rsync → npm ci → pm2 start).
+#
+# SAFETY RULES:
+# 1. DRY-RUN by default. Pass --commit to actually execute.
+# 2. Refuses to touch nginx if protected domains are present.
+# 3. Never runs `pm2 save` on Mac2.
+# 4. Never modifies any file outside /root/Projects/NationalPaperHangers on Kamatera.
+#
+# Usage:
+# bash scripts/deploy-kamatera.sh # dry-run (prints commands, no-ops)
+# bash scripts/deploy-kamatera.sh --commit # executes for real
+
+set -euo pipefail
+
+# ── Config ────────────────────────────────────────────────────────────────────
+KAMATERA_HOST="root@45.61.58.125"
+REMOTE_DIR="/root/Projects/NationalPaperHangers"
+LOCAL_DIR="/Users/stevestudio2/Projects/NationalPaperHangers"
+LOCAL_DB="national_paper_hangers"
+REMOTE_DB="national_paper_hangers"
+DUMP_FILE="/tmp/nph_deploy_$(date +%Y%m%d_%H%M%S).dump"
+TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
+LOG_FILE="/tmp/deploy-kamatera-nph-$(date +%Y%m%d_%H%M%S).log"
+
+# Protected DNS domains — if found in any nginx config this script would touch,
+# abort immediately. Steve's standing rule: never touch these.
+PROTECTED_DOMAINS=("designerwallcoverings.com" "studentdebtcrisis.org" "studentdebtcrisiscenter.org")
+
+# ── Parse flags ───────────────────────────────────────────────────────────────
+COMMIT=false
+for arg in "$@"; do
+ case "$arg" in
+ --commit) COMMIT=true ;;
+ --help|-h)
+ echo "Usage: $0 [--commit]"
+ echo " Default: dry-run. Add --commit to execute for real."
+ exit 0
+ ;;
+ *)
+ echo "Unknown flag: $arg" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# ── Logging ───────────────────────────────────────────────────────────────────
+exec > >(tee -a "$LOG_FILE") 2>&1
+echo "═══════════════════════════════════════════════════════════"
+echo " National Paper Hangers → Kamatera deploy script"
+echo " Started: $TIMESTAMP"
+echo " Mode: $([ "$COMMIT" = true ] && echo 'COMMIT (live)' || echo 'DRY-RUN')"
+echo " Log: $LOG_FILE"
+echo "═══════════════════════════════════════════════════════════"
+echo ""
+
+# ── Helper: run or echo ───────────────────────────────────────────────────────
+run() {
+ if [ "$COMMIT" = true ]; then
+ echo "[EXEC] $*"
+ eval "$@"
+ else
+ echo "[DRY ] $*"
+ fi
+}
+
+# ── GUARD: protected-domain check ─────────────────────────────────────────────
+# Inspect the nginx sites-available config we intend to create. We never read
+# or write existing Kamatera nginx configs; this guard is a belt-and-suspenders
+# check that the *new* nginx block file doesn't accidentally reference a
+# protected domain (e.g. if this script is ever adapted for another project).
+NEW_NGINX_BLOCK_CONTENT="nationalpaperhangers.com" # only domain this script targets
+for domain in "${PROTECTED_DOMAINS[@]}"; do
+ if echo "$NEW_NGINX_BLOCK_CONTENT" | grep -qi "$domain"; then
+ echo "[ABORT] Protected domain '$domain' detected in target nginx config. Stopping." >&2
+ exit 1
+ fi
+done
+echo "[OK] Protected-domain guard passed."
+echo ""
+
+# ── STEP B: pg_dump on Mac2 ───────────────────────────────────────────────────
+echo "── Step B: pg_dump (Mac2 → $DUMP_FILE)"
+# Full custom-format dump. Use --schema-only for a test deploy that skips data.
+# To schema-only: add --schema-only flag to the pg_dump command below.
+run "pg_dump -Fc -d '$LOCAL_DB' -f '$DUMP_FILE'"
+if [ "$COMMIT" = true ]; then
+ if [ ! -f "$DUMP_FILE" ]; then
+ echo "[FAIL] Dump file not created: $DUMP_FILE" >&2
+ exit 1
+ fi
+ DUMP_SIZE=$(du -sh "$DUMP_FILE" | cut -f1)
+ echo "[OK] Dump created: $DUMP_FILE ($DUMP_SIZE)"
+fi
+echo ""
+
+# ── STEP B (continued): scp dump to Kamatera ─────────────────────────────────
+echo "── Step B (cont): scp dump to Kamatera /tmp/"
+REMOTE_DUMP="/tmp/$(basename "$DUMP_FILE")"
+run "scp '$DUMP_FILE' '$KAMATERA_HOST:$REMOTE_DUMP'"
+echo ""
+
+# ── STEP B (continued): pg_restore on Kamatera ───────────────────────────────
+echo "── Step B (cont): pg_restore on Kamatera"
+# --no-owner: owner on Kamatera is root (or whatever PG superuser). The app
+# connects as a dedicated pg user; ownership is set by the CREATE ROLE step
+# in the runbook.
+# -c drops objects that exist first (idempotent for re-deploys).
+# --if-exists prevents error if a table doesn't exist on first run.
+run "ssh '$KAMATERA_HOST' \"pg_restore --no-owner -c --if-exists -d $REMOTE_DB '$REMOTE_DUMP' 2>&1 | tail -20\""
+echo ""
+
+# ── STEP C: rsync source code ─────────────────────────────────────────────────
+echo "── Step C: rsync code to Kamatera"
+# Excludes:
+# node_modules/ — reinstalled on target with npm ci
+# .env — never transfer; build the .env on Kamatera manually
+# .git/ — not needed on server
+# /tmp dump files — don't loop back
+run "rsync -avz --delete \
+ --exclude='node_modules/' \
+ --exclude='.env' \
+ --exclude='.git/' \
+ --exclude='*.dump' \
+ --exclude='*.log' \
+ '$LOCAL_DIR/' \
+ '$KAMATERA_HOST:$REMOTE_DIR/'"
+echo ""
+
+# ── STEP C (continued): npm ci on Kamatera ────────────────────────────────────
+echo "── Step C (cont): npm ci --omit=dev on Kamatera"
+# --omit=dev skips nodemon/supertest. Production only.
+run "ssh '$KAMATERA_HOST' \"cd $REMOTE_DIR && npm ci --omit=dev\""
+echo ""
+
+# ── STEP D: .env reminder ─────────────────────────────────────────────────────
+echo "── Step D: .env check"
+echo "[INFO] Verifying .env exists on Kamatera (required vars listed in DEPLOY_KAMATERA.md §D)."
+if [ "$COMMIT" = true ]; then
+ ENV_EXISTS=$(ssh "$KAMATERA_HOST" "test -f '$REMOTE_DIR/.env' && echo yes || echo no")
+ if [ "$ENV_EXISTS" != "yes" ]; then
+ echo "[WARN] .env not found at $REMOTE_DIR/.env on Kamatera."
+ echo " Create it before starting pm2. See DEPLOY_KAMATERA.md §D for required vars."
+ echo " Continuing — pm2 start will fail fast if SESSION_SECRET is absent in prod mode."
+ else
+ # Spot-check that SESSION_SECRET is present (value hidden).
+ SESSION_SET=$(ssh "$KAMATERA_HOST" "grep -c 'SESSION_SECRET=' '$REMOTE_DIR/.env' || true")
+ if [ "$SESSION_SET" -lt 1 ]; then
+ echo "[WARN] SESSION_SECRET not found in .env — server will refuse to boot in production."
+ else
+ echo "[OK] .env present; SESSION_SECRET key found."
+ fi
+ fi
+else
+ echo "[DRY ] Would check for $REMOTE_DIR/.env and SESSION_SECRET on Kamatera."
+fi
+echo ""
+
+# ── STEP E: pm2 start ─────────────────────────────────────────────────────────
+echo "── Step E: pm2 start / restart on Kamatera"
+# If the process already exists, `pm2 reload` does a zero-downtime restart.
+# If it doesn't exist, `pm2 start` creates it.
+# pm2 save runs on KAMATERA only — never on Mac2. Saves the Kamatera pm2 dump.
+run "ssh '$KAMATERA_HOST' \"
+ cd $REMOTE_DIR
+ if pm2 describe national-paper-hangers > /dev/null 2>&1; then
+ echo '[pm2] Process exists — reloading...'
+ pm2 reload national-paper-hangers --update-env
+ else
+ echo '[pm2] First start...'
+ pm2 start $REMOTE_DIR/ecosystem.kamatera.config.js --env production
+ fi
+ pm2 save
+ sleep 2
+ pm2 show national-paper-hangers | grep -E 'status|restart|memory|uptime'
+\""
+echo ""
+
+# ── STEP E (continued): nginx stale-config check ─────────────────────────────
+echo "── Step E (cont): nginx sites-enabled stale-file check"
+echo "[INFO] Checking for *.bak and *.old files in /etc/nginx/sites-enabled/ on Kamatera."
+echo " These cause silent cross-routing bugs (caught 2026-04-30 on Site Factory)."
+if [ "$COMMIT" = true ]; then
+ STALE=$(ssh "$KAMATERA_HOST" "find /etc/nginx/sites-enabled/ -name '*.bak' -o -name '*.old' 2>/dev/null || true")
+ if [ -n "$STALE" ]; then
+ echo "[WARN] Stale nginx configs found:"
+ echo "$STALE"
+ echo " Remove them manually: ssh $KAMATERA_HOST 'rm <file>' then nginx -t && systemctl reload nginx"
+ else
+ echo "[OK] No stale .bak/.old nginx configs found."
+ fi
+else
+ echo "[DRY ] Would run: find /etc/nginx/sites-enabled/ -name '*.bak' -o -name '*.old'"
+fi
+echo ""
+
+# ── Summary ───────────────────────────────────────────────────────────────────
+echo "═══════════════════════════════════════════════════════════"
+if [ "$COMMIT" = true ]; then
+ echo " Deploy COMPLETE."
+ echo " Next steps (manual — see DEPLOY_KAMATERA.md):"
+ echo " F. Install nginx server block + nginx -t && reload"
+ echo " G. certbot --nginx for SSL"
+ echo " H. DNS cutover (GATED on new CF zone-create token)"
+ echo " I. Smoke: curl + npm test against live URL"
+else
+ echo " DRY-RUN COMPLETE — no changes made."
+ echo " Rerun with --commit to execute."
+fi
+echo " Log saved to: $LOG_FILE"
+echo "═══════════════════════════════════════════════════════════"
diff --git a/scripts/enrich-instagram.js b/scripts/enrich-instagram.js
new file mode 100644
index 0000000..29fda06
--- /dev/null
+++ b/scripts/enrich-instagram.js
@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+// IG enrichment for unclaimed installers.
+//
+// Per DATA_POLICY.md §3 — IG handle ONLY if it appears as a clickable link on
+// the studio's own public website. We do NOT touch instagram.com directly.
+//
+// For each unclaimed installer with a website and no IG handle yet:
+// 1. Skip if website's domain is in directory_optout
+// 2. fetch() the homepage with our identifying UA
+// 3. Look for instagram.com/<handle> patterns in the HTML
+// 4. Filter out non-profile paths (p, reel, tv, explore, etc.)
+// 5. UPDATE installer with first valid handle
+// 6. 3-second delay between requests
+//
+// Usage:
+// node scripts/enrich-instagram.js # dry-run
+// node scripts/enrich-instagram.js --commit # write IG handles
+// node scripts/enrich-instagram.js --max=100 # cap (default 50)
+
+require('dotenv').config();
+const db = require('../lib/db');
+
+const UA = 'NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)';
+const SOURCE = 'studio_site_ig';
+const REQUEST_DELAY_MS = 3000;
+const COMMIT = process.argv.includes('--commit');
+const MAX = parseInt((process.argv.find(a => a.startsWith('--max=')) || '--max=50').split('=')[1], 10);
+
+// Reserved Instagram paths that aren't profile handles
+const NON_HANDLE_PATHS = new Set(['p', 'reel', 'reels', 'tv', 'explore', 'about', 'developer', 'directory', 'accounts', 'web']);
+// Site-builder / platform / generic handles that show up in footers but aren't the studio
+const PLATFORM_HANDLES = new Set([
+ 'squarespace', 'wix', 'wixcom', 'godaddy', 'wordpress', 'weebly', 'shopify',
+ 'bigcommerce', 'webflow', 'duda', 'hostgator', 'wpengine',
+ 'meta', 'instagram', 'facebook', 'whatsapp',
+ 'google', 'youtube', 'twitter', 'tiktok', 'linkedin', 'pinterest',
+ 'mailchimp', 'constantcontact'
+]);
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function logRequest(url, status, bytes, durMs, error) {
+ await db.query(
+ `INSERT INTO scrape_log (source, url, http_status, bytes, duration_ms, error)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ [SOURCE, url, status, bytes, durMs, error]
+ );
+}
+
+async function loadOptOutSet() {
+ const rows = await db.many(`SELECT domain FROM directory_optout`);
+ return new Set(rows.map(r => (r.domain || '').toLowerCase()));
+}
+
+function extractIG(html) {
+ if (!html) return null;
+ // Match instagram.com/<handle> where handle is letters/digits/dot/underscore
+ // Stop at next quote, slash, ?, #, or whitespace
+ const re = /(?:https?:)?\/\/(?:www\.)?instagram\.com\/([A-Za-z0-9._]{1,30})(?=["'\/?#\s>])/g;
+ let m;
+ const handles = new Set();
+ while ((m = re.exec(html)) !== null) {
+ const h = m[1];
+ if (h.length < 2) continue;
+ const hl = h.toLowerCase();
+ if (NON_HANDLE_PATHS.has(hl)) continue;
+ if (PLATFORM_HANDLES.has(hl)) continue;
+ handles.add(h);
+ }
+ // Prefer the first non-generic handle
+ for (const h of handles) return h;
+ return null;
+}
+
+async function main() {
+ console.log(`[ig-enrich] start · commit=${COMMIT} · max=${MAX}`);
+
+ const targets = await db.many(
+ `SELECT id, slug, business_name, website
+ FROM installers
+ WHERE claim_status='unclaimed'
+ AND website IS NOT NULL
+ AND website <> ''
+ AND instagram_handle IS NULL
+ ORDER BY id
+ LIMIT $1`,
+ [MAX]
+ );
+
+ console.log(`[ig-enrich] ${targets.length} candidates`);
+
+ // Preload all opt-out domains once — avoids an N+1 query per installer.
+ const optOutSet = await loadOptOutSet();
+
+ let found = 0, missed = 0, skipped = 0, failed = 0;
+ for (const t of targets) {
+ let host;
+ try { host = new URL(t.website).hostname.replace(/^www\./, ''); } catch {
+ console.log(` · ${t.business_name}: bad URL — skip`);
+ skipped++;
+ continue;
+ }
+ if (optOutSet.has(host.toLowerCase())) { console.log(` · ${t.business_name}: opted out`); skipped++; continue; }
+
+ const t0 = Date.now();
+ let status = 0, html = null, bytes = 0, err = null;
+ try {
+ const r = await fetch(t.website, {
+ headers: { 'user-agent': UA, accept: 'text/html' },
+ signal: AbortSignal.timeout(15000),
+ redirect: 'follow'
+ });
+ status = r.status;
+ if (r.ok) html = await r.text();
+ bytes = html ? html.length : 0;
+ } catch (e) {
+ err = e.message;
+ }
+ await logRequest(t.website, status, bytes, Date.now() - t0, err);
+
+ if (!html) {
+ console.log(` ✗ ${t.business_name} (${host}): ${status} ${err || ''}`);
+ failed++;
+ await sleep(REQUEST_DELAY_MS);
+ continue;
+ }
+
+ const handle = extractIG(html);
+ if (!handle) {
+ console.log(` · ${t.business_name} (${host}): no IG`);
+ missed++;
+ } else {
+ console.log(` ✓ ${t.business_name} (${host}) → @${handle}`);
+ found++;
+ if (COMMIT) {
+ await db.query('UPDATE installers SET instagram_handle=$2 WHERE id=$1', [t.id, handle]);
+ }
+ }
+
+ await sleep(REQUEST_DELAY_MS);
+ }
+
+ console.log(`[ig-enrich] done · found=${found} missed=${missed} skipped=${skipped} failed=${failed}`);
+ if (failed / Math.max(1, targets.length) > 0.10) {
+ console.warn(`[ig-enrich] FAILURE-RATE-WARNING: failure rate exceeded 10%`);
+ }
+ await db.pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/fetch-pd-images.js b/scripts/fetch-pd-images.js
new file mode 100644
index 0000000..26d53c7
--- /dev/null
+++ b/scripts/fetch-pd-images.js
@@ -0,0 +1,215 @@
+#!/usr/bin/env node
+// Fetch public-domain interior/wallcovering imagery from Wikimedia Commons.
+// Strict rule: PD or CC0 only. Anything else gets dropped. Steve's standing
+// "no stock images" rule applies — Unsplash/Pexels/Getty NEVER appear here.
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+const OUT_DIR = path.join(ROOT, 'public', 'img', 'segments');
+const MANIFEST = path.join(OUT_DIR, 'manifest.json');
+
+const UA = 'NationalPaperHangers/0.1 (https://nationalpaperhangers.com; ops@nationalpaperhangers.com)';
+
+const SEGMENTS = {
+ luxury_residential: [
+ 'drawing room interior wallpaper',
+ 'Victorian parlor interior',
+ 'Edwardian drawing room',
+ 'historic interior wallpaper',
+ ],
+ hospitality: [
+ 'art deco hotel lobby',
+ 'historic hotel interior',
+ 'luxury hotel lobby interior',
+ ],
+ museum: [
+ 'museum interior decoration',
+ 'historic dining room interior',
+ 'period room museum',
+ ],
+ hand_painted: [
+ 'hand painted wallpaper',
+ 'chinoiserie wallpaper',
+ 'panoramic wallpaper Zuber',
+ ],
+ grasscloth: [
+ 'grasscloth wallcovering',
+ 'natural fiber wall covering',
+ 'bamboo wall covering interior',
+ ],
+ silk: [
+ 'silk damask wallpaper',
+ 'damask wallpaper interior',
+ 'historic silk wall hanging',
+ ],
+ mural: [
+ 'mural wallpaper interior',
+ 'scenic wallpaper panorama',
+ 'fresco interior decoration',
+ ],
+ generic: [
+ 'wallpaper sample',
+ 'wallpaper pattern',
+ 'wallpaper sidewall',
+ 'wall covering pattern',
+ 'historic interior decoration',
+ 'antique interior decoration',
+ 'wallpaper roll Cooper Hewitt',
+ ],
+};
+
+const PER_QUERY_LIMIT = 12;
+const TARGET_PER_SEGMENT = 14;
+const THUMB_W = 1200;
+
+const PD_HINTS = ['public domain', 'cc0', 'pd-old', 'pd-us', 'pd-art', 'cc-zero'];
+
+function isPublicDomain(meta) {
+ if (!meta) return false;
+ const license = (meta.LicenseShortName?.value || meta.UsageTerms?.value || '').toLowerCase();
+ const tmpl = (meta.LicenseUrl?.value || '').toLowerCase();
+ if (PD_HINTS.some((h) => license.includes(h))) return true;
+ if (tmpl.includes('publicdomain') || tmpl.includes('zero/1.0')) return true;
+ return false;
+}
+
+async function searchCommons(query) {
+ const params = new URLSearchParams({
+ action: 'query',
+ format: 'json',
+ prop: 'imageinfo',
+ generator: 'search',
+ gsrsearch: `${query} filetype:bitmap`,
+ gsrnamespace: '6',
+ gsrlimit: String(PER_QUERY_LIMIT),
+ iiprop: 'url|extmetadata|size|mime',
+ iiurlwidth: String(THUMB_W),
+ });
+ const url = `https://commons.wikimedia.org/w/api.php?${params}`;
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
+ if (!res.ok) throw new Error(`commons search ${res.status} for ${query}`);
+ const j = await res.json();
+ return Object.values(j?.query?.pages || {});
+}
+
+async function downloadBinary(url, dest) {
+ // Be polite to Wikimedia's CDN — bulk hammering triggers 429.
+ await new Promise(r => setTimeout(r, 350));
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
+ if (res.status === 429) {
+ // Back off and retry once.
+ await new Promise(r => setTimeout(r, 5000));
+ const res2 = await fetch(url, { headers: { 'User-Agent': UA } });
+ if (!res2.ok) throw new Error(`download ${res2.status} ${url}`);
+ const buf2 = Buffer.from(await res2.arrayBuffer());
+ await fs.writeFile(dest, buf2);
+ return buf2.length;
+ }
+ if (!res.ok) throw new Error(`download ${res.status} ${url}`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ await fs.writeFile(dest, buf);
+ return buf.length;
+}
+
+function safeName(title, idx) {
+ const base = title
+ .replace(/^File:/i, '')
+ .replace(/\.[a-z0-9]+$/i, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 60);
+ return `${String(idx).padStart(2, '0')}-${base || 'img'}.jpg`;
+}
+
+async function ensureDir(p) {
+ await fs.mkdir(p, { recursive: true });
+}
+
+async function loadManifest() {
+ try {
+ const txt = await fs.readFile(MANIFEST, 'utf8');
+ return JSON.parse(txt);
+ } catch {
+ return { generated_at: null, items: [] };
+ }
+}
+
+async function main() {
+ await ensureDir(OUT_DIR);
+ const manifest = await loadManifest();
+ const seen = new Set(manifest.items.map((it) => it.source_title));
+
+ for (const [segment, queries] of Object.entries(SEGMENTS)) {
+ const segDir = path.join(OUT_DIR, segment);
+ await ensureDir(segDir);
+ let kept = (await fs.readdir(segDir)).filter((f) => f.endsWith('.jpg')).length;
+ if (kept >= TARGET_PER_SEGMENT) {
+ console.log(`[${segment}] already has ${kept}, skipping`);
+ continue;
+ }
+
+ for (const q of queries) {
+ if (kept >= TARGET_PER_SEGMENT) break;
+ let pages;
+ try {
+ pages = await searchCommons(q);
+ } catch (e) {
+ console.warn(`[${segment}] search failed for "${q}": ${e.message}`);
+ continue;
+ }
+ for (const page of pages) {
+ if (kept >= TARGET_PER_SEGMENT) break;
+ const ii = page.imageinfo?.[0];
+ if (!ii) continue;
+ if (!ii.mime || !ii.mime.startsWith('image/')) continue;
+ if (seen.has(page.title)) continue;
+ if (!isPublicDomain(ii.extmetadata)) continue;
+ const thumbUrl = ii.thumburl || ii.url;
+ const idx = manifest.items.length + 1;
+ const filename = safeName(page.title, idx);
+ const dest = path.join(segDir, filename);
+ try {
+ const bytes = await downloadBinary(thumbUrl, dest);
+ if (bytes < 8000) {
+ await fs.unlink(dest);
+ continue;
+ }
+ const item = {
+ file: `/img/segments/${segment}/${filename}`,
+ segment,
+ query: q,
+ source_title: page.title,
+ source_url: ii.descriptionurl || `https://commons.wikimedia.org/wiki/${encodeURIComponent(page.title)}`,
+ license: ii.extmetadata?.LicenseShortName?.value || 'Public domain',
+ creator: (ii.extmetadata?.Artist?.value || '').replace(/<[^>]+>/g, '').trim() || null,
+ credit: (ii.extmetadata?.Credit?.value || '').replace(/<[^>]+>/g, '').trim() || null,
+ width: ii.thumbwidth || ii.width,
+ height: ii.thumbheight || ii.height,
+ bytes,
+ };
+ manifest.items.push(item);
+ seen.add(page.title);
+ kept += 1;
+ console.log(`[${segment}] +${filename} (${item.license})`);
+ } catch (e) {
+ console.warn(`[${segment}] download failed ${page.title}: ${e.message}`);
+ }
+ }
+ }
+ console.log(`[${segment}] final count: ${kept}`);
+ }
+
+ manifest.generated_at = new Date().toISOString();
+ await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2));
+ console.log(`\nmanifest written: ${manifest.items.length} items at ${MANIFEST}`);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/scripts/gen-secrets.js b/scripts/gen-secrets.js
new file mode 100644
index 0000000..986ab24
--- /dev/null
+++ b/scripts/gen-secrets.js
@@ -0,0 +1,34 @@
+#!/usr/bin/env node
+// Generate cryptographically random values for the three signing secrets.
+// Each is 48 bytes (64 base64 chars) — well past anything brute-forceable.
+//
+// Usage:
+// npm run gen-secrets # print suggested .env block
+// npm run gen-secrets -- --shell # print as `export FOO=…` lines
+// npm run gen-secrets -- --json # print as JSON for piping to secrets-manager
+//
+// Note: the three secrets serve different purposes — rotating SESSION_SECRET
+// invalidates active sessions, rotating BOOKING_SIGNING_SECRET breaks every
+// outstanding /bookings/:uuid?t=… link in customer inboxes, rotating
+// UNSUBSCRIBE_SIGNING_SECRET invalidates pending unsubscribe links. Set them
+// once at first deploy and only rotate intentionally.
+
+const crypto = require('crypto');
+
+const KEYS = ['SESSION_SECRET', 'BOOKING_SIGNING_SECRET', 'UNSUBSCRIBE_SIGNING_SECRET'];
+const ARGS = process.argv.slice(2);
+const FMT = ARGS.includes('--shell') ? 'shell' : ARGS.includes('--json') ? 'json' : 'env';
+
+function gen() { return crypto.randomBytes(48).toString('base64'); }
+const out = Object.fromEntries(KEYS.map(k => [k, gen()]));
+
+if (FMT === 'json') {
+ console.log(JSON.stringify(out, null, 2));
+} else if (FMT === 'shell') {
+ for (const [k, v] of Object.entries(out)) console.log(`export ${k}='${v}'`);
+} else {
+ console.log('# Paste into .env (or route via secrets-manager):');
+ for (const [k, v] of Object.entries(out)) console.log(`${k}=${v}`);
+ console.log('\n# Reminder: rotating BOOKING_SIGNING_SECRET breaks outstanding booking-view links.');
+ console.log('# Rotating UNSUBSCRIBE_SIGNING_SECRET breaks pending unsubscribe links. Rotate intentionally.');
+}
diff --git a/scripts/generate-ig-dm-drafts.js b/scripts/generate-ig-dm-drafts.js
new file mode 100644
index 0000000..367059c
--- /dev/null
+++ b/scripts/generate-ig-dm-drafts.js
@@ -0,0 +1,203 @@
+#!/usr/bin/env node
+// Generate Instagram DM drafts for unclaimed studios that are running paid ads.
+//
+// Why IG DM (not email) for this cohort?
+// The dream-team-fast panel ruled IG DM is the right channel for the studios
+// we caught running paid ads:
+// - It's a business channel, not commercial email — zero CAN-SPAM exposure.
+// - Higher response rate than cold email in the small luxury-trade niche.
+// - The recipient sees Steve's IG profile (signal of legitimacy) before
+// the message text — the message lands as one trade peer to another.
+//
+// IMPORTANT — this script is READ-ONLY.
+// - No DB writes.
+// - No IG API calls. Meta TOS forbids unauthorized automation; Steve sends
+// each DM by hand from his own IG account.
+// - We just generate the personalized text he'll paste.
+//
+// Usage:
+// node scripts/generate-ig-dm-drafts.js # CSV to stdout
+// node scripts/generate-ig-dm-drafts.js --out=batch.csv # CSV to file
+// node scripts/generate-ig-dm-drafts.js --min=2 # studios on 2+ paid platforms
+// node scripts/generate-ig-dm-drafts.js --state=CA # state filter
+// node scripts/generate-ig-dm-drafts.js --limit=5 # cap for the day's batch
+//
+// CSV columns (7):
+// business_name, ig_handle, city, state, paid_platforms, dm_message, claim_url
+//
+// The dm_message ends with the claim_url so Steve just pastes the whole field
+// into the IG conversation and hits send.
+
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const db = require('../lib/db');
+
+const ARGS = process.argv.slice(2);
+const arg = (k, def = null) => {
+ const a = ARGS.find(x => x.startsWith(`--${k}=`));
+ return a ? a.split('=').slice(1).join('=') : def;
+};
+
+const OUT = arg('out');
+const MIN = parseInt(arg('min', '1'), 10);
+const STATE = (arg('state') || '').toUpperCase() || null;
+const LIMIT = parseInt(arg('limit', '0'), 10); // 0 = no cap
+
+const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://nationalpaperhangers.com').replace(/\/+$/, '');
+
+// Map raw signal keys to human-readable platform names.
+const PLATFORM_LABEL = {
+ google_ads: 'Google Ads',
+ meta_pixel: 'Meta',
+ tiktok_pixel: 'TikTok',
+ pinterest_tag: 'Pinterest',
+ linkedin_insight: 'LinkedIn',
+ twitter_pixel: 'Twitter/X',
+ reddit_pixel: 'Reddit',
+ bing_uet: 'Bing',
+ snap_pixel: 'Snap'
+};
+
+function paidPlatforms(signals) {
+ if (!signals) return [];
+ return Object.entries(PLATFORM_LABEL)
+ .filter(([k]) => signals[k] === true)
+ .map(([, v]) => v);
+}
+
+// Format the platforms list as a readable English clause.
+// ['Google Ads'] → 'Google Ads'
+// ['Google Ads', 'Pinterest'] → 'Google Ads + Pinterest'
+// ['Google Ads','Meta','TikTok'] → 'Google Ads, Meta, and TikTok'
+function platformsClause(list) {
+ if (list.length === 0) return '';
+ if (list.length === 1) return list[0];
+ if (list.length === 2) return `${list[0]} + ${list[1]}`;
+ return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
+}
+
+function locationClause(city, state) {
+ if (city && state) return `over in ${city}`;
+ if (city) return `over in ${city}`;
+ if (state) return `out in ${state}`;
+ return 'on your end';
+}
+
+// Build the personalized 4-6 sentence DM. Hand-paste-ready.
+//
+// Voice notes:
+// - Trade-peer tone, not sales-pitch tone.
+// - Reference one specific signal we caught (the platforms they're spending on).
+// - Mention the Designer Wallcoverings → NPH context (DW already routes
+// trade installs to the directory — Steve owns DW).
+// - Soft CTA: "if it's interesting, here's the claim link" — no pressure.
+// - End with the claim URL so the whole field can be pasted at once.
+function buildDmMessage({ businessName, city, state, paidPlatforms: plats, claimUrl }) {
+ const platformsBit = plats.length
+ ? `noticed you're running ${platformsClause(plats)} — that tells me you're actively chasing the trade buyer`
+ : `saw you doing real marketing work`;
+ const where = locationClause(city, state);
+
+ // 5 sentences, ~75 words. Designed to read in 10 seconds.
+ return [
+ `Hey — Steve from National Paper Hangers (also Designer Wallcoverings).`,
+ `${platformsBit}, which is exactly the cohort our directory is built for.`,
+ `I listed ${businessName} ${where} so designers and hospitality buyers searching outside LA can find you, and DW is already routing trade installs to NPH listings.`,
+ `If you want to take control of the page — bio, portfolio, availability, accept self-booked consults — claim it here:`,
+ `${claimUrl}`,
+ `Basic listing is free, no obligation either way. Happy to answer anything.`
+ ].join(' ');
+}
+
+// CSV-escape a single field. RFC 4180.
+function csv(v) {
+ if (v == null) return '';
+ const s = String(v);
+ if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
+ return s;
+}
+
+function header() {
+ return [
+ '# IG DM drafts — National Paper Hangers',
+ '# Generated: ' + new Date().toISOString(),
+ '# How to use:',
+ '# 1. Open IG (web or phone) on Steve\'s personal/business account.',
+ '# 2. For each row, navigate to https://instagram.com/<ig_handle>',
+ '# 3. Click Message → paste the entire dm_message field → send.',
+ '# 4. Log the send in comms_send_audit (channel=\'ig_dm\').',
+ '# 5. 5/day cadence max — see outreach/IG_DM_PLAYBOOK.md.',
+ '# Rule: NEVER use the IG API to automate sends — Meta TOS forbids it.',
+ ''
+ ].join('\n');
+}
+
+async function main() {
+ const params = [MIN];
+ let where = `claim_status = 'unclaimed'
+ AND ad_signals IS NOT NULL
+ AND (ad_signals->>'paid_ads_count')::int >= $1
+ AND instagram_handle IS NOT NULL
+ AND instagram_handle <> ''
+ AND website IS NOT NULL
+ AND website <> ''`;
+ if (STATE) {
+ params.push(STATE);
+ where += ` AND state = $${params.length}`;
+ }
+
+ let query = `SELECT slug, business_name, city, state, website, instagram_handle, ad_signals
+ FROM installers
+ WHERE ${where}
+ ORDER BY (ad_signals->>'paid_ads_count')::int DESC, business_name ASC`;
+ if (LIMIT > 0) {
+ params.push(LIMIT);
+ query += ` LIMIT $${params.length}`;
+ }
+
+ const rows = await db.many(query, params);
+
+ const lines = [];
+ lines.push(header());
+ lines.push('business_name,ig_handle,city,state,paid_platforms,dm_message,claim_url');
+ for (const r of rows) {
+ const plats = paidPlatforms(r.ad_signals);
+ const claimUrl = `${PUBLIC_URL}/installer/${r.slug}/claim`;
+ const dm = buildDmMessage({
+ businessName: r.business_name,
+ city: r.city,
+ state: r.state,
+ paidPlatforms: plats,
+ claimUrl
+ });
+ lines.push([
+ csv(r.business_name),
+ csv('@' + r.instagram_handle),
+ csv(r.city),
+ csv(r.state),
+ csv(plats.join('|')),
+ csv(dm),
+ csv(claimUrl)
+ ].join(','));
+ }
+
+ const output = lines.join('\n') + '\n';
+
+ if (OUT) {
+ const abs = path.resolve(OUT);
+ fs.writeFileSync(abs, output, 'utf8');
+ // Stderr summary so a redirected stdout stays clean if someone passes both.
+ process.stderr.write(`[ig-dm-drafts] wrote ${rows.length} drafts → ${abs}\n`);
+ } else {
+ process.stdout.write(output);
+ process.stderr.write(`[ig-dm-drafts] generated ${rows.length} drafts\n`);
+ }
+
+ await db.pool.end();
+}
+
+main().catch(err => {
+ console.error('[ig-dm-drafts] error:', err.message);
+ process.exit(1);
+});
diff --git a/scripts/geocode-installers.js b/scripts/geocode-installers.js
new file mode 100644
index 0000000..8b8b491
--- /dev/null
+++ b/scripts/geocode-installers.js
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+// Geocode installers using city/state via Nominatim (OpenStreetMap, free, no key).
+// Rate-limited to 1 req/sec per Nominatim's usage policy. Cached so reruns are cheap.
+// Resolution is city-level; multiple installers in the same city get a small jitter
+// (~±0.5 mile) so map pins don't stack.
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const db = require('../lib/db');
+
+const CACHE_PATH = path.join(__dirname, '..', 'data', 'geo-cache.json');
+const UA = 'NationalPaperHangers/0.1 (https://nationalpaperhangers.com; ops@nationalpaperhangers.com)';
+const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
+const REQ_INTERVAL_MS = 1100; // be polite
+
+function jitter(seed) {
+ // Deterministic jitter: same installer.id always gets the same offset.
+ // ±0.008° ≈ ±0.55 miles. Enough to spread a 6-installer city without
+ // moving anyone out of frame.
+ const r1 = Math.sin(seed * 12.9898) * 43758.5453; // PRNG-ish
+ const r2 = Math.sin(seed * 78.233) * 43758.5453;
+ const f1 = r1 - Math.floor(r1);
+ const f2 = r2 - Math.floor(r2);
+ return [(f1 - 0.5) * 0.016, (f2 - 0.5) * 0.016];
+}
+
+async function loadCache() {
+ try { return JSON.parse(await fs.readFile(CACHE_PATH, 'utf8')); }
+ catch { return {}; }
+}
+async function saveCache(c) {
+ await fs.writeFile(CACHE_PATH, JSON.stringify(c, null, 2));
+}
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function geocode(city, state) {
+ const params = new URLSearchParams({
+ city, state, country: 'United States', format: 'json', limit: '1',
+ });
+ const res = await fetch(`${NOMINATIM}?${params}`, { headers: { 'User-Agent': UA } });
+ if (!res.ok) throw new Error(`nominatim ${res.status}`);
+ const arr = await res.json();
+ if (!arr.length) return null;
+ return { lat: parseFloat(arr[0].lat), lng: parseFloat(arr[0].lon) };
+}
+
+async function main() {
+ const cache = await loadCache();
+ const rows = await db.many(
+ `SELECT id, city, state FROM installers
+ WHERE city IS NOT NULL AND state IS NOT NULL
+ AND (latitude IS NULL OR longitude IS NULL)
+ ORDER BY id`
+ );
+ console.log(`installers needing geocoding: ${rows.length}`);
+
+ const pairs = new Map();
+ for (const r of rows) {
+ const key = `${r.city.trim()}|${r.state.trim().toUpperCase()}`;
+ if (!pairs.has(key)) pairs.set(key, { city: r.city.trim(), state: r.state.trim().toUpperCase(), ids: [] });
+ pairs.get(key).ids.push(r.id);
+ }
+ console.log(`unique city/state pairs: ${pairs.size}`);
+
+ let resolvedPairs = 0, missPairs = 0, updatedRows = 0, idx = 0;
+ const total = pairs.size;
+ for (const [key, info] of pairs.entries()) {
+ idx += 1;
+ let coord = cache[key];
+ if (!coord) {
+ try {
+ coord = await geocode(info.city, info.state);
+ } catch (e) {
+ console.warn(`[${idx}/${total}] ${key} geocode error: ${e.message}`);
+ coord = null;
+ }
+ cache[key] = coord || { miss: true };
+ if ((idx % 25) === 0) await saveCache(cache);
+ await sleep(REQ_INTERVAL_MS);
+ }
+ if (!coord || coord.miss) { missPairs += 1; continue; }
+ resolvedPairs += 1;
+ for (const id of info.ids) {
+ const [dlat, dlng] = jitter(id);
+ await db.query(
+ `UPDATE installers
+ SET latitude = $1, longitude = $2,
+ geo_accuracy = 'city',
+ geocoded_at = now()
+ WHERE id = $3`,
+ [coord.lat + dlat, coord.lng + dlng, id]
+ );
+ updatedRows += 1;
+ }
+ if ((idx % 10) === 0) console.log(`[${idx}/${total}] ${key} → ${coord.lat.toFixed(3)},${coord.lng.toFixed(3)} (${info.ids.length} installers)`);
+ }
+ await saveCache(cache);
+ console.log(`\ndone. pairs resolved=${resolvedPairs} miss=${missPairs}; rows updated=${updatedRows}`);
+ process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/go-live-check.js b/scripts/go-live-check.js
new file mode 100644
index 0000000..14b74cf
--- /dev/null
+++ b/scripts/go-live-check.js
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+// Pre-flight env-var checklist for going live.
+// Reads each env var the app expects, prints PRESENT/ABSENT, and (when
+// NODE_ENV=production) exits non-zero if any required-for-live var is absent.
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+
+// requirement: 'live' | 'optional' | 'always'
+// live — must be present when NODE_ENV=production
+// always — must be present in every env (dev + prod)
+// optional — feature flag; absence just disables a thing
+const VARS = [
+ // Core
+ { name: 'NODE_ENV', req: 'always', desc: 'Environment marker. Set to "production" for live.' },
+ { name: 'PORT', req: 'always', desc: 'HTTP port. Defaults to 9765.' },
+ { name: 'PUBLIC_URL', req: 'live', desc: 'Canonical public URL. Used for emails, OG, callback URLs.' },
+
+ // Database
+ { name: 'PGHOST', req: 'always', desc: 'Postgres host.' },
+ { name: 'PGPORT', req: 'optional', desc: 'Postgres port (default 5432).' },
+ { name: 'PGDATABASE', req: 'always', desc: 'Postgres DB name (national_paper_hangers).' },
+ { name: 'PGUSER', req: 'always', desc: 'Postgres user.' },
+ { name: 'PGPASSWORD', req: 'optional', desc: 'Postgres password (omit for unix-socket trust).' },
+
+ // Sessions / signing
+ { name: 'SESSION_SECRET', req: 'live', desc: 'Cookie session signing secret.' },
+ { name: 'BOOKING_SIGNING_SECRET', req: 'live', desc: 'HMAC for /bookings/:uuid?t=… view tokens.' },
+ { name: 'UNSUBSCRIBE_SIGNING_SECRET', req: 'live', desc: 'HMAC for one-click unsubscribe links.' },
+
+ // Stripe — subscriptions + booking deposits + Connect
+ { name: 'STRIPE_SECRET_KEY', req: 'live', desc: 'sk_live_… (or sk_test_… in staging). Drives all Stripe SDK calls.' },
+ { name: 'STRIPE_PUBLISHABLE_KEY', req: 'live', desc: 'pk_live_… exposed to book.ejs for Stripe Elements.' },
+ { name: 'STRIPE_WEBHOOK_SECRET', req: 'live', desc: 'whsec_… for /webhooks/stripe signature verify.' },
+ { name: 'STRIPE_DEV_ACCEPT_UNSIGNED', req: 'optional', desc: 'Dev escape hatch — only honored when NODE_ENV != production.' },
+ { name: 'STRIPE_PRICE_PRO_MONTH', req: 'live', desc: 'Stripe price ID for Pro monthly subscription.' },
+ { name: 'STRIPE_PRICE_PRO_YEAR', req: 'live', desc: 'Stripe price ID for Pro annual subscription.' },
+ { name: 'STRIPE_PRICE_SIGNATURE_MONTH', req: 'live', desc: 'Stripe price ID for Signature monthly.' },
+ { name: 'STRIPE_PRICE_SIGNATURE_YEAR', req: 'live', desc: 'Stripe price ID for Signature annual.' },
+ { name: 'STRIPE_PRICE_ENTERPRISE_MONTH', req: 'live',desc: 'Stripe price ID for Enterprise monthly.' },
+
+ // Marketplace tunables
+ { name: 'NPH_DEFAULT_DEPOSIT_CENTS', req: 'optional', desc: 'Default booking deposit in cents (default 9900 = $99).' },
+ { name: 'NPH_PLATFORM_FEE_BPS', req: 'optional', desc: 'Platform fee in basis points (default 1000 = 10%).' },
+ { name: 'NPH_PLATFORM_ADMIN_INSTALLER_IDS', req: 'optional', desc: 'Comma-separated installer IDs that see platform-wide totals on /admin/billing.' },
+
+ // George (outbound email)
+ { name: 'GEORGE_URL', req: 'live', desc: 'George Gmail relay URL (http://localhost:9850 in dev, http://100.107.67.67:9850 from Kamatera tailnet).' },
+ { name: 'GEORGE_USER', req: 'live', desc: 'George basic-auth user.' },
+ { name: 'GEORGE_PASS', req: 'live', desc: 'George basic-auth password.' },
+ { name: 'GEORGE_ACCOUNT', req: 'optional', desc: 'Account label for George multi-account support (e.g. info).' },
+ { name: 'EMAIL_FROM', req: 'live', desc: 'From address for outbound mail (e.g. info@nationalpaperhangers.com).' },
+ { name: 'EMAIL_FROM_NAME', req: 'optional', desc: 'From display name (e.g. National Paper Hangers).' },
+ { name: 'MAILING_ADDRESS', req: 'live', desc: 'CAN-SPAM physical mailing address. Pre-flight gate REFUSES sends without this.' },
+
+ // Optional integrations
+ { name: 'BROWSERBASE_API_KEY', req: 'optional', desc: 'Cloud browser for vendor scrapes.' },
+ { name: 'BROWSERBASE_PROJECT_ID', req: 'optional', desc: 'Browserbase project ID.' }
+];
+
+const isProd = process.env.NODE_ENV === 'production';
+const rows = VARS.map(v => ({
+ ...v,
+ present: typeof process.env[v.name] === 'string' && process.env[v.name].length > 0
+}));
+
+function pad(s, n) { return (s + ' '.repeat(n)).slice(0, n); }
+const NAME_W = Math.max(...rows.map(r => r.name.length)) + 2;
+
+console.log('');
+console.log(`NPH go-live env-var checklist (NODE_ENV=${process.env.NODE_ENV || '<unset>'})`);
+console.log('='.repeat(72));
+let missingLive = 0, missingAlways = 0;
+for (const r of rows) {
+ const tag = r.present ? '✓ PRESENT' : '✗ ABSENT ';
+ const reqTag = r.req === 'always' ? '[always]' : r.req === 'live' ? '[live] ' : '[opt] ';
+ console.log(` ${tag} ${reqTag} ${pad(r.name, NAME_W)} ${r.desc}`);
+ if (!r.present) {
+ if (r.req === 'always') missingAlways += 1;
+ if (r.req === 'live') missingLive += 1;
+ }
+}
+console.log('');
+console.log(`Required-always missing: ${missingAlways}`);
+console.log(`Required-for-live missing: ${missingLive}`);
+
+if (missingAlways > 0) {
+ console.error('\n✗ FAIL — required-always env vars are missing. Fix .env before any boot.');
+ process.exit(2);
+}
+if (isProd && missingLive > 0) {
+ console.error('\n✗ FAIL — running in production but required-for-live env vars are absent. Save them via the secrets-manager skill.');
+ process.exit(1);
+}
+console.log('\n✓ OK for current NODE_ENV. (live keys absent is fine in dev.)');
+process.exit(0);
diff --git a/scripts/lead-list.js b/scripts/lead-list.js
new file mode 100644
index 0000000..fe387c8
--- /dev/null
+++ b/scripts/lead-list.js
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+// Lead list — studios running paid ads, ranked by platform count.
+// Output: pretty terminal table, or CSV with --csv.
+//
+// Usage:
+// node scripts/lead-list.js # all paid-ads studios, pretty print
+// node scripts/lead-list.js --csv # CSV to stdout
+// node scripts/lead-list.js --min=2 # only studios on 2+ paid platforms
+// node scripts/lead-list.js --state=CA # filter by state
+
+require('dotenv').config();
+const db = require('../lib/db');
+
+const CSV = process.argv.includes('--csv');
+const MIN = parseInt((process.argv.find(a => a.startsWith('--min=')) || '--min=1').split('=')[1], 10);
+const STATE = (process.argv.find(a => a.startsWith('--state=')) || '').split('=')[1] || null;
+
+async function main() {
+ const params = [MIN];
+ let where = `claim_status='unclaimed' AND ad_signals IS NOT NULL AND (ad_signals->>'paid_ads_count')::int >= $1`;
+ if (STATE) {
+ params.push(STATE.toUpperCase());
+ where += ` AND state = $${params.length}`;
+ }
+ const rows = await db.many(
+ `SELECT business_name, city, state, website, instagram_handle, ad_signals
+ FROM installers
+ WHERE ${where}
+ ORDER BY (ad_signals->>'paid_ads_count')::int DESC, business_name`,
+ params
+ );
+
+ if (CSV) {
+ console.log('business_name,city,state,website,instagram,paid_count,platforms');
+ for (const r of rows) {
+ const platforms = paidPlatforms(r.ad_signals).join('|');
+ console.log([
+ csv(r.business_name), csv(r.city), csv(r.state), csv(r.website),
+ csv(r.instagram_handle ? '@' + r.instagram_handle : ''),
+ r.ad_signals.paid_ads_count, csv(platforms)
+ ].join(','));
+ }
+ } else {
+ console.log(`\n=== Lead list — ${rows.length} unclaimed studios running paid ads ===\n`);
+ for (const r of rows) {
+ const platforms = paidPlatforms(r.ad_signals);
+ const tag = '💰'.repeat(Math.min(r.ad_signals.paid_ads_count, 5));
+ console.log(`${tag} ${r.business_name}`);
+ console.log(` ${r.city || '?'}, ${r.state || '?'} · ${r.website}`);
+ if (r.instagram_handle) console.log(` IG @${r.instagram_handle}`);
+ console.log(` Platforms: ${platforms.join(', ')}`);
+ console.log('');
+ }
+ if (rows.length === 0) console.log(' (none yet — run scripts/scan-ad-signals.js --commit first)');
+ }
+
+ await db.pool.end();
+}
+
+function paidPlatforms(s) {
+ if (!s) return [];
+ const map = {
+ google_ads: 'Google Ads',
+ meta_pixel: 'Meta',
+ tiktok_pixel: 'TikTok',
+ pinterest_tag: 'Pinterest',
+ linkedin_insight: 'LinkedIn',
+ twitter_pixel: 'Twitter/X',
+ reddit_pixel: 'Reddit',
+ bing_uet: 'Bing',
+ snap_pixel: 'Snap'
+ };
+ return Object.entries(map).filter(([k]) => s[k] === true).map(([, v]) => v);
+}
+
+function csv(v) {
+ if (v == null) return '';
+ const s = String(v);
+ if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
+ return s;
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/scan-ad-signals.js b/scripts/scan-ad-signals.js
new file mode 100644
index 0000000..aa3d30b
--- /dev/null
+++ b/scripts/scan-ad-signals.js
@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+// Ad-pixel fingerprint scanner — pure regex, no LLM.
+//
+// For each unclaimed installer with a website, fetch homepage and detect
+// tracking pixels for: Google Ads, Meta, TikTok, Pinterest, LinkedIn, Twitter,
+// Reddit, Bing, Snap. Stores result as JSONB in installers.ad_signals.
+//
+// Usage:
+// node scripts/scan-ad-signals.js # dry run
+// node scripts/scan-ad-signals.js --commit # write to DB
+// node scripts/scan-ad-signals.js --max=200 # cap (default 100)
+// node scripts/scan-ad-signals.js --rescan # re-scan even already-scanned sites
+
+require('dotenv').config();
+const db = require('../lib/db');
+
+const UA = 'NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)';
+const SOURCE = 'studio_site_adscan';
+const REQUEST_DELAY_MS = 3000;
+const COMMIT = process.argv.includes('--commit');
+const RESCAN = process.argv.includes('--rescan');
+const MAX = parseInt((process.argv.find(a => a.startsWith('--max=')) || '--max=100').split('=')[1], 10);
+
+const PIXEL_PATTERNS = {
+ google_ads: [
+ /googleadservices\.com/i,
+ /gtag\(\s*['"]config['"]\s*,\s*['"]AW-/i,
+ /google_conversion_id/i,
+ /googletagmanager\.com\/gtag\/js\?id=AW-/i
+ ],
+ google_analytics: [
+ /gtag\(\s*['"]config['"]\s*,\s*['"]G-/i,
+ /www\.googletagmanager\.com\/gtm\.js/i,
+ /www\.google-analytics\.com\/analytics\.js/i
+ ],
+ meta_pixel: [
+ /connect\.facebook\.net\/[^"']*\/fbevents\.js/i,
+ /fbq\(\s*['"]init['"]/i,
+ /facebook\.com\/tr\?id=/i
+ ],
+ tiktok_pixel: [
+ /analytics\.tiktok\.com/i,
+ /ttq\.load\(/i
+ ],
+ pinterest_tag: [
+ /s\.pinimg\.com\/ct\.js/i,
+ /pintrk\(\s*['"]load['"]/i
+ ],
+ linkedin_insight: [
+ /snap\.licdn\.com\/li\.lms-analytics/i,
+ /_linkedin_partner_id/i
+ ],
+ twitter_pixel: [
+ /static\.ads-twitter\.com\/uwt\.js/i,
+ /twq\(\s*['"]init['"]/i
+ ],
+ reddit_pixel: [
+ /www\.redditstatic\.com\/ads\/pixel\.js/i,
+ /rdt\(\s*['"]init['"]/i
+ ],
+ bing_uet: [
+ /bat\.bing\.com/i,
+ /uetq\.push/i
+ ],
+ snap_pixel: [
+ /sc-static\.net\/scevent/i,
+ /snaptr\(\s*['"]init['"]/i
+ ]
+};
+
+function detectSignals(html) {
+ const out = {};
+ for (const [k, patterns] of Object.entries(PIXEL_PATTERNS)) {
+ out[k] = patterns.some(p => p.test(html));
+ }
+ // paid_ads_count excludes pure-analytics signals (GA)
+ const paidPlatforms = [
+ 'google_ads', 'meta_pixel', 'tiktok_pixel', 'pinterest_tag',
+ 'linkedin_insight', 'twitter_pixel', 'reddit_pixel', 'bing_uet', 'snap_pixel'
+ ];
+ out.paid_ads_count = paidPlatforms.filter(k => out[k]).length;
+ return out;
+}
+
+async function logRequest(url, status, bytes, durMs, error) {
+ await db.query(
+ `INSERT INTO scrape_log (source, url, http_status, bytes, duration_ms, error)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ [SOURCE, url, status, bytes, durMs, error]
+ );
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function main() {
+ console.log(`[ad-scan] start · commit=${COMMIT} · rescan=${RESCAN} · max=${MAX}`);
+
+ const filter = RESCAN ? '' : 'AND ad_signals IS NULL';
+ const targets = await db.many(
+ `SELECT id, slug, business_name, website
+ FROM installers
+ WHERE claim_status='unclaimed'
+ AND website IS NOT NULL AND website <> ''
+ ${filter}
+ ORDER BY id
+ LIMIT $1`,
+ [MAX]
+ );
+
+ console.log(`[ad-scan] ${targets.length} candidates`);
+
+ let scanned = 0, paidCount = 0, failed = 0;
+ for (const t of targets) {
+ const t0 = Date.now();
+ let status = 0, html = null, bytes = 0, err = null;
+ try {
+ const r = await fetch(t.website, {
+ headers: { 'user-agent': UA, accept: 'text/html' },
+ signal: AbortSignal.timeout(15000),
+ redirect: 'follow'
+ });
+ status = r.status;
+ if (r.ok) html = await r.text();
+ bytes = html ? html.length : 0;
+ } catch (e) {
+ err = e.message;
+ }
+ await logRequest(t.website, status, bytes, Date.now() - t0, err);
+
+ if (!html) {
+ console.log(` ✗ ${t.business_name}: ${status} ${err || ''}`);
+ failed++;
+ // Still record an empty signals object so we don't keep retrying
+ if (COMMIT) {
+ await db.query(
+ `UPDATE installers SET ad_signals=$2, ad_signals_at=now() WHERE id=$1`,
+ [t.id, JSON.stringify({ fetch_failed: true, paid_ads_count: 0 })]
+ );
+ }
+ await sleep(REQUEST_DELAY_MS);
+ continue;
+ }
+
+ const signals = detectSignals(html);
+ scanned++;
+ if (signals.paid_ads_count > 0) paidCount++;
+
+ const platforms = Object.entries(signals)
+ .filter(([k, v]) => v === true && !['google_analytics'].includes(k))
+ .map(([k]) => k.replace(/_/g, ' '));
+
+ if (platforms.length > 0) {
+ console.log(` 💰 ${t.business_name}: [${platforms.join(', ')}]`);
+ } else if (signals.google_analytics) {
+ console.log(` · ${t.business_name}: GA only (no paid ads)`);
+ } else {
+ console.log(` · ${t.business_name}: no tracking detected`);
+ }
+
+ if (COMMIT) {
+ await db.query(
+ `UPDATE installers SET ad_signals=$2, ad_signals_at=now() WHERE id=$1`,
+ [t.id, JSON.stringify(signals)]
+ );
+ }
+
+ await sleep(REQUEST_DELAY_MS);
+ }
+
+ console.log(`[ad-scan] done · scanned=${scanned} runningPaidAds=${paidCount} failed=${failed}`);
+ if (failed / Math.max(1, targets.length) > 0.10) {
+ console.warn(`[ad-scan] FAILURE-RATE-WARNING: ${(failed/targets.length*100).toFixed(0)}%`);
+ }
+ await db.pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/scrape-wia-browserbase.js b/scripts/scrape-wia-browserbase.js
new file mode 100644
index 0000000..b70a4f7
--- /dev/null
+++ b/scripts/scrape-wia-browserbase.js
@@ -0,0 +1,420 @@
+#!/usr/bin/env node
+// Scrape WIA public installer locator via Browserbase + Playwright.
+//
+// Authorization: invoked by Steve with "do for me with browserbase" on 2026-05-05.
+// Compliance: DATA_POLICY.md v1.0 — collects ONLY business name, city/state/country,
+// public website, accreditations from the public locator listing rows. No per-profile
+// drilldown, no email, no phone, no street address.
+//
+// Usage:
+// node scripts/scrape-wia-browserbase.js # dry-run probe (saves screenshot + HTML)
+// node scripts/scrape-wia-browserbase.js --commit # also inserts to PG
+// node scripts/scrape-wia-browserbase.js --pages=3 # max pages to walk (default 1)
+// node scripts/scrape-wia-browserbase.js --debug-only # JUST take a screenshot, no extraction (for tuning)
+
+require('dotenv').config();
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/browserbase/.env', override: false });
+
+const fs = require('fs');
+const path = require('path');
+const slugify = require('slugify');
+const Browserbase = require('@browserbasehq/sdk').default;
+const { chromium } = require('playwright-core');
+
+const db = require('../lib/db');
+
+const SOURCE_NAME = 'wia';
+const UA = 'NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)';
+const REQUEST_DELAY_MS = 3000;
+const DAILY_CAP = 200;
+const COMMIT = process.argv.includes('--commit');
+const DEBUG_ONLY = process.argv.includes('--debug-only');
+const PAGES = parseInt((process.argv.find(a => a.startsWith('--pages=')) || '--pages=1').split('=')[1], 10);
+
+// Locator URL discovered 2026-05-05 — WordPress page that hosts the
+// zip + distance search form. The form submits via GET to root and the
+// installer results render on the same locator page (likely via JS).
+const LOCATOR_URL = 'https://www.wallcoveringinstallers.org/consumers/locate-a-wallcovering-installer/';
+
+// Major-metro zip codes — walk a small set to seed broad geographic coverage.
+// Per DATA_POLICY.md §5, 3s delay between requests + 200/day cap apply.
+const SEARCH_ZIPS = [
+ // Tier 1 — top metros (already covered, dedup will skip)
+ { zip: '10001', label: 'New York, NY' },
+ { zip: '90048', label: 'Los Angeles, CA' },
+ { zip: '60601', label: 'Chicago, IL' },
+ { zip: '33131', label: 'Miami, FL' },
+ { zip: '75201', label: 'Dallas, TX' },
+ { zip: '94103', label: 'San Francisco, CA' },
+ { zip: '02108', label: 'Boston, MA' },
+ { zip: '98101', label: 'Seattle, WA' },
+ { zip: '20001', label: 'Washington, DC' },
+ { zip: '30303', label: 'Atlanta, GA' },
+ { zip: '85004', label: 'Phoenix, AZ' },
+ { zip: '80202', label: 'Denver, CO' },
+ { zip: '55401', label: 'Minneapolis, MN' },
+ { zip: '63101', label: 'St. Louis, MO' },
+ { zip: '37203', label: 'Nashville, TN' },
+ { zip: '70112', label: 'New Orleans, LA' },
+ { zip: '97201', label: 'Portland, OR' },
+ { zip: '84101', label: 'Salt Lake City, UT' },
+ { zip: '64108', label: 'Kansas City, MO' },
+ { zip: '53202', label: 'Milwaukee, WI' },
+ // Tier 2 — secondary metros that may have local installers not in 100mi of tier-1
+ { zip: '15222', label: 'Pittsburgh, PA' },
+ { zip: '44113', label: 'Cleveland, OH' },
+ { zip: '46204', label: 'Indianapolis, IN' },
+ { zip: '38103', label: 'Memphis, TN' },
+ { zip: '28202', label: 'Charlotte, NC' },
+ { zip: '33602', label: 'Tampa, FL' },
+ { zip: '45202', label: 'Cincinnati, OH' },
+ { zip: '14202', label: 'Buffalo, NY' },
+ { zip: '89101', label: 'Las Vegas, NV' },
+ { zip: '40202', label: 'Louisville, KY' },
+ { zip: '23219', label: 'Richmond, VA' },
+ { zip: '32202', label: 'Jacksonville, FL' },
+ { zip: '19102', label: 'Philadelphia, PA' },
+ { zip: '21202', label: 'Baltimore, MD' },
+ { zip: '78701', label: 'Austin, TX' },
+ { zip: '48226', label: 'Detroit, MI' },
+ { zip: '78205', label: 'San Antonio, TX' },
+ { zip: '76102', label: 'Fort Worth, TX' },
+ { zip: '95814', label: 'Sacramento, CA' },
+ { zip: '92101', label: 'San Diego, CA' },
+ // Tier 3 — sparse / rural geographic coverage
+ { zip: '99501', label: 'Anchorage, AK' },
+ { zip: '96813', label: 'Honolulu, HI' },
+ { zip: '04101', label: 'Portland, ME' },
+ { zip: '03101', label: 'Manchester, NH' },
+ { zip: '05401', label: 'Burlington, VT' },
+ { zip: '57104', label: 'Sioux Falls, SD' },
+ { zip: '58102', label: 'Fargo, ND' },
+ { zip: '59601', label: 'Helena, MT' },
+ { zip: '83702', label: 'Boise, ID' },
+ { zip: '87501', label: 'Santa Fe, NM' }
+];
+const SEARCH_DISTANCE = '100'; // miles
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function logRequest(url, status, bytes, durMs, error) {
+ await db.query(
+ `INSERT INTO scrape_log (source, url, http_status, bytes, duration_ms, error)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ [SOURCE_NAME, url, status, bytes, durMs, error]
+ );
+}
+
+async function loadOptOutSet() {
+ const rows = await db.many(`SELECT domain FROM directory_optout`);
+ return new Set(rows.map(r => (r.domain || '').toLowerCase()));
+}
+
+async function checkDailyCap() {
+ const r = await db.one(
+ `SELECT COUNT(*)::int AS c FROM scrape_log WHERE source=$1 AND created_at::date = CURRENT_DATE`,
+ [SOURCE_NAME]
+ );
+ return r.c;
+}
+
+async function upsertInstaller(rec) {
+ const baseSlug = slugify(`${rec.business_name} ${rec.city || ''}`, { lower: true, strict: true }).slice(0, 70) || 'installer';
+ let slug = baseSlug;
+ let n = 2;
+ // Dedupe by (business_name + state) within source_name='wia' so a re-run is
+ // idempotent. Fall back to (business_name) if state is null.
+ const exact = rec.state
+ ? await db.one(
+ 'SELECT id, slug FROM installers WHERE LOWER(business_name)=LOWER($1) AND state=$2 AND source_name=$3',
+ [rec.business_name, rec.state, SOURCE_NAME]
+ )
+ : await db.one(
+ 'SELECT id, slug FROM installers WHERE LOWER(business_name)=LOWER($1) AND state IS NULL AND source_name=$2',
+ [rec.business_name, SOURCE_NAME]
+ );
+ if (exact) {
+ return { id: exact.id, slug: exact.slug, status: 'skip_existing' };
+ }
+ while (await db.one('SELECT id FROM installers WHERE slug=$1', [slug])) {
+ slug = `${baseSlug}-${n++}`;
+ }
+ const placeholderEmail = `nph-unclaimed-${slug}@listings.local`;
+ const lockedHash = '$2b$10$' + require('crypto').randomUUID().replace(/-/g, '').slice(0, 53);
+
+ const r = await db.one(
+ `INSERT INTO installers
+ (slug, email, password_hash, business_name, city, state, country,
+ website, bio, accreditations, instagram_handle,
+ claim_status, status, tier, subscription_status,
+ source_name, source_url, source_scraped_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'unclaimed','pending','basic','inactive',$12,$13,$14)
+ RETURNING id, slug`,
+ [slug, placeholderEmail, lockedHash, rec.business_name, rec.city, rec.state, rec.country || 'US',
+ rec.website, rec.bio, rec.accreditations || [], rec.instagram_handle || null,
+ SOURCE_NAME, rec.source_url, rec.source_scraped_at]
+ );
+ return { ...r, status: 'inserted' };
+}
+
+async function main() {
+ console.log(`[wia-bb] start · commit=${COMMIT} · debug-only=${DEBUG_ONLY} · pages=${PAGES}`);
+
+ const apiKey = process.env.BROWSERBASE_API_KEY;
+ const projectId = process.env.BROWSERBASE_PROJECT_ID;
+ if (!apiKey || !projectId) {
+ console.error('[wia-bb] missing BROWSERBASE_API_KEY or BROWSERBASE_PROJECT_ID — check ~/.claude/skills/browserbase/.env');
+ process.exit(2);
+ }
+
+ const usedToday = await checkDailyCap();
+ console.log(`[wia-bb] scrape budget today: ${usedToday}/${DAILY_CAP}`);
+ if (usedToday >= DAILY_CAP) {
+ console.error('[wia-bb] daily cap hit, aborting');
+ process.exit(1);
+ }
+
+ const debugDir = path.join(__dirname, '..', 'tmp', 'wia-bb-' + Date.now());
+ fs.mkdirSync(debugDir, { recursive: true });
+
+ const bb = new Browserbase({ apiKey });
+ const session = await bb.sessions.create({ projectId });
+ console.log(`[wia-bb] session: ${session.id} · live: ${session.debuggerUrl || session.connectUrl}`);
+
+ const browser = await chromium.connectOverCDP(session.connectUrl);
+ const ctx = browser.contexts()[0] || await browser.newContext();
+ await ctx.setExtraHTTPHeaders({ 'user-agent': UA });
+ const page = ctx.pages()[0] || await ctx.newPage();
+ await page.setExtraHTTPHeaders({ 'user-agent': UA });
+
+ // Honor robots.txt — quick check on root
+ try {
+ const robotsResp = await page.goto('https://www.wallcoveringinstallers.org/robots.txt', { waitUntil: 'load', timeout: 30000 });
+ const robotsTxt = robotsResp ? await robotsResp.text() : '';
+ await logRequest('https://www.wallcoveringinstallers.org/robots.txt', robotsResp ? robotsResp.status() : 0, (robotsTxt || '').length, 0, null);
+ fs.writeFileSync(path.join(debugDir, 'robots.txt'), robotsTxt || '');
+ if (/Disallow:\s*\/\s*$/m.test(robotsTxt)) {
+ console.error('[wia-bb] robots.txt disallows root crawling — aborting per policy');
+ await browser.close();
+ process.exit(1);
+ }
+ } catch (e) {
+ console.warn('[wia-bb] robots.txt fetch failed (continuing):', e.message);
+ }
+
+ await sleep(REQUEST_DELAY_MS);
+
+ // Step 1: Visit the locator page once to warm up state
+ console.log(`[wia-bb] entry locator: ${LOCATOR_URL}`);
+ {
+ const t0 = Date.now();
+ const resp = await page.goto(LOCATOR_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ const html = await page.content();
+ await logRequest(LOCATOR_URL, resp ? resp.status() : 0, html.length, Date.now() - t0, null);
+ fs.writeFileSync(path.join(debugDir, 'locator-entry.html'), html);
+ await page.screenshot({ path: path.join(debugDir, 'locator-entry.png'), fullPage: true });
+ }
+
+ if (DEBUG_ONLY) {
+ console.log(`[wia-bb] debug-only mode — saved locator entry to ${debugDir}`);
+ await browser.close();
+ return;
+ }
+
+ // Step 2: Drive the search form for each metro zip
+ const records = [];
+ const zipsToSearch = SEARCH_ZIPS.slice(0, PAGES > 1 ? PAGES : SEARCH_ZIPS.length);
+
+ for (const z of zipsToSearch) {
+ console.log(`[wia-bb] search zip=${z.zip} (${z.label})`);
+ await sleep(REQUEST_DELAY_MS);
+
+ const t0 = Date.now();
+ try {
+ // Re-load locator page for a clean form each round
+ await page.goto(LOCATOR_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+
+ // Fill the zip + distance fields. There are two parallel forms (mobile + desktop)
+ // both with the same field names. Just fill whichever exists.
+ await page.evaluate((opts) => {
+ const setVal = (sel, v) => { const el = document.querySelector(sel); if (el) { el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); } };
+ setVal('input[name="zip"]', opts.zip);
+ const dist = document.querySelector('select[name="distance"]');
+ if (dist) {
+ // pick the largest option <= our requested distance, or the largest available
+ const opts2 = Array.from(dist.options).map(o => parseInt((o.value || '').replace(/[^0-9]/g, ''), 10) || 0);
+ const targetIdx = opts2.indexOf(Math.max(...opts2));
+ if (targetIdx >= 0) { dist.selectedIndex = targetIdx; dist.dispatchEvent(new Event('change', { bubbles: true })); }
+ }
+ }, { zip: z.zip, distance: SEARCH_DISTANCE });
+
+ // Submit by clicking the location-search submit button (NOT the name-search one)
+ // The button next to the zip/distance has aria-label or button text "Search"
+ // We pick the FIRST .mbl-search-submit which is the location form on this layout.
+ const submitClicked = await page.evaluate(() => {
+ // Prefer a button inside the same form as input[name="zip"]
+ const zipInput = document.querySelector('input[name="zip"]');
+ if (!zipInput) return false;
+ const form = zipInput.closest('form') || zipInput.closest('div');
+ if (form) {
+ const btn = form.querySelector('input[type="submit"], button[type="submit"]');
+ if (btn) { btn.click(); return true; }
+ }
+ // Fallback
+ const anyBtn = document.querySelector('.mbl-search-submit, input[value="Search"]');
+ if (anyBtn) { anyBtn.click(); return true; }
+ return false;
+ });
+
+ if (!submitClicked) {
+ console.warn(` ! could not find submit button for zip=${z.zip}`);
+ continue;
+ }
+
+ // Wait for results — JS-driven, may take a few seconds
+ try { await page.waitForLoadState('networkidle', { timeout: 15000 }); } catch {}
+ await sleep(2000);
+
+ const html = await page.content();
+ const finalUrl = page.url();
+ fs.writeFileSync(path.join(debugDir, `results-zip-${z.zip}.html`), html);
+ await page.screenshot({ path: path.join(debugDir, `results-zip-${z.zip}.png`), fullPage: true });
+ await logRequest(finalUrl, 200, html.length, Date.now() - t0, null);
+
+ // Extract installer cards using WIA's real structure (.mbl-result divs).
+ // We deliberately do NOT touch .mbl-phone, .mbl-phone-mobile, .mbl-email
+ // per DATA_POLICY §3.
+ const candidates = await page.evaluate(() => {
+ const out = [];
+ document.querySelectorAll('.mbl-result').forEach(card => {
+ const company = card.querySelector('.mbl-company');
+ const businessName = company ? (company.textContent || '').trim() : null;
+ if (!businessName || businessName.length < 2) return;
+
+ const addrEl = card.querySelector('.mbl-address');
+ // .mbl-address contains street + <br> + "City, ST ZIP"
+ // We pull ONLY the city/state line; never the street.
+ let cityStateLine = null;
+ if (addrEl) {
+ const html = addrEl.innerHTML || '';
+ const parts = html.split(/<br\s*\/?>/i).map(p => p.replace(/<[^>]+>/g, '').trim()).filter(Boolean);
+ // The city-state line is the last segment containing ", XX"
+ cityStateLine = parts.reverse().find(p => /,\s*[A-Z]{2}\b/.test(p)) || null;
+ }
+
+ const websiteEl = card.querySelector('.mbl-website a');
+ const website = websiteEl ? websiteEl.href : null;
+
+ // Bio paragraph follows .mbl-contact-info
+ const bioEl = card.querySelector('.mbl-contact-info + p, p:not([class])');
+ const bio = bioEl ? (bioEl.textContent || '').trim().slice(0, 600) : null;
+
+ // Accreditations from class signals
+ const cls = card.className || '';
+ const accreditations = [];
+ if (cls.includes('mbl-accredited-result')) accreditations.push('WIA Certified Installer');
+ if (cls.includes('ris-accreditation')) accreditations.push('RIS Accredited');
+
+ out.push({
+ business_name: businessName,
+ city_state_line: cityStateLine,
+ website,
+ bio,
+ accreditations
+ });
+ });
+ return out;
+ });
+
+ console.log(` · ${candidates.length} member-locator cards`);
+
+ for (const c of candidates) {
+ // Parse city + state from "City, ST ZIP" — explicitly anchor to comma
+ // so we never grab a street name as city.
+ let city = null, state = null, country = 'US';
+ const line = c.city_state_line || '';
+ const m1 = line.match(/^([A-Za-z][A-Za-z .'-]{1,40}),\s*([A-Z]{2})(?:\s+\d{4,5})?\s*$/);
+ if (m1) { city = m1[1].trim(); state = m1[2].trim(); }
+ else {
+ const m2 = line.match(/^([A-Za-z][A-Za-z .'-]{1,40}),\s*(United Kingdom|Canada|Australia|New Zealand|Germany|France|Italy|Spain|Netherlands)\s*$/i);
+ if (m2) { city = m2[1].trim(); country = m2[2].trim(); state = null; }
+ }
+
+ // Reject obvious noise (nav menu items that slipped through, etc.)
+ if (/^(locate|find|membership|about|contact|search|home|members)/i.test(c.business_name)) continue;
+
+ records.push({
+ business_name: c.business_name,
+ city, state, country,
+ website: cleanUrl(c.website),
+ bio: c.bio || null,
+ accreditations: c.accreditations || [],
+ instagram_handle: null,
+ source_url: finalUrl,
+ source_scraped_at: new Date().toISOString(),
+ search_zip: z.zip
+ });
+ }
+ } catch (e) {
+ console.warn(` ! error on zip=${z.zip}: ${e.message}`);
+ }
+ }
+
+ // Dedupe by business_name across all zip results
+ const dedupedByName = new Map();
+ for (const r of records) {
+ const k = (r.business_name || '').toLowerCase().trim();
+ if (!k) continue;
+ if (!dedupedByName.has(k)) dedupedByName.set(k, r);
+ }
+ const uniqRecords = Array.from(dedupedByName.values());
+ console.log(`[wia-bb] total candidates across all zips: ${records.length}, deduped: ${uniqRecords.length}`);
+ records.length = 0;
+ records.push(...uniqRecords);
+
+ console.log(`[wia-bb] total candidates: ${records.length}`);
+
+ let inserted = 0, skipped = 0;
+ if (COMMIT) {
+ // Preload opt-out domains once — avoids N+1 query per record.
+ const optOutSet = await loadOptOutSet();
+ for (const rec of records) {
+ // Drop opted-out domains
+ try {
+ if (rec.website) {
+ const host = new URL(rec.website).hostname.replace(/^www\./, '');
+ if (optOutSet.has(host.toLowerCase())) { skipped++; continue; }
+ }
+ } catch {}
+ const r = await upsertInstaller(rec);
+ if (r.status === 'inserted') {
+ console.log(` ✓ ${r.slug} (${rec.business_name} · ${rec.city || '?'}, ${rec.state || rec.country || '?'})`);
+ inserted++;
+ } else {
+ skipped++;
+ }
+ }
+ } else {
+ for (const rec of records.slice(0, 20)) {
+ console.log(` · DRY: ${rec.business_name} · ${rec.city || '?'}, ${rec.state || rec.country || '?'} · ${rec.website || '(no site)'}`);
+ }
+ if (records.length > 20) console.log(` · DRY: ...and ${records.length - 20} more`);
+ }
+
+ await browser.close();
+ console.log(`[wia-bb] done · inserted=${inserted} skipped=${skipped} candidates=${records.length}`);
+ console.log(`[wia-bb] artifacts: ${debugDir}`);
+ await db.pool.end();
+}
+
+function cleanUrl(u) {
+ if (!u) return null;
+ try {
+ const url = new URL(u);
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
+ if (url.hostname.includes('wallcoveringinstallers.org')) return null; // skip internal links
+ return url.toString();
+ } catch { return null; }
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/scrape-wia.js b/scripts/scrape-wia.js
new file mode 100644
index 0000000..4176f4c
--- /dev/null
+++ b/scripts/scrape-wia.js
@@ -0,0 +1,277 @@
+#!/usr/bin/env node
+// Scrape the public WIA (Wallcovering Installers Association) member directory.
+//
+// Per ~/Projects/NationalPaperHangers/DATA_POLICY.md §3 we collect ONLY:
+// - business name
+// - city, state, country
+// - public website
+// - public bio (≤ 600 chars, attributed)
+// - accreditations
+// - Instagram handle ONLY if it appears as a clickable link on the studio's own website
+//
+// We do NOT collect: phone, email, street address, owner names, license #s, etc.
+//
+// Crawl etiquette (DATA_POLICY §5):
+// - User-Agent: NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)
+// - 3-second delay between requests
+// - 200-request daily cap
+// - Honor robots.txt
+// - Skip any domain in directory_optout
+//
+// Usage:
+// node scripts/scrape-wia.js # dry-run, prints to stdout
+// node scripts/scrape-wia.js --commit # actually inserts into PG
+// node scripts/scrape-wia.js --enrich-ig # also fetches studio website to extract IG
+// node scripts/scrape-wia.js --max=50 # cap inserts (default 100)
+
+require('dotenv').config();
+const slugify = require('slugify');
+const db = require('../lib/db');
+
+const UA = 'NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)';
+const SOURCE_NAME = 'wia';
+const REQUEST_DELAY_MS = 3000;
+const DAILY_CAP = 200;
+const PER_RUN_CAP = parseInt((process.argv.find(a => a.startsWith('--max=')) || '--max=100').split('=')[1], 10);
+const COMMIT = process.argv.includes('--commit');
+const ENRICH_IG = process.argv.includes('--enrich-ig');
+
+// ---- robots-aware fetch with logging
+async function fetchPage(url, source = 'studio_site') {
+ const t0 = Date.now();
+ let status = 0, bytes = 0, error = null, body = null;
+ try {
+ const r = await fetch(url, {
+ headers: { 'user-agent': UA, 'accept': 'text/html,application/xhtml+xml' },
+ signal: AbortSignal.timeout(30000)
+ });
+ status = r.status;
+ body = r.ok ? await r.text() : null;
+ bytes = body ? body.length : 0;
+ } catch (e) {
+ error = e.message;
+ }
+ const dur = Date.now() - t0;
+ await db.query(
+ `INSERT INTO scrape_log (source, url, http_status, bytes, duration_ms, error)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ [source, url, status, bytes, dur, error]
+ );
+ return { status, body, bytes, error };
+}
+
+async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function isOptedOut(domain) {
+ if (!domain) return false;
+ const r = await db.one(`SELECT 1 FROM directory_optout WHERE domain = $1 LIMIT 1`, [domain.toLowerCase()]);
+ return !!r;
+}
+
+// ---- WIA-specific parsing
+// The public locator is iMIS-backed; profile cards expose an `href` that includes
+// `compose.asp?contactid=...`. The directory listing is paginated; we walk pages
+// until we hit an empty page or PER_RUN_CAP.
+//
+// Because parsing iMIS HTML across versions is brittle, this scraper is written
+// to expect a SEED LIST of profile URLs (one per line) at scripts/wia-profile-urls.txt.
+// You can build that seed list from the directory in a browser, OR a follow-up
+// crawler can populate it. This decouples "discover URLs" from "parse a profile",
+// which keeps each step auditable and respects the daily cap.
+const fs = require('fs');
+const path = require('path');
+const SEED_PATH = path.join(__dirname, 'wia-profile-urls.txt');
+
+function loadSeedUrls() {
+ if (!fs.existsSync(SEED_PATH)) {
+ console.error(`[scrape-wia] No seed file at ${SEED_PATH}. Create it with one WIA profile URL per line.`);
+ return [];
+ }
+ return fs.readFileSync(SEED_PATH, 'utf8')
+ .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
+}
+
+function extractText(html, regex) {
+ const m = html.match(regex);
+ return m ? decodeHtml(m[1]).trim() : null;
+}
+
+function decodeHtml(s) {
+ return s
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
+ .replace(/<[^>]+>/g, ''); // strip nested tags
+}
+
+function parseWiaProfile(html, sourceUrl) {
+ // Extract per DATA_POLICY §3 — only allowed fields.
+ // These selectors are heuristic; they target labeled fields in iMIS profile output.
+ const businessName =
+ extractText(html, /<span[^>]*id="[^"]*Title[^"]*"[^>]*>([^<]+)</i) ||
+ extractText(html, /<h1[^>]*>([^<]+)<\/h1>/i);
+
+ if (!businessName) return null;
+
+ const website =
+ extractText(html, /href="(https?:\/\/[^"]+)"[^>]*>(?:Website|Visit)/i) ||
+ extractText(html, /<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>https?:\/\//i);
+
+ // Locality fields — accept "City, State Country" or split lines
+ const locality =
+ extractText(html, /<span[^>]*City[^>]*>([^<]+)</i) ||
+ extractText(html, /<div[^>]*Address[^>]*>[\s\S]*?,\s*([^<]+)<\/div>/i);
+
+ const stateAbbr = extractText(html, /<span[^>]*State[^>]*>([^<]+)</i);
+ const country = extractText(html, /<span[^>]*Country[^>]*>([^<]+)</i) || 'US';
+
+ const bio = extractText(html, /<div[^>]*(?:Bio|About|Description)[^>]*>([\s\S]{0,1200}?)<\/div>/i);
+ const trimmedBio = bio ? bio.slice(0, 600) : null;
+
+ // Accreditations — look for explicit list / common keywords
+ const accreditations = [];
+ if (/wia\s+certified|certified\s+installer/i.test(html)) accreditations.push('WIA Certified Installer');
+ if (/master\s+installer/i.test(html)) accreditations.push('WIA Master Installer');
+ if (/maya\s+romanoff/i.test(html)) accreditations.push('Maya Romanoff Trained');
+ if (/fromental/i.test(html)) accreditations.push('Fromental Trained');
+
+ return {
+ business_name: businessName,
+ city: locality,
+ state: stateAbbr,
+ country,
+ website,
+ bio: trimmedBio,
+ accreditations,
+ source_name: SOURCE_NAME,
+ source_url: sourceUrl,
+ source_scraped_at: new Date().toISOString()
+ };
+}
+
+// ---- Optional Instagram enrichment from the studio's own website
+async function enrichInstagram(websiteUrl) {
+ if (!websiteUrl) return null;
+ try {
+ const u = new URL(websiteUrl);
+ if (await isOptedOut(u.hostname)) return null;
+ const { body } = await fetchPage(websiteUrl, 'studio_site');
+ if (!body) return null;
+ const m = body.match(/instagram\.com\/([A-Za-z0-9._]{1,30})(?=["'\/?#])/i);
+ if (!m) return null;
+ const handle = m[1].replace(/\/$/, '');
+ if (['p', 'reel', 'tv', 'explore', 'about', 'directory'].includes(handle.toLowerCase())) return null;
+ return handle;
+ } catch (e) {
+ return null;
+ }
+}
+
+// ---- Insert / upsert
+async function upsertInstaller(rec) {
+ // Slug from business name + city
+ const baseSlug = slugify(`${rec.business_name} ${rec.city || ''}`, { lower: true, strict: true }).slice(0, 70) || 'installer';
+ let slug = baseSlug;
+ let n = 2;
+ while (await db.one('SELECT id FROM installers WHERE slug=$1', [slug])) {
+ // If same source_url, don't dupe
+ const existing = await db.one('SELECT source_url FROM installers WHERE slug=$1', [slug]);
+ if (existing && existing.source_url === rec.source_url) {
+ console.log(` • SKIP (already imported): ${slug}`);
+ return null;
+ }
+ slug = `${baseSlug}-${n++}`;
+ }
+
+ // Synthetic email — required by NOT NULL but never used. Pattern keeps it
+ // obviously non-deliverable: nph-unclaimed-<slug>@listings.local. Real email
+ // comes from studio when they claim.
+ const placeholderEmail = `nph-unclaimed-${slug}@listings.local`;
+ // Bcrypt hash of a random unguessable string — login is impossible until the
+ // studio claims and resets via the claim flow.
+ const lockedHash = '$2b$10$' + Buffer.from(crypto.randomUUID()).toString('base64').slice(0, 53);
+
+ const r = await db.one(
+ `INSERT INTO installers
+ (slug, email, password_hash, business_name, city, state, country,
+ website, bio, accreditations, instagram_handle,
+ claim_status, status, tier, subscription_status,
+ source_name, source_url, source_scraped_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'unclaimed','pending','basic','inactive',$12,$13,$14)
+ RETURNING id, slug`,
+ [slug, placeholderEmail, lockedHash, rec.business_name, rec.city, rec.state, rec.country || 'US',
+ rec.website, rec.bio, rec.accreditations || [], rec.instagram_handle || null,
+ rec.source_name, rec.source_url, rec.source_scraped_at]
+ );
+ return r;
+}
+
+const crypto = require('crypto');
+
+async function main() {
+ console.log(`[scrape-wia] start · commit=${COMMIT} · enrich-ig=${ENRICH_IG} · max=${PER_RUN_CAP}`);
+
+ // Daily cap check
+ const todayCount = await db.one(
+ `SELECT COUNT(*)::int AS c FROM scrape_log WHERE source=$1 AND created_at::date = CURRENT_DATE`,
+ [SOURCE_NAME]
+ );
+ if (todayCount.c >= DAILY_CAP) {
+ console.error(`[scrape-wia] daily cap hit (${todayCount.c}/${DAILY_CAP}), aborting`);
+ process.exit(1);
+ }
+
+ const seedUrls = loadSeedUrls();
+ if (seedUrls.length === 0) {
+ console.error('[scrape-wia] no seed URLs — see scripts/wia-profile-urls.txt comment in script');
+ process.exit(1);
+ }
+
+ let imported = 0, skipped = 0, failed = 0;
+ for (const url of seedUrls) {
+ if (imported >= PER_RUN_CAP) {
+ console.log(`[scrape-wia] hit per-run cap of ${PER_RUN_CAP}, stopping`);
+ break;
+ }
+ try {
+ const u = new URL(url);
+ if (await isOptedOut(u.hostname)) { console.log(` · OPTED-OUT: ${url}`); skipped++; continue; }
+ } catch {}
+
+ console.log(`[scrape-wia] fetch: ${url}`);
+ const { status, body, error } = await fetchPage(url, SOURCE_NAME);
+ if (error || !body) { console.warn(` ! ${status} ${error || 'no body'}`); failed++; await sleep(REQUEST_DELAY_MS); continue; }
+
+ const rec = parseWiaProfile(body, url);
+ if (!rec) { console.warn(` ! parse: no business name found`); failed++; await sleep(REQUEST_DELAY_MS); continue; }
+
+ if (ENRICH_IG && rec.website) {
+ console.log(` · enrich IG from ${rec.website}`);
+ await sleep(REQUEST_DELAY_MS);
+ rec.instagram_handle = await enrichInstagram(rec.website);
+ }
+
+ if (COMMIT) {
+ const inserted = await upsertInstaller(rec);
+ if (inserted) {
+ console.log(` ✓ INSERT id=${inserted.id} slug=${inserted.slug} (${rec.business_name})`);
+ imported++;
+ } else {
+ skipped++;
+ }
+ } else {
+ console.log(` · DRY: ${rec.business_name} | ${rec.city}, ${rec.state} ${rec.country} | ${rec.website || '(no site)'} | ig=${rec.instagram_handle || '-'} | acc=${(rec.accreditations || []).join(', ')}`);
+ imported++;
+ }
+
+ await sleep(REQUEST_DELAY_MS);
+ }
+
+ console.log(`[scrape-wia] done · imported=${imported} skipped=${skipped} failed=${failed}`);
+ if (failed / Math.max(1, imported + failed) > 0.10) {
+ // Standing rule: alert on >10% batch failure
+ console.warn(`[scrape-wia] FAILURE-RATE-WARNING: failure rate exceeded 10% — review scrape_log and adjust parser`);
+ }
+ await db.pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/send-claim-invitations.js b/scripts/send-claim-invitations.js
new file mode 100644
index 0000000..d7b07de
--- /dev/null
+++ b/scripts/send-claim-invitations.js
@@ -0,0 +1,215 @@
+#!/usr/bin/env node
+// Gated send script for the "claim your listing" outbound campaign.
+//
+// Default mode: DRY-RUN. Prints exactly what would be sent + what's blocked.
+// Use --commit to actually send via George.
+//
+// CRITICAL — fail-closed prerequisites enforced via lib/compliance.js:
+// 1. MAILING_ADDRESS env (real, deliverable street address)
+// 2. PUBLIC_URL (https in prod, non-localhost in dev)
+// 3. SESSION_SECRET (or UNSUBSCRIBE_SIGNING_SECRET)
+// 4. comms_suppression / comms_send_audit / unsubscribe_tokens tables
+//
+// The script ALSO checks Steve's standing rule from MEMORY.md: never use
+// Anthropic API key — irrelevant here, no LLM calls.
+//
+// PER-RECIPIENT:
+// - Suppression scrub against comms_suppression
+// - Audit row in comms_send_audit (decision: sent | blocked_*)
+// - List-Unsubscribe header on every send
+// - In-body compliance footer with mailing address + unsubscribe link
+//
+// EMAIL SOURCING:
+// The email address is NOT pulled from the installer row (DATA_POLICY §3
+// forbids storing it). It must be supplied via --email on the CLI for a
+// single targeted send, or a CSV via --csv when Steve has authorized the
+// data-collection carve-out for the campaign. The dream-team's verdict is
+// to use IG DM as the primary channel — email is the fallback that needs
+// explicit per-send authorization.
+//
+// USAGE:
+// node scripts/send-claim-invitations.js --slug=studio-slug --email=info@studio.com
+// node scripts/send-claim-invitations.js --slug=studio-slug --email=info@studio.com --commit
+// node scripts/send-claim-invitations.js --csv=./outreach-batch.csv --commit
+//
+// Each row of the CSV must be: slug,email (no header, no quotes).
+
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const db = require('../lib/db');
+const email = require('../lib/email');
+const compliance = require('../lib/compliance');
+
+const ARGS = process.argv.slice(2);
+const COMMIT = ARGS.includes('--commit');
+const arg = (k, def = null) => {
+ const a = ARGS.find(x => x.startsWith(`--${k}=`));
+ return a ? a.split('=').slice(1).join('=') : def;
+};
+
+const CAMPAIGN = 'claim_invite';
+
+function escapeHtml(s) {
+ return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+}
+
+async function loadCsv(csvPath) {
+ const abs = path.resolve(csvPath);
+ const txt = fs.readFileSync(abs, 'utf8');
+ return txt.split(/\r?\n/).filter(Boolean).map(line => {
+ const [slug, addr] = line.split(',').map(s => (s || '').trim());
+ return { slug, email: (addr || '').toLowerCase() };
+ });
+}
+
+function buildClaimInviteHtml({ installer, claimUrl, unsubscribeUrl }) {
+ const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <h1 style="font-size:22px;margin:0 0 12px">Claim your listing on National Paper Hangers</h1>
+ <p style="font-size:15px;line-height:1.6">Hello,</p>
+ <p style="font-size:15px;line-height:1.6">
+ We've listed <strong>${escapeHtml(installer.business_name)}</strong> in the National Paper Hangers
+ public directory based on your <a href="https://wallcoveringinstallers.org" style="color:#0e0e0e">WIA</a>
+ accreditation. Trade buyers (designers, hospitality operators, architects) are finding studios on NPH
+ when they need a luxury wallcovering installer outside Los Angeles.
+ </p>
+ <p style="font-size:15px;line-height:1.6">
+ If you'd like to take control of the listing — update your bio, add portfolio images, set availability,
+ and accept self-booked consultations — claim it in 2 minutes:
+ </p>
+ <p style="text-align:center;margin:24px 0">
+ <a href="${claimUrl}" style="display:inline-block;padding:14px 22px;background:#0e0e0e;color:#fff;text-decoration:none">Claim ${escapeHtml(installer.business_name)}</a>
+ </p>
+ <p style="font-size:13px;color:#666;line-height:1.6">
+ The basic listing is free. Pro ($39/mo) and Signature ($149/mo) add live calendar booking and a Verified badge.
+ No obligation — your listing stays visible either way.
+ </p>
+ <p style="font-size:13px;color:#666;line-height:1.6">
+ Questions? Reply to this email — it goes to a person, not a queue.
+ </p>
+ ${compliance.complianceFooter({ campaign: CAMPAIGN, unsubscribeUrl })}
+</div>`;
+ return html;
+}
+
+async function sendOne({ slug, recipientEmail }) {
+ if (!slug || !recipientEmail) {
+ return { ok: false, reason: 'missing_slug_or_email' };
+ }
+ recipientEmail = recipientEmail.toLowerCase();
+
+ const installer = await db.one(
+ `SELECT id, slug, business_name, website, claim_status FROM installers WHERE slug = $1`,
+ [slug]
+ );
+ if (!installer) {
+ return { ok: false, reason: 'installer_not_found' };
+ }
+ if (installer.claim_status === 'claimed') {
+ await compliance.recordAudit({
+ channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
+ installerId: installer.id, decision: 'blocked_already_claimed', reason: 'claim_status=claimed'
+ });
+ return { ok: false, reason: 'already_claimed' };
+ }
+
+ const suppressed = await compliance.isSuppressed({ channel: 'email', identifier: recipientEmail });
+ if (suppressed) {
+ await compliance.recordAudit({
+ channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
+ installerId: installer.id, decision: 'blocked_suppression', reason: 'recipient on comms_suppression'
+ });
+ return { ok: false, reason: 'suppressed' };
+ }
+
+ const publicUrl = process.env.PUBLIC_URL.replace(/\/+$/, '');
+ const claimUrl = `${publicUrl}/installer/${installer.slug}/claim`;
+ const unsubToken = await compliance.mintUnsubscribeToken({
+ channel: 'email', identifier: recipientEmail, campaign: CAMPAIGN, installerId: installer.id
+ });
+ const unsubscribeUrl = `${publicUrl}/unsubscribe?token=${encodeURIComponent(unsubToken)}`;
+ const subject = `Claim your listing — ${installer.business_name}`;
+ const html = buildClaimInviteHtml({ installer, claimUrl, unsubscribeUrl });
+ const headers = compliance.listUnsubscribeHeader(unsubscribeUrl);
+
+ if (!COMMIT) {
+ console.log(`[DRY-RUN] would send to ${recipientEmail} re: ${installer.business_name}`);
+ console.log(` claim: ${claimUrl}`);
+ console.log(` unsubscribe: ${unsubscribeUrl}`);
+ return { ok: true, dryRun: true };
+ }
+
+ try {
+ const result = await email.sendEmail({
+ to: recipientEmail, subject, html, extraHeaders: headers
+ });
+ await compliance.recordAudit({
+ channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
+ installerId: installer.id,
+ decision: result.ok ? 'sent' : 'failed',
+ reason: result.ok ? null : (result.error || 'send_failed'),
+ subject,
+ messageId: result.id || result.messageId || null,
+ payload: { claimUrl, unsubscribeUrl }
+ });
+ return { ok: result.ok, reason: result.ok ? 'sent' : 'send_failed' };
+ } catch (err) {
+ await compliance.recordAudit({
+ channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
+ installerId: installer.id, decision: 'failed', reason: err.message, subject
+ });
+ return { ok: false, reason: 'exception:' + err.message };
+ }
+}
+
+async function main() {
+ console.log(`[claim-invite] mode=${COMMIT ? 'COMMIT' : 'DRY-RUN'}`);
+
+ // Pre-flight gate. Throws ComplianceError if anything is missing.
+ try {
+ await compliance.assertSendCompliance({ campaign: CAMPAIGN });
+ console.log('[claim-invite] compliance gate: PASS');
+ } catch (err) {
+ console.error(`[claim-invite] compliance gate: FAIL — ${err.code}`);
+ console.error(` ${err.message}`);
+ process.exit(2);
+ }
+
+ const csvPath = arg('csv');
+ const oneSlug = arg('slug');
+ const oneEmail = arg('email');
+
+ let batch = [];
+ if (csvPath) {
+ batch = await loadCsv(csvPath);
+ console.log(`[claim-invite] loaded ${batch.length} recipients from ${csvPath}`);
+ } else if (oneSlug && oneEmail) {
+ batch = [{ slug: oneSlug, email: oneEmail.toLowerCase() }];
+ } else {
+ console.error('Usage: --csv=path.csv OR --slug=… --email=… (add --commit to actually send)');
+ process.exit(1);
+ }
+
+ let sent = 0, blocked = 0, failed = 0, dry = 0;
+ for (const row of batch) {
+ const r = await sendOne({ slug: row.slug, recipientEmail: row.email });
+ if (r.dryRun) dry++;
+ else if (r.ok) sent++;
+ else if (r.reason === 'suppressed' || r.reason === 'already_claimed') blocked++;
+ else failed++;
+ // Throttle so we never blast — 2s between sends
+ if (COMMIT) await new Promise(r => setTimeout(r, 2000));
+ }
+
+ console.log(`[claim-invite] done · sent=${sent} blocked=${blocked} failed=${failed} dry-run=${dry}`);
+
+ // Steve's standing rule: alert on >10% failure rate
+ const total = sent + failed;
+ if (total > 0 && (failed / total) > 0.10) {
+ console.warn(`[claim-invite] FAILURE-RATE-WARNING: ${(failed/total*100).toFixed(0)}% — review comms_send_audit`);
+ }
+
+ await db.pool.end();
+}
+
+main().catch(err => { console.error(err); process.exit(1); });
diff --git a/scripts/wia-profile-urls.txt b/scripts/wia-profile-urls.txt
new file mode 100644
index 0000000..8ee5551
--- /dev/null
+++ b/scripts/wia-profile-urls.txt
@@ -0,0 +1,17 @@
+# Seed list — one WIA profile URL per line.
+#
+# How to populate this file (option C, manual cleanroom step):
+# 1. Open https://www.wallcoveringinstallers.org/ in your browser.
+# 2. Use the public installer locator. Search by city / zip / segment.
+# 3. For each result, copy the profile URL into this file.
+# 4. Save and run: node scripts/scrape-wia.js --commit --enrich-ig
+#
+# Why this manual step exists:
+# - It keeps "discover URLs" auditable (you choose what to import).
+# - It avoids unattended directory crawling that could hit volume the
+# source did not consent to.
+# - It is documented in DATA_POLICY.md §5.
+#
+# Lines starting with # are ignored. Blank lines are ignored.
+# Example:
+# https://www.wallcoveringinstallers.org/members/profile.aspx?id=12345
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..48caa8b
--- /dev/null
+++ b/server.js
@@ -0,0 +1,199 @@
+require('dotenv').config();
+
+const express = require('express');
+const path = require('path');
+const morgan = require('morgan');
+const session = require('express-session');
+const PgSession = require('connect-pg-simple')(session);
+const helmet = require('helmet');
+const rateLimit = require('express-rate-limit');
+
+const { pool } = require('./lib/db');
+const { attachInstaller } = require('./lib/auth');
+const { csrfMiddleware } = require('./lib/csrf');
+
+const publicRoutes = require('./routes/public');
+const authRoutes = require('./routes/auth');
+const adminRoutes = require('./routes/admin');
+const apiRoutes = require('./routes/api');
+const claimRoutes = require('./routes/claim');
+const webhookRoutes = require('./routes/webhooks');
+const unsubscribeRoutes = require('./routes/unsubscribe');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9765', 10);
+const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
+const IS_PROD = process.env.NODE_ENV === 'production';
+const HTTPS_PUBLIC = PUBLIC_URL.startsWith('https://');
+
+// Fail-closed: do not boot prod with the dev secret.
+if (IS_PROD && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'dev-secret-change-me')) {
+ throw new Error('SESSION_SECRET must be set to a strong value in production');
+}
+
+// Trust the first proxy hop (nginx → Node) so req.secure + cookie secure flag
+// behave correctly when terminated upstream.
+app.set('trust proxy', 1);
+
+// View engine
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, 'views'));
+
+app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ // Inline styles are used in EJS templates and email previews; inline
+ // scripts power the GA snippet and small page-local handlers. Tighten
+ // to nonces in a follow-up if/when templates are refactored.
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
+ fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
+ scriptSrc: [
+ "'self'", "'unsafe-inline'",
+ 'https://www.googletagmanager.com',
+ // Stripe.js + Elements
+ 'https://js.stripe.com', 'https://q.stripe.com'
+ ],
+ imgSrc: ["'self'", 'data:', 'https:'],
+ connectSrc: [
+ "'self'",
+ 'https://www.google-analytics.com',
+ 'https://*.analytics.google.com',
+ // Stripe API + Elements telemetry
+ 'https://api.stripe.com', 'https://m.stripe.network', 'https://m.stripe.com'
+ ],
+ // Stripe iframes (Elements card field, 3DS challenge, hCaptcha for radar)
+ frameSrc: ["'self'", 'https://js.stripe.com', 'https://hooks.stripe.com'],
+ frameAncestors: ["'none'"],
+ formAction: ["'self'"],
+ baseUri: ["'self'"]
+ }
+ },
+ hsts: HTTPS_PUBLIC ? { maxAge: 63072000, includeSubDomains: true, preload: true } : false,
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ crossOriginEmbedderPolicy: false
+}));
+
+// Morgan with sensitive-token redaction. The claim-verify and unsubscribe
+// flows put short-lived secrets in URL query params (?token=… and ?t=…);
+// logging them in production is a credential leak via log retention.
+morgan.token('safe-url', (req) => {
+ const u = req.originalUrl || req.url || '';
+ return u.replace(/([?&](?:token|t|sig|key)=)[^&#]+/gi, '$1[REDACTED]');
+});
+const morganFormat = IS_PROD
+ ? ':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'
+ : ':method :safe-url :status :response-time ms - :res[content-length]';
+app.use(morgan(morganFormat));
+
+// Stripe webhook needs raw body — mount BEFORE json parser AND before CSRF.
+app.use('/webhooks/stripe', express.raw({ type: 'application/json' }), webhookRoutes);
+
+app.use(express.json({ limit: '512kb' }));
+app.use(express.urlencoded({ extended: true, limit: '512kb' }));
+app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
+
+app.use(session({
+ store: new PgSession({ pool, tableName: 'session', createTableIfMissing: true }),
+ secret: process.env.SESSION_SECRET || 'dev-secret-change-me',
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ maxAge: 1000 * 60 * 60 * 24 * 30,
+ httpOnly: true,
+ sameSite: 'lax',
+ // Force secure cookies whenever PUBLIC_URL is https, regardless of NODE_ENV.
+ secure: HTTPS_PUBLIC || IS_PROD
+ },
+ name: 'nph.sid'
+}));
+
+// Locals every view gets — set BEFORE CSRF so the CSRF reject page can
+// render error.ejs (which expects `path` to be defined).
+const segmentImage = require('./lib/segment-image');
+app.use((req, res, next) => {
+ res.locals.publicUrl = PUBLIC_URL;
+ res.locals.path = req.path;
+ res.locals.flash = req.session && req.session.flash || null;
+ // Stripe.js needs the publishable key on the page. Empty string → book.ejs
+ // falls through to mocked-redirect mode (no Elements mount).
+ res.locals.stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY || '';
+ // Public-domain segment image picker — every view can call this on any
+ // installer row to get a deterministic PD photo or null.
+ res.locals.pickSegmentImage = segmentImage.pickSegmentImage;
+ res.locals.imageAttribution = segmentImage.attributionFor;
+ // Buyer-side Google sign-in identity (populated by /auth/google/callback).
+ // Views can show "signed in as …" + auto-fill name/email on the booking form.
+ res.locals.consumer = (req.session && req.session.consumer) || null;
+ if (req.session) req.session.flash = null;
+ next();
+});
+
+// CSRF runs after session + locals, before routes. The webhook route is
+// mounted above and never reaches this middleware.
+app.use(csrfMiddleware);
+
+app.use(attachInstaller);
+
+// Rate limiters — applied to specific high-abuse routes.
+const loginLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, max: 10,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
+const claimLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000, max: 5,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
+const bookLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000, max: 8,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
+// Public map JSON. Browsers load it once per /map view; abusive scrapers
+// hit it in tight loops. 60 req/min/IP keeps the legitimate UX snappy
+// while making bulk-extraction expensive.
+const geoLimiter = rateLimit({
+ windowMs: 60 * 1000, max: 60,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
+app.use('/login', loginLimiter);
+app.use('/signup', loginLimiter);
+app.use(['/installer/:slug/claim', '/installer/:slug/claim/complete'], claimLimiter);
+app.use('/api/installers/:slug/book', bookLimiter);
+app.use('/api/installers.geo', geoLimiter);
+
+app.use('/', publicRoutes);
+app.use('/', authRoutes);
+app.use('/', require('./routes/auth-google'));
+app.use('/', require('./routes/auth-linkedin'));
+app.use('/', claimRoutes);
+app.use('/unsubscribe', unsubscribeRoutes);
+app.use('/admin', adminRoutes);
+app.use('/api', apiRoutes);
+
+// 404
+app.use((req, res) => {
+ res.status(404).render('public/404', { title: 'Not Found', path: req.path });
+});
+
+// Error handler
+app.use((err, req, res, next) => {
+ console.error('[error]', err);
+ res.status(err.status || 500);
+ if (req.accepts('html')) {
+ return res.render('public/error', { title: 'Something went wrong', message: err.message, path: req.path });
+ }
+ res.json({ error: err.message || 'internal_error' });
+});
+
+// Don't listen when required by tests (tests/app.js exports app via a separate factory)
+if (require.main === module) {
+ app.listen(PORT, () => {
+ console.log(`[nph] listening on :${PORT} (${process.env.NODE_ENV || 'development'})`);
+ });
+}
+
+module.exports = app;
diff --git a/tests/app.js b/tests/app.js
new file mode 100644
index 0000000..5979e43
--- /dev/null
+++ b/tests/app.js
@@ -0,0 +1,65 @@
+// Shared Express app for tests — identical to server.js but no .listen()
+// so supertest can bind its own ephemeral port.
+require('dotenv').config();
+
+const express = require('express');
+const path = require('path');
+const session = require('express-session');
+const { pool } = require('../lib/db');
+const { attachInstaller } = require('../lib/auth');
+const segmentImage = require('../lib/segment-image');
+
+const publicRoutes = require('../routes/public');
+const authRoutes = require('../routes/auth');
+const adminRoutes = require('../routes/admin');
+const apiRoutes = require('../routes/api');
+const claimRoutes = require('../routes/claim');
+const webhookRoutes = require('../routes/webhooks');
+const unsubscribeRoutes = require('../routes/unsubscribe');
+
+const app = express();
+
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, '..', 'views'));
+
+app.use('/webhooks/stripe', express.raw({ type: 'application/json' }), webhookRoutes);
+app.use(express.json({ limit: '512kb' }));
+app.use(express.urlencoded({ extended: true, limit: '512kb' }));
+app.use(express.static(path.join(__dirname, '..', 'public'), { maxAge: 0 }));
+
+// In-memory session store — no DB writes during tests
+app.use(session({
+ store: new (require('express-session').MemoryStore)(),
+ secret: 'test-secret',
+ resave: false,
+ saveUninitialized: false,
+ name: 'nph.sid'
+}));
+
+app.use((req, res, next) => {
+ res.locals.publicUrl = 'http://localhost';
+ res.locals.path = req.path;
+ res.locals.flash = null;
+ res.locals.pickSegmentImage = segmentImage.pickSegmentImage;
+ res.locals.imageAttribution = segmentImage.attributionFor;
+ res.locals.stripePublishableKey = '';
+ res.locals.consumer = null;
+ next();
+});
+
+app.use(attachInstaller);
+app.use('/', publicRoutes);
+app.use('/', authRoutes);
+app.use('/', claimRoutes);
+app.use('/unsubscribe', unsubscribeRoutes);
+app.use('/admin', adminRoutes);
+app.use('/api', apiRoutes);
+
+app.use((req, res) => res.status(404).render('public/404', { title: 'Not Found' }));
+app.use((err, req, res, next) => {
+ res.status(err.status || 500);
+ if (req.accepts('html')) return res.render('public/error', { title: 'Error', message: err.message });
+ res.json({ error: err.message });
+});
+
+module.exports = app;
diff --git a/tests/compliance.test.js b/tests/compliance.test.js
new file mode 100644
index 0000000..d5feb8f
--- /dev/null
+++ b/tests/compliance.test.js
@@ -0,0 +1,141 @@
+// Coverage for the security primitives shipped this push:
+// - lib/booking-token.js (HMAC sign/verify for /bookings/:uuid IDOR-gate)
+// - lib/compliance.js (CAN-SPAM pre-flight + suppression + unsubscribe tokens)
+// - routes/unsubscribe (token consumption, suppression write)
+//
+// Read-only against the live local DB except for the unsubscribe-token write,
+// which inserts then immediately reverses (or short-circuits via DRY-RUN).
+
+'use strict';
+
+const { test, after } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+
+const app = require('./app');
+const db = require('../lib/db');
+const bookingToken = require('../lib/booking-token');
+const compliance = require('../lib/compliance');
+
+const request = supertest(app);
+
+// ─────────────────────────────────────────────────────────────────────────────
+// booking-token: HMAC sign/verify
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('bookingToken: sign + verify round-trip', () => {
+ const uuid = '00000000-0000-4000-8000-000000000001';
+ const sig = bookingToken.sign(uuid);
+ assert.equal(typeof sig, 'string');
+ assert.equal(sig.length, 24);
+ assert.equal(bookingToken.verify(uuid, sig), true);
+});
+
+test('bookingToken: rejects mutated signature', () => {
+ const uuid = '00000000-0000-4000-8000-000000000002';
+ const sig = bookingToken.sign(uuid);
+ const tampered = sig.slice(0, -1) + (sig.slice(-1) === 'a' ? 'b' : 'a');
+ assert.equal(bookingToken.verify(uuid, tampered), false);
+});
+
+test('bookingToken: rejects sig from a different uuid', () => {
+ const sig = bookingToken.sign('aaa');
+ assert.equal(bookingToken.verify('bbb', sig), false);
+});
+
+test('bookingToken: rejects empty/missing input', () => {
+ assert.equal(bookingToken.verify('uuid', ''), false);
+ assert.equal(bookingToken.verify('', 'sig'), false);
+ assert.equal(bookingToken.verify(null, null), false);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// compliance: assertSendCompliance fails closed without MAILING_ADDRESS
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('compliance: assertSendCompliance throws no_mailing_address when MAILING_ADDRESS unset', async () => {
+ const original = process.env.MAILING_ADDRESS;
+ delete process.env.MAILING_ADDRESS;
+ try {
+ await assert.rejects(
+ () => compliance.assertSendCompliance({ campaign: 'unit_test' }),
+ err => err.code === 'no_mailing_address'
+ );
+ } finally {
+ if (original !== undefined) process.env.MAILING_ADDRESS = original;
+ }
+});
+
+test('compliance: assertSendCompliance throws no_campaign on missing campaign', async () => {
+ await assert.rejects(
+ () => compliance.assertSendCompliance({}),
+ err => err.code === 'no_campaign'
+ );
+});
+
+test('compliance: assertSendCompliance rejects placeholder MAILING_ADDRESS values', async () => {
+ const original = process.env.MAILING_ADDRESS;
+ for (const placeholder of ['TBD', 'placeholder', '', 'localhost', 'TODO fill in']) {
+ process.env.MAILING_ADDRESS = placeholder;
+ await assert.rejects(
+ () => compliance.assertSendCompliance({ campaign: 'unit_test' }),
+ err => err.code === 'no_mailing_address',
+ `expected reject for "${placeholder}"`
+ );
+ }
+ if (original !== undefined) process.env.MAILING_ADDRESS = original;
+ else delete process.env.MAILING_ADDRESS;
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// compliance: unsubscribe token mint → consume → suppression
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('compliance: mint → consume unsubscribe token, then suppression check sees recipient', async () => {
+ const fakeEmail = `unit-test-${Date.now()}@example.invalid`;
+ const token = await compliance.mintUnsubscribeToken({
+ channel: 'email', identifier: fakeEmail, campaign: 'unit_test'
+ });
+ assert.equal(typeof token, 'string');
+ assert.ok(token.length >= 16);
+
+ const consumed = await compliance.consumeUnsubscribeToken(token);
+ assert.ok(consumed, 'expected consume to return the row');
+ assert.equal(consumed.identifier, fakeEmail);
+ assert.equal(consumed.channel, 'email');
+
+ // Mirror what routes/unsubscribe.js does on consume — record suppression.
+ await compliance.addSuppression({
+ channel: 'email', identifier: fakeEmail, reason: 'unsubscribe', source: 'unit_test'
+ });
+
+ const suppressed = await compliance.isSuppressed({ channel: 'email', identifier: fakeEmail });
+ assert.equal(suppressed, true);
+
+ // Cleanup so the test row doesn't pollute production data.
+ await db.query('DELETE FROM comms_suppression WHERE identifier = $1', [fakeEmail]);
+ await db.query('DELETE FROM unsubscribe_tokens WHERE identifier = $1', [fakeEmail]);
+});
+
+test('compliance: consumeUnsubscribeToken returns null for unknown token', async () => {
+ const r = await compliance.consumeUnsubscribeToken('not-a-real-token-xyz-12345');
+ assert.equal(r, null);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// /unsubscribe HTTP — bad token paths
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('GET /unsubscribe with no token returns 400', async () => {
+ const res = await request.get('/unsubscribe');
+ assert.equal(res.status, 400);
+});
+
+test('GET /unsubscribe with garbage token returns 400', async () => {
+ const res = await request.get('/unsubscribe?token=garbage-not-real-token');
+ assert.equal(res.status, 400);
+});
+
+after(async () => {
+ await db.pool.end();
+});
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
new file mode 100644
index 0000000..bfb8d86
--- /dev/null
+++ b/tests/smoke.test.js
@@ -0,0 +1,308 @@
+// Smoke test suite — node:test + supertest
+// Runs against the live local `national_paper_hangers` DB (read-only except
+// the claim-token subtests, which clean up after themselves).
+
+'use strict';
+
+const { test, after } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+const crypto = require('crypto');
+
+const app = require('./app');
+const db = require('../lib/db');
+const { availableSlots } = require('../lib/slots');
+
+const request = supertest(app);
+
+// Known slugs from seed data
+const CLAIMED_SLUG = 'atelier-bond-nyc'; // status=active, claim_status=self
+const UNCLAIMED_SLUG = 'demo-unclaimed-ridgeway-paper'; // claim_status=unclaimed
+const BOOK_SLUG = 'paperworks-collective-la'; // status=active, tier=signature
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 1. DB connection ping
+// ─────────────────────────────────────────────────────────────────────────────
+test('DB: pool can query (1+1=2)', async () => {
+ const r = await db.query('SELECT 1+1 AS result');
+ assert.equal(r.rows[0].result, 2);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 2. GET /find — 200 + installer cards in HTML
+// ─────────────────────────────────────────────────────────────────────────────
+test('GET /find returns 200 and contains installer card markup', async () => {
+ const res = await request.get('/find');
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /html/);
+ // Each card has a link to /installer/<slug>
+ assert.ok(
+ res.text.includes('/installer/'),
+ 'Expected at least one /installer/ link in /find HTML'
+ );
+});
+
+test('GET /find?state=NY scopes results', async () => {
+ const res = await request.get('/find?state=NY');
+ assert.equal(res.status, 200);
+ // atelier-bond-nyc is in NY and should appear
+ assert.ok(res.text.includes('atelier-bond-nyc') || res.text.includes('Atelier Bond'));
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 3. GET /installer/:slug — claimed and unclaimed
+// ─────────────────────────────────────────────────────────────────────────────
+test('GET /installer/:slug for a claimed (self) installer returns 200', async () => {
+ const res = await request.get(`/installer/${CLAIMED_SLUG}`);
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /html/);
+ assert.ok(res.text.toLowerCase().includes('atelier bond'));
+});
+
+test('GET /installer/:slug for an unclaimed installer returns 200', async () => {
+ const res = await request.get(`/installer/${UNCLAIMED_SLUG}`);
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /html/);
+});
+
+test('GET /installer/nonexistent-slug returns 404', async () => {
+ const res = await request.get('/installer/does-not-exist-xyz');
+ assert.equal(res.status, 404);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// /map — Leaflet directory map + /api/installers.geo JSON feed
+// ─────────────────────────────────────────────────────────────────────────────
+test('GET /map renders the Leaflet map page', async () => {
+ const res = await request.get('/map');
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /html/);
+ // Leaflet CSS link is the load-bearing dependency the page can't fail without
+ assert.ok(res.text.includes('/vendor/leaflet/leaflet.css'), 'expected Leaflet CSS link');
+ // Page must call /api/installers.geo to render markers
+ assert.ok(res.text.includes('/api/installers.geo'), 'expected geo-feed fetch in page');
+});
+
+test('GET /api/installers.geo returns JSON with count + installers array', async () => {
+ const res = await request.get('/api/installers.geo');
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /json/);
+ assert.ok(typeof res.body.count === 'number');
+ assert.ok(Array.isArray(res.body.installers));
+ // If any installers are returned, every entry must have lat/lng coords
+ // (otherwise the marker cluster crashes)
+ if (res.body.installers.length > 0) {
+ const sample = res.body.installers[0];
+ assert.ok(typeof sample.lat === 'number' && Number.isFinite(sample.lat), 'lat must be a number');
+ assert.ok(typeof sample.lng === 'number' && Number.isFinite(sample.lng), 'lng must be a number');
+ assert.ok(typeof sample.slug === 'string' && sample.slug.length > 0, 'slug required');
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// /healthz — used by nginx upstream + uptime monitors after Kamatera deploy
+// ─────────────────────────────────────────────────────────────────────────────
+test('GET /healthz returns 200 with ok:true', async () => {
+ const res = await request.get('/healthz');
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /json/);
+ assert.equal(res.body.ok, true);
+ assert.ok(typeof res.body.ts === 'number');
+ assert.equal(res.headers['cache-control'], 'no-store');
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// /admin/* redirects to /login when unauthenticated
+// ─────────────────────────────────────────────────────────────────────────────
+test('GET /admin redirects to /login when unauthenticated', async () => {
+ const res = await request.get('/admin').redirects(0);
+ assert.equal(res.status, 302);
+ assert.match(res.headers.location, /^\/login/);
+});
+
+test('GET /admin/bookings/123 redirects to /login when unauthenticated', async () => {
+ const res = await request.get('/admin/bookings/123').redirects(0);
+ assert.equal(res.status, 302);
+ assert.match(res.headers.location, /^\/login/);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 4. GET /installer/:slug/book — booking form renders
+// ─────────────────────────────────────────────────────────────────────────────
+test('GET /installer/:slug/book renders booking form for active installer', async () => {
+ const res = await request.get(`/installer/${BOOK_SLUG}/book`);
+ assert.equal(res.status, 200);
+ assert.match(res.headers['content-type'], /html/);
+ // Page should mention booking or the installer name
+ assert.ok(
+ res.text.toLowerCase().includes('book') || res.text.includes('Paperworks'),
+ 'Expected book page content'
+ );
+});
+
+test('GET /installer/:slug/book for inactive installer returns 404', async () => {
+ // oakwood-wallcoverings-tx is status=pending so /book should 404
+ const res = await request.get('/installer/oakwood-wallcoverings-tx/book');
+ assert.equal(res.status, 404);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 5. lib/slots.js unit test (pure logic, no network, uses real DB for fixture)
+// ─────────────────────────────────────────────────────────────────────────────
+test('slots: feed availability + time-off + booking → correct openings count & timing', async () => {
+ // Use a test installer inserted into the DB only for this test,
+ // then removed in cleanup.
+ const email = `smoke-slots-test-${Date.now()}@test.invalid`;
+ const pw = '$2b$10$.2P40NWtgXQiylgmJWIprO6.cML8l.wHfMwzB29jAd01uVXyjofka'; // demo1234
+
+ // Insert a temporary installer
+ const ins = await db.one(
+ `INSERT INTO installers (slug, email, password_hash, business_name, status, tier)
+ VALUES ($1, $2, $3, 'Smoke Test Studio', 'active', 'pro')
+ RETURNING id`,
+ [`smoke-slots-test-${Date.now()}`, email, pw]
+ );
+ const id = ins.id;
+
+ try {
+ // Availability: Monday–Friday 09:00–17:00 for next 7 days
+ // Luxon weekday: 1=Mon..5=Fri → stored as 0=Sun..6=Sat so Mon=1, Fri=5
+ for (const dow of [1, 2, 3, 4, 5]) {
+ await db.query(
+ `INSERT INTO installer_availability (installer_id, day_of_week, start_time, end_time, timezone)
+ VALUES ($1, $2, '09:00', '17:00', 'America/Los_Angeles')`,
+ [id, dow]
+ );
+ }
+
+ // Pick tomorrow (should definitely be a future date)
+ const { DateTime } = require('luxon');
+ const tz = 'America/Los_Angeles';
+ const tomorrow = DateTime.now().setZone(tz).plus({ days: 1 });
+ // Find a Monday within the next 7 days so we have at least one working day
+ let testDay = tomorrow;
+ for (let i = 0; i < 7; i++) {
+ // Luxon weekday 1=Mon
+ if (testDay.weekday >= 1 && testDay.weekday <= 5) break;
+ testDay = testDay.plus({ days: 1 });
+ }
+ const dayStr = testDay.toISODate(); // e.g. "2026-05-06"
+
+ // Time-off: block 09:00–11:00 on that day
+ await db.query(
+ `INSERT INTO installer_time_off (installer_id, start_at, end_at, reason, all_day)
+ VALUES ($1,
+ ($2::date + time '09:00') AT TIME ZONE 'America/Los_Angeles',
+ ($2::date + time '11:00') AT TIME ZONE 'America/Los_Angeles',
+ 'Smoke test time-off', false)`,
+ [id, dayStr]
+ );
+
+ // Existing booking: 13:00–14:00 on that day (confirmed)
+ await db.query(
+ `INSERT INTO bookings (installer_id, customer_name, customer_email, scheduled_start, scheduled_end, status)
+ VALUES ($1, 'Test Customer', 'customer@test.invalid',
+ ($2::date + time '13:00') AT TIME ZONE 'America/Los_Angeles',
+ ($2::date + time '14:00') AT TIME ZONE 'America/Los_Angeles',
+ 'confirmed')`,
+ [id, dayStr]
+ );
+
+ // Window 09:00–17:00 (8h) minus time-off 09:00–11:00 = 11:00–17:00 (6h)
+ // minus booking 13:00–14:15 (with 15m buffer end) = two gaps:
+ // 11:00–13:00 → 2 slots of 60m (11:00, 12:00)
+ // 14:15–17:00 → 1h45m → 1 slot of 60m starting 14:15
+ // Total: 3 slots on that day.
+
+ const slots = await availableSlots(id, {
+ startDate: dayStr,
+ endDate: dayStr,
+ slotMinutes: 60,
+ bufferMinutes: 15,
+ timezone: tz
+ });
+
+ assert.ok(slots.length >= 2, `Expected at least 2 open slots, got ${slots.length}`);
+
+ // All slots must be in the future and fit within 09:00–17:00
+ for (const s of slots) {
+ const start = DateTime.fromISO(s.start).setZone(tz);
+ const end = DateTime.fromISO(s.end).setZone(tz);
+ assert.ok(start > DateTime.now().setZone(tz), `Slot start ${s.start} should be in the future`);
+ assert.ok(start.hour >= 9, `Slot start hour ${start.hour} should be >= 9`);
+ assert.ok(end.hour <= 17, `Slot end hour ${end.hour} should be <= 17`);
+ }
+
+ // No slot should overlap the booked 13:00–14:00 window
+ for (const s of slots) {
+ const slotStart = DateTime.fromISO(s.start).setZone(tz);
+ const slotEnd = DateTime.fromISO(s.end).setZone(tz);
+ const bookStart = testDay.set({ hour: 13, minute: 0, second: 0, millisecond: 0 });
+ const bookEnd = testDay.set({ hour: 14, minute: 0, second: 0, millisecond: 0 });
+ const overlaps = slotStart < bookEnd && slotEnd > bookStart;
+ assert.ok(!overlaps, `Slot ${s.start}–${s.end} overlaps existing booking`);
+ }
+
+ } finally {
+ // bookings FK is ON DELETE RESTRICT — must remove explicitly before the installer
+ await db.query('DELETE FROM bookings WHERE installer_id = $1', [id]);
+ await db.query('DELETE FROM installers WHERE id = $1', [id]);
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 6. Claim token: generate, verify, consume, second-use fails
+// ─────────────────────────────────────────────────────────────────────────────
+test('claim token: generate → verify → consume; second-use returns invalid-token state', async () => {
+ // Find an unclaimed installer to use as test subject (read slug only)
+ const target = await db.one(
+ `SELECT id, slug, claim_status, claim_token FROM installers
+ WHERE claim_status = 'unclaimed' LIMIT 1`
+ );
+ assert.ok(target, 'Expected at least one unclaimed installer in DB for claim token test');
+
+ // Snapshot original state so we can restore it
+ const origToken = target.claim_token;
+ const origStatus = target.claim_status;
+
+ const token = crypto.randomBytes(24).toString('base64url');
+
+ try {
+ // 1. Write token
+ await db.query(
+ `UPDATE installers SET claim_status='pending_claim', claim_token=$2, claim_token_at=now() WHERE id=$1`,
+ [target.id, token]
+ );
+
+ // 2. Verify it matches
+ const row1 = await db.one('SELECT claim_token, claim_status FROM installers WHERE id=$1', [target.id]);
+ assert.equal(row1.claim_token, token, 'Token should match what we stored');
+ assert.equal(row1.claim_status, 'pending_claim');
+
+ // 3. Consume it (simulate the verify endpoint's final UPDATE)
+ await db.query(
+ `UPDATE installers SET claim_status='claimed', claimed_at=now(), claim_token=NULL WHERE id=$1`,
+ [target.id]
+ );
+
+ // 4. Second-use: token is now NULL — should not match
+ const row2 = await db.one('SELECT claim_token, claim_status FROM installers WHERE id=$1', [target.id]);
+ assert.equal(row2.claim_token, null, 'Token must be nulled after consumption');
+ assert.notEqual(row2.claim_token, token, 'Second use of token must fail (null !== token)');
+ assert.equal(row2.claim_status, 'claimed');
+
+ } finally {
+ // Restore original state so we don't permanently alter the unclaimed listing
+ await db.query(
+ `UPDATE installers SET claim_status=$2, claim_token=$3, claim_token_at=NULL, claimed_at=NULL WHERE id=$1`,
+ [target.id, origStatus, origToken]
+ );
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Teardown: close the pg pool so node:test can exit cleanly
+// ─────────────────────────────────────────────────────────────────────────────
+after(async () => {
+ await db.pool.end();
+});
diff --git a/views/admin/billing.ejs b/views/admin/billing.ejs
new file mode 100644
index 0000000..64459d1
--- /dev/null
+++ b/views/admin/billing.ejs
@@ -0,0 +1,134 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+ <header class="admin-page-head"><h1>Billing</h1></header>
+ <% if (upgradePrompt) { %><div class="callout callout-warn">Calendar bookings require Pro or higher. Pick a plan to enable customer self-booking.</div><% } %>
+ <div class="callout">
+ <strong>Current tier:</strong> <%= installer.tier %> · <strong>Status:</strong> <%= installer.subscription_status %>
+ <% if (installer.current_period_end) { %> · renews <%= new Date(installer.current_period_end).toLocaleDateString() %><% } %>
+ <% if (!stripeLive) { %><br><span class="muted">Stripe is not yet configured. Upgrades below run in mock mode and toggle the tier locally for testing.</span><% } %>
+ </div>
+
+ <h2>Plans</h2>
+ <div class="pricing-grid">
+ <% ['pro','signature','enterprise'].forEach(function(tier){ %>
+ <article class="price-card <%= installer.tier === tier ? 'price-card-current' : '' %>">
+ <h3><%= tier.charAt(0).toUpperCase() + tier.slice(1) %></h3>
+ <% priceTable.filter(p => p.tier === tier).forEach(function(p){ %>
+ <p class="price"><%= p.label %></p>
+ <form method="post" action="/admin/billing/checkout" class="inline-form">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="hidden" name="tier" value="<%= p.tier %>">
+ <input type="hidden" name="cadence" value="<%= p.cadence %>">
+ <button type="submit" class="btn <%= installer.tier === tier ? 'btn-ghost' : 'btn-primary' %> btn-sm">
+ <%= installer.tier === tier ? 'Current' : 'Choose ' + p.cadence %>
+ </button>
+ </form>
+ <% }); %>
+ </article>
+ <% }); %>
+ </div>
+
+ <% if (installer.stripe_customer_id) { %>
+ <form method="post" action="/admin/billing/portal" class="inline-form" style="margin-top:24px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-ghost">Manage subscription in Stripe portal ↗</button>
+ </form>
+ <% } %>
+
+ <h2 style="margin-top:48px">Booking deposits & payouts</h2>
+ <%
+ var _payoutsActive = !!installer.stripe_account_id && !!installer.stripe_account_charges_enabled;
+ var _depositLabel = '$' + ((defaultDepositCents || 9900) / 100).toFixed(0);
+ var _feePct = ((defaultPlatformFeeBps || 1000) / 100).toFixed(0);
+ %>
+ <p class="muted">
+ NPH collects a <strong><%= _depositLabel %></strong> reservation deposit from every customer at booking. NPH retains a <strong><%= _feePct %>%</strong> marketplace fee; the balance routes to your bank via Stripe Connect.
+ </p>
+
+ <% if (!_payoutsActive) { %>
+ <div class="callout callout-warn">
+ <strong>Payouts are not set up.</strong> Deposits collected for your bookings are held in NPH's account and will transfer once you complete Stripe onboarding.
+ <form method="post" action="/admin/connect/onboard" class="inline-form" style="margin-top:8px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-primary btn-sm">Set up payouts →</button>
+ </form>
+ </div>
+ <% } else { %>
+ <div class="callout callout-ok">
+ <strong>✓ Payouts active.</strong> Connect account <code><%= (installer.stripe_account_id || '').slice(0,12) %>…</code>
+ <% if (installer.stripe_account_payouts_enabled) { %> · Payouts enabled<% } %>
+ <form method="post" action="/admin/connect/onboard" class="inline-form" style="margin-top:8px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-ghost btn-sm">Manage in Stripe ↗</button>
+ </form>
+ </div>
+ <% } %>
+
+ <% if (installerMarket) { %>
+ <div class="stat-grid" style="margin-top:24px">
+ <div class="stat">
+ <span class="stat-label">Deposits paid</span>
+ <span class="stat-value">$<%= (installerMarket.deposits_paid_cents/100).toFixed(2) %></span>
+ <span class="stat-sub muted"><%= installerMarket.count_paid %> bookings</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Platform fee</span>
+ <span class="stat-value">$<%= (installerMarket.platform_fee_cents/100).toFixed(2) %></span>
+ <span class="stat-sub muted">NPH retained</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Your share</span>
+ <span class="stat-value">$<%= ((installerMarket.deposits_paid_cents - installerMarket.platform_fee_cents)/100).toFixed(2) %></span>
+ <span class="stat-sub muted"><%= _payoutsActive ? 'transferred via Stripe' : 'held — set up payouts' %></span>
+ </div>
+ <% if (installerMarket.count_pending > 0) { %>
+ <div class="stat">
+ <span class="stat-label">Awaiting payment</span>
+ <span class="stat-value"><%= installerMarket.count_pending %></span>
+ <span class="stat-sub muted">customer hasn't confirmed</span>
+ </div>
+ <% } %>
+ </div>
+ <% } %>
+
+ <% if (typeof platformMarket !== 'undefined' && platformMarket) { %>
+ <h2 style="margin-top:48px">Platform totals (NPH staff)</h2>
+ <p class="muted">Rolled up across every studio with a paid booking.</p>
+ <div class="stat-grid" style="margin-top:16px">
+ <div class="stat">
+ <span class="stat-label">Deposits collected</span>
+ <span class="stat-value">$<%= (platformMarket.deposits_paid_cents/100).toFixed(2) %></span>
+ <span class="stat-sub muted"><%= platformMarket.count_paid %> bookings</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Platform fee earned</span>
+ <span class="stat-value">$<%= (platformMarket.platform_fee_cents/100).toFixed(2) %></span>
+ <span class="stat-sub muted">NPH revenue</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Transferred to studios</span>
+ <span class="stat-value">$<%= (platformMarket.transferred_cents/100).toFixed(2) %></span>
+ <span class="stat-sub muted">via Stripe Connect</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Held — pending Connect</span>
+ <span class="stat-value">$<%= (platformMarket.payouts_pending_cents/100).toFixed(2) %></span>
+ <span class="stat-sub muted">studios not yet onboarded</span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Active studios</span>
+ <span class="stat-value"><%= platformMarket.active_installers %></span>
+ <span class="stat-sub muted">with at least one paid deposit</span>
+ </div>
+ <% if (platformMarket.count_failed > 0) { %>
+ <div class="stat stat-warn">
+ <span class="stat-label">Failed payments</span>
+ <span class="stat-value"><%= platformMarket.count_failed %></span>
+ <span class="stat-sub muted">card declined / abandoned</span>
+ </div>
+ <% } %>
+ </div>
+ <% } %>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/admin/booking-detail.ejs b/views/admin/booking-detail.ejs
new file mode 100644
index 0000000..3e93fe1
--- /dev/null
+++ b/views/admin/booking-detail.ejs
@@ -0,0 +1,100 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+
+<%
+ function fmt(v) { return (v === null || v === undefined || v === '') ? '—' : v; }
+ function pretty(v) { return v ? String(v).replace(/_/g, ' ') : '—'; }
+ var when = booking.scheduled_start ? new Date(booking.scheduled_start).toLocaleString() : '—';
+ var dur = (booking.scheduled_start && booking.scheduled_end)
+ ? Math.round((new Date(booking.scheduled_end) - new Date(booking.scheduled_start)) / 60000) + ' min'
+ : '—';
+%>
+
+<section class="admin-main">
+ <header class="admin-page-head">
+ <p class="kicker"><a href="/admin/bookings" style="color:inherit">← Back to bookings</a></p>
+ <h1>Booking — <%= booking.customer_name %></h1>
+ <span class="status-badge status-<%= booking.status %>"><%= booking.status %></span>
+ </header>
+
+ <div class="admin-grid" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(320px, 1fr));gap:32px">
+ <section class="admin-section">
+ <h2>Schedule</h2>
+ <dl>
+ <dt>When</dt><dd><%= when %></dd>
+ <dt>Duration</dt><dd><%= dur %></dd>
+ <dt>Type</dt><dd><%= pretty(booking.project_type) %></dd>
+ <dt>Status</dt><dd><%= booking.status %></dd>
+ <% if (booking.confirmed_at) { %><dt>Confirmed</dt><dd><%= new Date(booking.confirmed_at).toLocaleString() %></dd><% } %>
+ <% if (booking.completed_at) { %><dt>Completed</dt><dd><%= new Date(booking.completed_at).toLocaleString() %></dd><% } %>
+ <% if (booking.canceled_at) { %><dt>Canceled</dt><dd><%= new Date(booking.canceled_at).toLocaleString() %></dd><% } %>
+ </dl>
+ </section>
+
+ <section class="admin-section">
+ <h2>Customer</h2>
+ <dl>
+ <dt>Name</dt><dd><%= booking.customer_name %></dd>
+ <dt>Email</dt><dd><a href="mailto:<%= booking.customer_email %>"><%= booking.customer_email %></a></dd>
+ <dt>Phone</dt><dd><%= fmt(booking.customer_phone) %></dd>
+ </dl>
+ </section>
+
+ <section class="admin-section">
+ <h2>Project address</h2>
+ <p>
+ <%= [booking.address_line1, booking.address_line2].filter(Boolean).join(', ') || '—' %><br>
+ <%= [booking.city, booking.state, booking.zip].filter(Boolean).join(', ') || '—' %>
+ </p>
+ </section>
+
+ <section class="admin-section">
+ <h2>Project brief</h2>
+ <dl>
+ <dt>Brand</dt><dd><%= fmt(booking.brand) %></dd>
+ <dt>Brand SKU / pattern</dt><dd><%= fmt(booking.brand_sku) %></dd>
+ <dt>Material</dt><dd><%= pretty(booking.material) %></dd>
+ <dt>Rooms / surfaces</dt><dd><%= fmt(booking.surfaces) %></dd>
+ <dt>Square feet</dt><dd><%= fmt(booking.square_feet) %></dd>
+ <dt>Estimated rolls</dt><dd><%= fmt(booking.roll_count_estimate) %></dd>
+ <dt>Ceiling height</dt><dd><%= booking.ceiling_height_ft ? booking.ceiling_height_ft + ' ft' : '—' %></dd>
+ <dt>Surface state</dt><dd><%= pretty(booking.surface_state) %></dd>
+ <dt>Access</dt><dd><%= pretty(booking.access_constraints) %></dd>
+ <dt>Budget band</dt><dd><%= pretty(booking.budget_band) %></dd>
+ </dl>
+ </section>
+
+ <% if (booking.customer_notes) { %>
+ <section class="admin-section">
+ <h2>Customer notes</h2>
+ <p style="white-space:pre-wrap"><%= booking.customer_notes %></p>
+ </section>
+ <% } %>
+ <% if (booking.installer_notes) { %>
+ <section class="admin-section">
+ <h2>Your notes</h2>
+ <p style="white-space:pre-wrap"><%= booking.installer_notes %></p>
+ </section>
+ <% } %>
+ </div>
+
+ <div class="actions" style="margin-top:32px;display:flex;gap:12px;flex-wrap:wrap">
+ <% if (booking.status === 'pending') { %>
+ <form method="post" action="/admin/bookings/<%= booking.id %>/confirm" class="inline-form">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button class="btn btn-primary">Confirm booking</button>
+ </form>
+ <form method="post" action="/admin/bookings/<%= booking.id %>/decline" class="inline-form">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button class="btn btn-ghost">Decline</button>
+ </form>
+ <% } else if (booking.status === 'confirmed') { %>
+ <form method="post" action="/admin/bookings/<%= booking.id %>/complete" class="inline-form">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button class="btn btn-primary">Mark complete</button>
+ </form>
+ <% } %>
+ </div>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/bookings.ejs b/views/admin/bookings.ejs
new file mode 100644
index 0000000..a6ccc19
--- /dev/null
+++ b/views/admin/bookings.ejs
@@ -0,0 +1,70 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+ <header class="admin-page-head">
+ <h1>Bookings</h1>
+ <% if (typeof market !== 'undefined' && market) { %>
+ <p class="muted" style="margin-top:4px">
+ <strong><%= market.count_paid %></strong> paid ·
+ <strong>$<%= (market.deposits_paid_cents/100).toFixed(2) %></strong> deposits collected ·
+ NPH fee <strong>$<%= (market.platform_fee_cents/100).toFixed(2) %></strong>
+ <% if (market.count_pending > 0) { %> · <%= market.count_pending %> awaiting payment<% } %>
+ <% if (market.count_failed > 0) { %> · <span style="color:#c44">⚠ <%= market.count_failed %> failed</span><% } %>
+ </p>
+ <% } %>
+ </header>
+ <nav class="filter-tabs">
+ <% ['all','upcoming','pending','past','canceled'].forEach(function(f){ %>
+ <a href="?status=<%= f %>" class="<%= filter === f ? 'is-current' : '' %>"><%= f %></a>
+ <% }); %>
+ </nav>
+ <% if (bookings.length === 0) { %>
+ <div class="empty-state">No bookings in this view.</div>
+ <% } else { %>
+ <table class="data-table">
+ <thead><tr><th>When</th><th>Customer</th><th>Type / brief</th><th>Address</th><th>Deposit</th><th>Status</th><th>Actions</th></tr></thead>
+ <tbody>
+ <% bookings.forEach(function(b){
+ var briefBits = [];
+ if (b.brand) briefBits.push(b.brand + (b.brand_sku ? ' / ' + b.brand_sku : ''));
+ if (b.material) briefBits.push(b.material.replace(/_/g,' '));
+ if (b.square_feet) briefBits.push(b.square_feet + ' sq ft');
+ if (b.roll_count_estimate) briefBits.push('~' + b.roll_count_estimate + ' rolls');
+ if (b.ceiling_height_ft) briefBits.push(b.ceiling_height_ft + 'ft ceil');
+ if (b.surface_state) briefBits.push(b.surface_state.replace(/_/g,' '));
+ if (b.access_constraints) briefBits.push(b.access_constraints.replace(/_/g,' '));
+ %>
+ <tr>
+ <td><a href="/admin/bookings/<%= b.id %>" style="color:inherit"><%= new Date(b.scheduled_start).toLocaleString() %></a></td>
+ <td><a href="/admin/bookings/<%= b.id %>" style="color:inherit"><%= b.customer_name %></a><br><span class="muted"><%= b.customer_email %><% if (b.customer_phone) { %> · <%= b.customer_phone %><% } %></span></td>
+ <td>
+ <a href="/admin/bookings/<%= b.id %>" style="color:inherit">
+ <%= (b.project_type || 'consultation').replace(/_/g,' ') %>
+ </a>
+ <% if (briefBits.length) { %><br><span class="muted" style="font-size:12px"><%= briefBits.join(' · ') %></span><% } %>
+ </td>
+ <td><%= [b.city, b.state, b.zip].filter(Boolean).join(', ') %></td>
+ <td>
+ <% if (b.deposit_amount_cents) { %>
+ <strong>$<%= (b.deposit_amount_cents/100).toFixed(2) %></strong><br>
+ <span class="status-badge deposit-<%= b.deposit_status || 'none' %>"><%= b.deposit_status || '—' %></span>
+ <% } else { %>
+ <span class="muted">—</span>
+ <% } %>
+ </td>
+ <td><span class="status-badge status-<%= b.status %>"><%= b.status %></span></td>
+ <td class="actions">
+ <% if (b.status === 'pending') { %>
+ <form method="post" action="/admin/bookings/<%= b.id %>/confirm" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-primary btn-sm">Confirm</button></form>
+ <form method="post" action="/admin/bookings/<%= b.id %>/decline" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-ghost btn-sm">Decline</button></form>
+ <% } else if (b.status === 'confirmed') { %>
+ <form method="post" action="/admin/bookings/<%= b.id %>/complete" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-primary btn-sm">Mark complete</button></form>
+ <% } %>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <% } %>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/admin/calendar.ejs b/views/admin/calendar.ejs
new file mode 100644
index 0000000..5164342
--- /dev/null
+++ b/views/admin/calendar.ejs
@@ -0,0 +1,53 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+ <header class="admin-page-head"><h1>Calendar</h1><p class="muted">Set weekly availability and block time off. Customers self-book into open windows.</p></header>
+
+ <div class="cal-layout">
+ <section class="admin-section">
+ <h2>Weekly availability</h2>
+ <p class="muted">Recurring schedule. Slot length is set per-booking by the customer (default 60 min, 15 min buffer).</p>
+ <table class="data-table availability-table">
+ <thead><tr><th>Day</th><th>Start</th><th>End</th><th></th></tr></thead>
+ <tbody id="availability-tbody">
+ <% availability.forEach(function(a){ %>
+ <tr data-id="<%= a.id %>">
+ <td><%= ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][a.day_of_week] %></td>
+ <td><%= a.start_time %></td>
+ <td><%= a.end_time %></td>
+ <td><button type="button" class="btn btn-ghost btn-sm" data-remove-availability="<%= a.id %>">Remove</button></td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <form id="add-availability" class="inline-form-row">
+ <select name="day_of_week" required>
+ <option value="">Day…</option>
+ <option value="0">Sun</option><option value="1">Mon</option><option value="2">Tue</option>
+ <option value="3">Wed</option><option value="4">Thu</option><option value="5">Fri</option><option value="6">Sat</option>
+ </select>
+ <input type="time" name="start_time" required>
+ <input type="time" name="end_time" required>
+ <button type="submit" class="btn btn-primary btn-sm">Add window</button>
+ </form>
+ </section>
+
+ <section class="admin-section">
+ <h2>Time off / blocks</h2>
+ <ul id="time-off-list" class="time-off-list"></ul>
+ <form id="add-time-off" class="inline-form-row">
+ <input type="datetime-local" name="start_at" required>
+ <input type="datetime-local" name="end_at" required>
+ <input type="text" name="reason" placeholder="Reason (optional)">
+ <button type="submit" class="btn btn-primary btn-sm">Block</button>
+ </form>
+ </section>
+
+ <section class="admin-section">
+ <h2>Upcoming on your calendar</h2>
+ <div id="upcoming-cal-list" class="muted">Loading…</div>
+ </section>
+ </div>
+</section>
+<%- include('../partials/footer') %>
+<script src="/js/calendar-installer.js" defer></script>
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
new file mode 100644
index 0000000..c0a2556
--- /dev/null
+++ b/views/admin/dashboard.ejs
@@ -0,0 +1,177 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+
+<section class="admin-main">
+ <%
+ /* Onboarding completion signals derived from available render data */
+ var _profileComplete = !!installer.profile_complete;
+ var _availabilitySet = (typeof hasAvailability !== 'undefined') ? !!hasAvailability : false;
+ var _proActive = installer.subscription_status === 'active';
+ var _payoutsActive = !!installer.stripe_account_id && !!installer.stripe_account_charges_enabled;
+ var _allDone = _profileComplete && _availabilitySet && _proActive && _payoutsActive;
+ %>
+
+ <% if (typeof connectFlash !== 'undefined' && connectFlash === 'ok') { %>
+ <div class="callout callout-ok"><strong>Payouts ready.</strong> Stripe Connect onboarding complete — booking deposits will route to your bank account, net of NPH's <%= ((installer.tier === 'enterprise') ? '7' : '10') %>% platform fee.</div>
+ <% } else if (typeof connectFlash !== 'undefined' && connectFlash === 'mock') { %>
+ <div class="callout">Stripe is in test mode — Connect onboarding ran in mock. Save real <code>STRIPE_SECRET_KEY</code> via the secrets manager to enable live payouts.</div>
+ <% } %>
+
+ <% if (flash && flash.connect_prompt && !installer.stripe_account_id) { %>
+ <aside class="callout callout-prompt" role="region" aria-label="Set up payouts">
+ <p style="margin:0 0 6px"><strong>Welcome — your studio is claimed. One last setup step.</strong></p>
+ <p style="margin:0 0 12px;font-size:0.92rem">Connect your bank account so booking deposits flow to you automatically. Takes 3 minutes — Stripe-hosted, no NPH access to your bank info.</p>
+ <form method="post" action="/admin/connect/onboard" class="inline-form" style="margin:0">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-primary">Set up payouts now →</button>
+ </form>
+ <p style="margin:8px 0 0;font-size:0.82rem;color:var(--fg-muted)">You can skip and do this later from /admin/billing.</p>
+ </aside>
+ <% } %>
+
+ <% if (welcome || !_allDone) { %>
+ <div class="onboarding-checklist" aria-label="Account setup checklist">
+ <h2 class="onboarding-title">Get your studio live</h2>
+ <ol class="onboarding-steps" role="list">
+ <li class="onboarding-step <%= _profileComplete ? 'step-done' : 'step-todo' %>">
+ <span class="step-icon" aria-hidden="true"><%= _profileComplete ? '✓' : '○' %></span>
+ <span class="step-label">Profile complete</span>
+ <% if (!_profileComplete) { %>
+ <a href="/admin/profile" class="step-action">Complete profile →</a>
+ <% } else { %>
+ <span class="step-done-label">Done</span>
+ <% } %>
+ </li>
+ <li class="onboarding-step <%= _availabilitySet ? 'step-done' : 'step-todo' %>">
+ <span class="step-icon" aria-hidden="true"><%= _availabilitySet ? '✓' : '○' %></span>
+ <span class="step-label">Availability set</span>
+ <% if (!_availabilitySet) { %>
+ <a href="/admin/calendar" class="step-action">Set availability →</a>
+ <% } else { %>
+ <span class="step-done-label">Done</span>
+ <% } %>
+ </li>
+ <li class="onboarding-step <%= _proActive ? 'step-done' : 'step-todo' %>">
+ <span class="step-icon" aria-hidden="true"><%= _proActive ? '✓' : '○' %></span>
+ <span class="step-label">Pro tier active</span>
+ <% if (!_proActive) { %>
+ <a href="/admin/billing" class="step-action btn btn-primary btn-sm">Upgrade to Pro →</a>
+ <% } else { %>
+ <span class="step-done-label">Active</span>
+ <% } %>
+ </li>
+ <li class="onboarding-step <%= _payoutsActive ? 'step-done' : 'step-todo' %>">
+ <span class="step-icon" aria-hidden="true"><%= _payoutsActive ? '✓' : '○' %></span>
+ <span class="step-label">Payouts set up</span>
+ <% if (!_payoutsActive) { %>
+ <form method="post" action="/admin/connect/onboard" class="inline-form" style="margin:0">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="step-action btn btn-primary btn-sm">Set up payouts →</button>
+ </form>
+ <% } else { %>
+ <span class="step-done-label">Active</span>
+ <% } %>
+ </li>
+ </ol>
+ <% if (!_payoutsActive) { %>
+ <p class="muted" style="margin: 12px 0 0; font-size: 0.85rem;">
+ Customers pay their booking deposit through NPH. Once your Stripe payouts are set up, your share lands in your bank in 1–2 business days, net of NPH's <%= Math.round((installer.tier === 'enterprise' ? 700 : 1000) / 100) %>% marketplace fee.
+ </p>
+ <% } %>
+ </div>
+ <% } %>
+
+ <header class="admin-page-head">
+ <h1>Dashboard</h1>
+ <p class="muted"><%= installer.business_name %> · <%= installer.city %>, <%= installer.state %> · <%= installer.tier %> tier</p>
+ </header>
+
+ <div class="stat-grid">
+ <div class="stat">
+ <span class="stat-label">Pending</span>
+ <span class="stat-value"><%= stats.pending_count %></span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Upcoming</span>
+ <span class="stat-value"><%= stats.upcoming_count %></span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Completed</span>
+ <span class="stat-value"><%= stats.completed_count %></span>
+ </div>
+ <div class="stat">
+ <span class="stat-label">Portfolio items</span>
+ <span class="stat-value"><%= portfolioCount %></span>
+ </div>
+ <% if (typeof market !== 'undefined' && market) { %>
+ <div class="stat">
+ <span class="stat-label">Deposits paid</span>
+ <span class="stat-value">$<%= (market.deposits_paid_cents/100).toFixed(0) %></span>
+ <span class="stat-sub muted"><%= market.count_paid %> bookings</span>
+ </div>
+ <% if (!market.on_connect && market.deposits_paid_cents > 0) { %>
+ <div class="stat stat-warn">
+ <span class="stat-label">Pending payout</span>
+ <span class="stat-value">$<%= (market.payouts_pending_cents/100).toFixed(0) %></span>
+ <span class="stat-sub muted">held — set up payouts</span>
+ </div>
+ <% } %>
+ <% } %>
+ </div>
+
+ <% if (installer.subscription_status !== 'active') { %>
+ <div class="callout callout-warn">
+ <strong>Calendar bookings are off.</strong>
+ Upgrade to Pro to let customers self-book directly into your calendar.
+ <a href="/admin/billing" class="btn btn-primary btn-sm">Upgrade</a>
+ </div>
+ <% } %>
+
+ <section class="admin-section">
+ <div class="section-head">
+ <h2>Upcoming bookings</h2>
+ <a href="/admin/bookings" class="section-link">All bookings →</a>
+ </div>
+ <% if (upcoming.length === 0) { %>
+ <div class="empty-state">No upcoming bookings yet.</div>
+ <% } else { %>
+ <table class="data-table">
+ <thead><tr><th>When</th><th>Customer</th><th>Type</th><th>Status</th></tr></thead>
+ <tbody>
+ <% upcoming.forEach(function(b){ %>
+ <tr>
+ <td><%= new Date(b.scheduled_start).toLocaleString() %></td>
+ <td><%= b.customer_name %><br><span class="muted"><%= b.customer_email %></span></td>
+ <td><%= (b.project_type || 'consultation').replace(/_/g,' ') %></td>
+ <td><span class="status-badge status-<%= b.status %>"><%= b.status %></span></td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <% } %>
+ </section>
+
+ <section class="admin-section">
+ <div class="section-head"><h2>Quick actions</h2></div>
+ <div class="action-grid">
+ <a class="action-card" href="/admin/calendar">
+ <h3>Calendar</h3>
+ <p>Set weekly availability and block time off.</p>
+ </a>
+ <a class="action-card" href="/admin/profile">
+ <h3>Profile</h3>
+ <p>Edit your studio bio, materials, brands, and accreditations.</p>
+ </a>
+ <a class="action-card" href="/admin/bookings">
+ <h3>Bookings</h3>
+ <p>Review, confirm, decline, or complete jobs.</p>
+ </a>
+ <a class="action-card" href="/admin/billing">
+ <h3>Billing</h3>
+ <p>Manage your subscription and invoices.</p>
+ </a>
+ </div>
+ </section>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/ops-credentials.ejs b/views/admin/ops-credentials.ejs
new file mode 100644
index 0000000..f0f5bfa
--- /dev/null
+++ b/views/admin/ops-credentials.ejs
@@ -0,0 +1,82 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+
+<%
+ var typeLabel = {
+ brand_trained: 'Brand-trained',
+ brand_certified: 'Brand-certified',
+ brand_approved: 'Brand-approved',
+ manufacturer_partner: 'Manufacturer partner',
+ trade_member: 'Trade member'
+ };
+%>
+
+<section class="admin-main">
+ <header class="admin-page-head">
+ <p class="kicker">Ops</p>
+ <h1>Credential review queue</h1>
+ <p class="muted"><%= pending.length %> pending · <%= verifiedCount %> verified to date</p>
+ </header>
+
+ <% if (flash && flash.ok) { %><div class="callout callout-ok"><%= flash.ok %></div><% } %>
+ <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+
+ <% if (pending.length === 0) { %>
+ <div class="empty-state">
+ <h3>Nothing to review.</h3>
+ <p>All submitted Brand-Trained credentials have been processed. Studios can submit new ones from <code>/admin/profile</code>.</p>
+ </div>
+ <% } else { %>
+ <table class="data-table">
+ <thead>
+ <tr>
+ <th>Studio</th>
+ <th>Brand</th>
+ <th>Type</th>
+ <th>Year</th>
+ <th>Cert</th>
+ <th>Notes</th>
+ <th>Submitted</th>
+ <th>Action</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% pending.forEach(function(c){ %>
+ <tr>
+ <td>
+ <a href="/installer/<%= c.slug %>" target="_blank" rel="noopener" style="color:inherit"><strong><%= c.business_name %></strong></a><br>
+ <span class="muted" style="font-size:12px"><%= [c.city, c.state].filter(Boolean).join(', ') %><% if (c.website) { %> · <a href="<%= c.website %>" target="_blank" rel="noopener" style="color:inherit">site ↗</a><% } %></span>
+ </td>
+ <td><strong><%= c.brand %></strong></td>
+ <td><%= typeLabel[c.credential_type] || c.credential_type %></td>
+ <td><%= c.year_issued || '—' %><% if (c.year_expires) { %> – <%= c.year_expires %><% } %></td>
+ <td>
+ <% if (c.certificate_url) { %>
+ <a href="<%= c.certificate_url %>" target="_blank" rel="noopener">View ↗</a>
+ <% } else { %>—<% } %>
+ </td>
+ <td><span class="muted" style="font-size:12px"><%= c.notes || '—' %></span></td>
+ <td><span class="muted" style="font-size:12px"><%= new Date(c.created_at).toLocaleDateString() %></span></td>
+ <td class="actions" style="white-space:nowrap">
+ <form method="post" action="/admin/ops/credentials/<%= c.id %>/verify" class="inline-form" style="display:inline">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button class="btn btn-primary btn-sm">Verify</button>
+ </form>
+ <form method="post" action="/admin/ops/credentials/<%= c.id %>/reject" class="inline-form" style="display:inline" onsubmit="return confirm('Reject this credential? It will be removed.')">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button class="btn btn-ghost btn-sm">Reject</button>
+ </form>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+
+ <p class="muted" style="margin-top:24px;font-size:13px">
+ Verifying flips <code>ops_verified=true</code> on the row → the badge appears immediately on the studio's public profile.
+ Rejecting deletes the row — the studio can resubmit with corrected info.
+ </p>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/partials/admin-header.ejs b/views/admin/partials/admin-header.ejs
new file mode 100644
index 0000000..84875de
--- /dev/null
+++ b/views/admin/partials/admin-header.ejs
@@ -0,0 +1,25 @@
+<header class="site-header admin-header">
+ <div class="header-inner">
+ <a href="/admin" class="brand">
+ <span class="brand-mark">N · P · H</span>
+ <span class="brand-name">National Paper Hangers</span>
+ <span class="admin-tag">admin</span>
+ </a>
+ <nav class="primary-nav" aria-label="Admin">
+ <a href="/admin" class="<%= path === '/admin' ? 'is-current' : '' %>">Dashboard</a>
+ <a href="/admin/calendar" class="<%= path.startsWith('/admin/calendar') ? 'is-current' : '' %>">Calendar</a>
+ <a href="/admin/bookings" class="<%= path.startsWith('/admin/bookings') ? 'is-current' : '' %>">Bookings</a>
+ <a href="/admin/profile" class="<%= path.startsWith('/admin/profile') ? 'is-current' : '' %>">Profile</a>
+ <a href="/admin/template" class="<%= path.startsWith('/admin/template') ? 'is-current' : '' %>">Page design</a>
+ <a href="/admin/billing" class="<%= path.startsWith('/admin/billing') ? 'is-current' : '' %>">Billing</a>
+ </nav>
+ <div class="header-actions">
+ <button type="button" class="theme-toggle" aria-label="Toggle theme" data-theme-toggle>
+ <span class="t-light" aria-hidden="true">☼</span>
+ <span class="t-dark" aria-hidden="true">☾</span>
+ </button>
+ <a href="/installer/<%= installer.slug %>" class="btn btn-ghost btn-sm" target="_blank">View public profile ↗</a>
+ <form method="post" action="/logout" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-ghost btn-sm" type="submit">Log out</button></form>
+ </div>
+ </div>
+</header>
diff --git a/views/admin/profile.ejs b/views/admin/profile.ejs
new file mode 100644
index 0000000..4ae68f1
--- /dev/null
+++ b/views/admin/profile.ejs
@@ -0,0 +1,181 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+ <header class="admin-page-head"><h1>Profile</h1><p class="muted">Customers see this information on your public profile.</p></header>
+ <% if (flash && flash.ok) { %><div class="callout callout-success"><%= flash.ok %></div><% } %>
+ <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+ <form method="post" action="/admin/profile" class="profile-form">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <fieldset>
+ <legend>Studio</legend>
+ <label>Business name <input type="text" name="business_name" value="<%= installer.business_name %>" required></label>
+ <label>Contact name <input type="text" name="contact_name" value="<%= installer.contact_name || '' %>"></label>
+ <label>Phone <input type="tel" name="phone" value="<%= installer.phone || '' %>"></label>
+ <label>Headline (one line shown under your name)
+ <input type="text" name="headline" value="<%= installer.headline || '' %>" maxlength="120">
+ </label>
+ <label>Bio (3–4 sentences)
+ <textarea name="bio" rows="4"><%= installer.bio || '' %></textarea>
+ </label>
+ <label>Website <input type="url" name="website" value="<%= installer.website || '' %>"></label>
+ </fieldset>
+ <fieldset>
+ <legend>Location & travel</legend>
+ <div class="row3">
+ <label>City <input type="text" name="city" value="<%= installer.city || '' %>"></label>
+ <label>State <input type="text" name="state" maxlength="2" value="<%= installer.state || '' %>"></label>
+ <label>ZIP <input type="text" name="zip" maxlength="10" value="<%= installer.zip || '' %>"></label>
+ </div>
+ <label>Service radius (miles) <input type="number" name="service_radius_miles" value="<%= installer.service_radius_miles %>"></label>
+ <label class="check"><input type="checkbox" name="travel_available" <%= installer.travel_available ? 'checked' : '' %>> Available to travel for projects</label>
+ </fieldset>
+ <fieldset>
+ <legend>Specialty</legend>
+
+ <label class="tag-input-label">Market segments
+ <div class="tag-input-wrapper"
+ data-tag-input="market_segments"
+ data-allowed='["luxury_residential","hospitality","retail","museum","yacht"]'>
+ <input type="hidden" name="market_segments" value="<%= (installer.market_segments || []).join(', ') %>">
+ </div>
+ </label>
+
+ <label class="tag-input-label">Materials
+ <div class="tag-input-wrapper"
+ data-tag-input="materials"
+ data-allowed='["hand_painted","silk","grasscloth","vinyl","mural","metallic_leaf","hand_screened","digital_print"]'>
+ <input type="hidden" name="materials" value="<%= (installer.materials || []).join(', ') %>">
+ </div>
+ </label>
+
+ <label class="tag-input-label">Brands handled
+ <span class="tag-input-hint">Type a brand name and press Enter</span>
+ <div class="tag-input-wrapper" data-tag-input="brands_handled">
+ <input type="hidden" name="brands_handled" value="<%= (installer.brands_handled || []).join(', ') %>">
+ </div>
+ </label>
+
+ <label class="tag-input-label">Accreditations
+ <span class="tag-input-hint">Type a credential and press Enter</span>
+ <div class="tag-input-wrapper" data-tag-input="accreditations">
+ <input type="hidden" name="accreditations" value="<%= (installer.accreditations || []).join(', ') %>">
+ </div>
+ </label>
+
+ </fieldset>
+ <fieldset>
+ <legend>Operations</legend>
+ <label>Team size <input type="number" name="team_size" value="<%= installer.team_size || '' %>"></label>
+ <label>Founded year <input type="number" name="founded_year" value="<%= installer.founded_year || '' %>"></label>
+ <label>Response SLA (hours) <input type="number" name="response_time_hours" value="<%= installer.response_time_hours %>"></label>
+ </fieldset>
+ <fieldset>
+ <legend>Studio capacity</legend>
+ <p class="muted" style="font-size:13px;margin:0 0 12px">Trade buyers specifying $4K/roll papers want to know you can reach the entryway and have the right tools. Surfacing this distinguishes you from contractors.</p>
+ <% var eq = installer.equipment || {}; %>
+ <label>Maximum reach (feet)
+ <input type="number" name="eq_max_reach_ft" min="0" max="200" value="<%= eq.max_reach_ft || '' %>" placeholder="e.g. 22">
+ </label>
+ <label>Lift / ladder
+ <select name="eq_lift_type">
+ <option value="">— Select —</option>
+ <% [['extension_ladder','Extension ladder'],['scaffold','Scaffold'],['scissor_lift','Scissor lift'],['boom_lift','Boom lift']].forEach(function(o){ %>
+ <option value="<%= o[0] %>" <%= eq.lift_type === o[0] ? 'selected' : '' %>><%= o[1] %></option>
+ <% }); %>
+ </select>
+ </label>
+ <label>Pasting table
+ <select name="eq_paper_table">
+ <option value="">— Select —</option>
+ <% [['none','None / paste-the-wall only'],['folding','Folding table'],['dedicated_60','Dedicated 60" table'],['dedicated_72_plus','Dedicated 72"+ table']].forEach(function(o){ %>
+ <option value="<%= o[0] %>" <%= eq.paper_table === o[0] ? 'selected' : '' %>><%= o[1] %></option>
+ <% }); %>
+ </select>
+ </label>
+ <label>Vehicle
+ <select name="eq_vehicle">
+ <option value="">— Select —</option>
+ <% [['van','Van'],['box_truck','Box truck'],['trailer','Trailer-equipped']].forEach(function(o){ %>
+ <option value="<%= o[0] %>" <%= eq.vehicle === o[0] ? 'selected' : '' %>><%= o[1] %></option>
+ <% }); %>
+ </select>
+ </label>
+ <label class="check"><input type="checkbox" name="eq_dust_extraction" <%= eq.dust_extraction ? 'checked' : '' %>> HEPA dust extraction on-site</label>
+ <label>Notes <input type="text" name="eq_notes" maxlength="400" value="<%= eq.notes || '' %>" placeholder="e.g. Genie GS-1932 lift available on request"></label>
+ </fieldset>
+ <button type="submit" class="btn btn-primary btn-lg">Save profile</button>
+ </form>
+
+ <% if (typeof credentials !== 'undefined') {
+ var typeLabel = {
+ brand_trained: 'Brand-trained',
+ brand_certified: 'Brand-certified',
+ brand_approved: 'Brand-approved',
+ manufacturer_partner: 'Manufacturer partner',
+ trade_member: 'Trade member'
+ };
+ %>
+ <section class="admin-section" style="margin-top:48px">
+ <h2>Brand-trained credentials</h2>
+ <p class="muted" style="font-size:13px">Trade buyers specifying $4K/roll papers care that you've been trained by the brand. Add each credential separately — NPH ops reviews each entry before it appears on your public profile.</p>
+
+ <% if (credentials.length) { %>
+ <table class="data-table" style="margin-bottom:24px">
+ <thead><tr><th>Brand</th><th>Type</th><th>Year</th><th>Expires</th><th>Cert</th><th>Status</th><th></th></tr></thead>
+ <tbody>
+ <% credentials.forEach(function(c){ %>
+ <tr>
+ <td><strong><%= c.brand %></strong><% if (c.notes) { %><br><span class="muted" style="font-size:12px"><%= c.notes %></span><% } %></td>
+ <td><%= typeLabel[c.credential_type] || c.credential_type %></td>
+ <td><%= c.year_issued || '—' %></td>
+ <td><%= c.year_expires || '—' %></td>
+ <td><% if (c.certificate_url) { %><a href="<%= c.certificate_url %>" target="_blank" rel="noopener">View ↗</a><% } else { %>—<% } %></td>
+ <td>
+ <% if (c.ops_verified) { %>
+ <span class="status-badge status-confirmed">Verified</span>
+ <% } else { %>
+ <span class="status-badge status-pending">Pending review</span>
+ <% } %>
+ </td>
+ <td>
+ <form method="post" action="/admin/credentials/<%= c.id %>/delete" class="inline-form" onsubmit="return confirm('Remove this credential?')">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button class="btn btn-ghost btn-sm">Remove</button>
+ </form>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <% } else { %>
+ <p class="muted" style="margin-bottom:24px">No credentials added yet.</p>
+ <% } %>
+
+ <h3 style="margin-top:24px">Add a credential</h3>
+ <form method="post" action="/admin/credentials" class="profile-form">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <div class="row3">
+ <label>Brand <input type="text" name="brand" required maxlength="100" placeholder="e.g. de Gournay"></label>
+ <label>Type
+ <select name="credential_type">
+ <option value="brand_trained">Brand-trained</option>
+ <option value="brand_certified">Brand-certified</option>
+ <option value="brand_approved">Brand-approved</option>
+ <option value="manufacturer_partner">Manufacturer partner</option>
+ <option value="trade_member">Trade member</option>
+ </select>
+ </label>
+ <label>Year issued <input type="number" name="year_issued" min="1900" max="2100" placeholder="e.g. 2019"></label>
+ </div>
+ <div class="row3">
+ <label>Year expires <input type="number" name="year_expires" min="1900" max="2200" placeholder="optional"></label>
+ <label>Certificate URL <input type="url" name="certificate_url" placeholder="https://… (PDF or page)"></label>
+ <label>Notes <input type="text" name="notes" maxlength="400" placeholder="optional"></label>
+ </div>
+ <button type="submit" class="btn btn-primary">Add credential</button>
+ </form>
+ </section>
+ <% } %>
+</section>
+<%- include('../partials/footer') %>
+<script src="/js/tag-input.js"></script>
diff --git a/views/admin/template.ejs b/views/admin/template.ejs
new file mode 100644
index 0000000..ec6220e
--- /dev/null
+++ b/views/admin/template.ejs
@@ -0,0 +1,220 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<style>
+ .tpl-chooser{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:18px;margin:24px 0 40px}
+ .tpl-card{border:2px solid var(--border,#d4d2c8);border-radius:14px;overflow:hidden;cursor:pointer;background:var(--card-bg,#fff);transition:all .2s;display:flex;flex-direction:column}
+ .tpl-card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.08)}
+ .tpl-card.is-selected{border-color:#0a0a0a;box-shadow:0 0 0 3px #0a0a0a inset}
+ .tpl-card .preview{aspect-ratio:5/4;display:block;background:#f3f1ea;border-bottom:1px solid var(--border,#d4d2c8)}
+ .tpl-card iframe{width:100%;height:100%;border:0;background:#fff;pointer-events:none}
+ .tpl-card .meta{padding:14px 18px}
+ .tpl-card h3{margin:0 0 4px;font-size:16px}
+ .tpl-card p{margin:0;font-size:13px;color:var(--muted,#666);line-height:1.45}
+ .tpl-card .stamp{display:inline-block;font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#16a34a;margin-top:8px;font-weight:700}
+ .tpl-card .stamp.off{color:var(--muted,#888)}
+ .dropzone{border:2px dashed #b8b3a3;border-radius:14px;padding:28px;text-align:center;background:#fafaf6;color:#444;font-size:14px;transition:all .2s;cursor:pointer}
+ .dropzone.dragover{border-color:#0a0a0a;background:#fff8e6}
+ .dropzone strong{display:block;font-size:16px;margin-bottom:6px;color:#0a0a0a}
+ .dropzone .hint{font-size:12px;color:#777;margin-top:8px}
+ .upload-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin:12px 0}
+ .upload-item{position:relative;aspect-ratio:1;background:#f0eee5;border-radius:8px;overflow:hidden;border:1px solid #d4d2c8}
+ .upload-item img{width:100%;height:100%;object-fit:cover}
+ .upload-item .rm{position:absolute;top:6px;right:6px;background:rgba(10,10,10,.85);color:#fff;border:0;border-radius:50%;width:24px;height:24px;cursor:pointer;font-size:14px;line-height:1}
+ .upload-item .role{position:absolute;bottom:6px;left:6px;background:rgba(255,255,255,.95);color:#0a0a0a;font-size:11px;padding:2px 8px;border-radius:4px;font-weight:600}
+ .upload-progress{height:4px;background:#e5e3da;border-radius:2px;overflow:hidden;margin-top:8px;display:none}
+ .upload-progress.is-active{display:block}
+ .upload-progress div{height:100%;background:#16a34a;transition:width .25s;width:0}
+</style>
+
+<section class="admin-main">
+ <header class="admin-page-head">
+ <h1>Choose your page design</h1>
+ <p class="muted">Six layouts. Same content — your address, specialty, brands, and projects pulled in automatically. Pick the one that fits how you work.</p>
+ </header>
+
+ <% if (flash && flash.ok) { %><div class="callout callout-success"><%= flash.ok %></div><% } %>
+ <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+
+ <form method="post" action="/admin/template" id="tplForm">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="hidden" name="template_slug" id="tpl_slug" value="<%= installer.template_slug || 'editorial' %>">
+ <input type="hidden" name="hero_url" id="hero_url" value="<%= (installer.template_settings && installer.template_settings.hero_url) || '' %>">
+ <input type="hidden" name="accent_color" id="accent_color" value="<%= (installer.template_settings && installer.template_settings.accent_color) || '' %>">
+
+ <div class="tpl-chooser" id="tplChooser">
+ <%
+ var TEMPLATES = [
+ { slug:'editorial', title:'Editorial', desc:'Magazine-style profile. Big editorial photo, drop-cap intro, story-led copy. Best for studios with one knockout project.' },
+ { slug:'trade-pro', title:'Trade Pro', desc:'Spec-sheet aesthetic. Crew, reach, lift, dust extraction up front. Built for designers and architects who specify by capability.' },
+ { slug:'concierge', title:'Concierge', desc:'Black, ivory, brushed gold. Centered, slim, by-appointment feel. For studios that win on quiet quality.' },
+ { slug:'studio', title:'Modern Studio', desc:'Vibrant grid layout, big project tiles, fast and approachable. Best for active studios with lots of recent work.' },
+ { slug:'heritage', title:'Heritage', desc:'Sepia, classical serif, "Established 19XX" emblem. Best for multi-generational and historic studios.' },
+ { slug:'bilingue', title:'Bilingüe', desc:'EN/ES toggle, warm ochre + terracotta palette. Bilingual studios serving Spanish-speaking clients.' }
+ ];
+ var current = installer.template_slug || 'editorial';
+ %>
+ <% TEMPLATES.forEach(function(t){ %>
+ <div class="tpl-card <%= current === t.slug ? 'is-selected' : '' %>" data-tpl="<%= t.slug %>" tabindex="0" role="button" aria-pressed="<%= current === t.slug %>" aria-label="Use <%= t.title %> template">
+ <div class="preview"><iframe loading="lazy" src="/installer/<%= installer.slug %>?_preview=<%= t.slug %>" title="<%= t.title %> preview"></iframe></div>
+ <div class="meta">
+ <h3><%= t.title %></h3>
+ <p><%= t.desc %></p>
+ <span class="stamp <%= current === t.slug ? '' : 'off' %>"><%= current === t.slug ? '✓ Selected' : 'Click to preview' %></span>
+ </div>
+ </div>
+ <% }); %>
+ </div>
+
+ <fieldset style="margin:0 0 32px">
+ <legend>Hero photo</legend>
+ <p class="muted" style="font-size:13px;margin:0 0 12px">Drop one image to be the big hero on your page. We'll fall back to your first portfolio project if you don't pick one.</p>
+ <div class="dropzone" id="heroDrop" data-target="hero">
+ <strong>Drop hero image here</strong>
+ <span>or click to choose a file (JPG/PNG/WebP, up to 8 MB)</span>
+ <div class="hint">Tip: a high-resolution detail shot of a finished installation works best — seam, corner, or full wall.</div>
+ </div>
+ <div class="upload-grid" id="heroPreview"></div>
+ <div class="upload-progress" id="heroProgress"><div></div></div>
+ </fieldset>
+
+ <fieldset style="margin:0 0 32px">
+ <legend>Portfolio images</legend>
+ <p class="muted" style="font-size:13px;margin:0 0 12px">Drag up to 8 project images. We'll pull the city/state/year from your address and the project record. You can edit titles after.</p>
+ <div class="dropzone" id="pfDrop" data-target="portfolio">
+ <strong>Drop portfolio images here</strong>
+ <span>or click to choose files</span>
+ <div class="hint">First-drop image becomes the cover. Drag tiles to reorder after they upload.</div>
+ </div>
+ <div class="upload-grid" id="pfPreview"></div>
+ <div class="upload-progress" id="pfProgress"><div></div></div>
+ </fieldset>
+
+ <fieldset style="margin:0 0 32px">
+ <legend>Accent color (optional)</legend>
+ <p class="muted" style="font-size:13px;margin:0 0 12px">Some templates honor an accent color for buttons and highlights. Leave blank to use the template default.</p>
+ <input type="color" id="accent_color_picker" value="<%= (installer.template_settings && installer.template_settings.accent_color) || '#0a0a0a' %>" style="width:80px;height:40px;border:1px solid #d4d2c8;border-radius:8px;cursor:pointer">
+ </fieldset>
+
+ <div style="display:flex;gap:12px;align-items:center">
+ <button type="submit" class="btn btn-primary btn-lg">Save page design</button>
+ <a href="/installer/<%= installer.slug %>" target="_blank" class="btn btn-ghost">Preview live page ↗</a>
+ </div>
+ </form>
+
+ <section style="margin-top:64px;padding:24px 28px;background:#fafaf6;border-radius:14px">
+ <h2 style="margin:0 0 8px;font-size:18px">Already on file</h2>
+ <p class="muted" style="margin:0 0 14px;font-size:13px">Pulled from your studio profile. Edit on <a href="/admin/profile">/admin/profile</a> — every template uses the same data.</p>
+ <dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 16px;margin:0;font-size:14px">
+ <dt class="muted">Studio</dt><dd><%= installer.business_name %></dd>
+ <dt class="muted">Where</dt><dd><%= [installer.city, installer.state, installer.zip].filter(Boolean).join(' · ') %></dd>
+ <dt class="muted">Founded</dt><dd><%= installer.founded_year || 'Not set' %></dd>
+ <dt class="muted">Specialty</dt><dd><%= ((installer.market_segments || []).concat(installer.materials || [])).map(function(s){return s.replace(/_/g,' ');}).join(', ') || 'Not set' %></dd>
+ <dt class="muted">Brands</dt><dd><%= (installer.brands_handled || []).join(', ') || 'Not set' %></dd>
+ <% if (installer.instagram_handle) { %><dt class="muted">Instagram</dt><dd>@<%= installer.instagram_handle %></dd><% } %>
+ <% if (installer.website) { %><dt class="muted">Website</dt><dd><%= installer.website %></dd><% } %>
+ </dl>
+ </section>
+</section>
+
+<script>
+(function(){
+ var csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
+
+ // Template card selection
+ document.querySelectorAll('.tpl-card').forEach(function(card){
+ function pick(){
+ document.querySelectorAll('.tpl-card').forEach(function(c){ c.classList.remove('is-selected'); c.querySelector('.stamp').classList.add('off'); c.querySelector('.stamp').textContent = 'Click to preview'; });
+ card.classList.add('is-selected');
+ var stamp = card.querySelector('.stamp');
+ stamp.classList.remove('off');
+ stamp.textContent = '✓ Selected';
+ document.getElementById('tpl_slug').value = card.getAttribute('data-tpl');
+ }
+ card.addEventListener('click', pick);
+ card.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
+ });
+
+ // Accent color
+ var picker = document.getElementById('accent_color_picker');
+ picker.addEventListener('input', function(){ document.getElementById('accent_color').value = picker.value; });
+
+ // ----- Drag-drop uploaders -----
+ function wireDropzone(zoneId, previewId, progressId, role) {
+ var zone = document.getElementById(zoneId);
+ var preview = document.getElementById(previewId);
+ var progress = document.getElementById(progressId);
+ var bar = progress.querySelector('div');
+ var input = document.createElement('input');
+ input.type = 'file'; input.multiple = (role === 'portfolio'); input.accept = 'image/*'; input.style.display = 'none';
+ zone.parentNode.insertBefore(input, zone.nextSibling);
+ zone.addEventListener('click', function(){ input.click(); });
+ input.addEventListener('change', function(){ handleFiles(input.files); });
+ ['dragenter','dragover'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.classList.add('dragover'); }); });
+ ['dragleave','drop'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.classList.remove('dragover'); }); });
+ zone.addEventListener('drop', function(e){ e.preventDefault(); handleFiles(e.dataTransfer.files); });
+
+ function handleFiles(files){
+ var arr = Array.from(files).filter(function(f){ return /^image\//.test(f.type); });
+ if (!arr.length) return;
+ var done = 0;
+ progress.classList.add('is-active'); bar.style.width = '0%';
+ arr.forEach(function(f, idx){
+ var fd = new FormData();
+ fd.append('file', f);
+ fd.append('role', role);
+ fd.append('_csrf', csrf);
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/admin/uploads', true);
+ xhr.upload.onprogress = function(e){
+ if (e.lengthComputable) {
+ var pct = Math.round(((done + e.loaded / e.total) / arr.length) * 100);
+ bar.style.width = pct + '%';
+ }
+ };
+ xhr.onload = function(){
+ if (xhr.status >= 200 && xhr.status < 300) {
+ try {
+ var j = JSON.parse(xhr.responseText);
+ addPreview(j.url, j.id, role);
+ if (role === 'hero') { document.getElementById('hero_url').value = j.url; }
+ } catch (e) {}
+ }
+ done++;
+ if (done === arr.length) {
+ bar.style.width = '100%';
+ setTimeout(function(){ progress.classList.remove('is-active'); bar.style.width = '0%'; }, 600);
+ }
+ };
+ xhr.onerror = function(){ done++; };
+ xhr.send(fd);
+ });
+ }
+
+ function addPreview(url, id, role){
+ // Hero replaces; portfolio appends
+ if (role === 'hero') preview.innerHTML = '';
+ var item = document.createElement('div');
+ item.className = 'upload-item';
+ item.dataset.id = id || '';
+ item.innerHTML = '<img src="'+url+'" alt=""><span class="role">'+role+'</span><button type="button" class="rm" aria-label="Remove">×</button>';
+ item.querySelector('.rm').addEventListener('click', function(){ item.remove(); if (role === 'hero') document.getElementById('hero_url').value = ''; });
+ preview.appendChild(item);
+ }
+ }
+
+ wireDropzone('heroDrop', 'heroPreview', 'heroProgress', 'hero');
+ wireDropzone('pfDrop', 'pfPreview', 'pfProgress', 'portfolio');
+
+ // Pre-populate hero preview if one's saved
+ var savedHero = document.getElementById('hero_url').value;
+ if (savedHero) {
+ var hp = document.getElementById('heroPreview');
+ var item = document.createElement('div');
+ item.className = 'upload-item';
+ item.innerHTML = '<img src="'+savedHero+'" alt=""><span class="role">hero</span><button type="button" class="rm" aria-label="Remove">×</button>';
+ item.querySelector('.rm').addEventListener('click', function(){ item.remove(); document.getElementById('hero_url').value = ''; });
+ hp.appendChild(item);
+ }
+})();
+</script>
+<%- include('../partials/footer') %>
diff --git a/views/auth/login.ejs b/views/auth/login.ejs
new file mode 100644
index 0000000..7cc3efd
--- /dev/null
+++ b/views/auth/login.ejs
@@ -0,0 +1,17 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+<section class="auth-page">
+ <div class="auth-card">
+ <h1 class="display-sm">Installer login</h1>
+ <% if (error) { %><p class="form-error"><%= error %></p><% } %>
+ <form method="post" action="/login">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="hidden" name="next" value="<%= next %>">
+ <label>Email <input type="email" name="email" required autofocus></label>
+ <label>Password <input type="password" name="password" required minlength="8"></label>
+ <button type="submit" class="btn btn-primary btn-lg">Log in</button>
+ </form>
+ <p class="auth-alt">No account? <a href="/signup">Apply to list →</a></p>
+ </div>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/auth/signup.ejs b/views/auth/signup.ejs
new file mode 100644
index 0000000..461ea7b
--- /dev/null
+++ b/views/auth/signup.ejs
@@ -0,0 +1,30 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+<section class="auth-page">
+ <div class="auth-card auth-card-wide">
+ <h1 class="display-sm">Apply to list</h1>
+ <p class="lede">Free to apply. We review each applicant before activating the listing. After approval, you can upgrade to Pro to enable calendar bookings.</p>
+ <% if (error) { %><p class="form-error"><%= error %></p><% } %>
+ <form method="post" action="/signup">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <fieldset>
+ <legend>Account</legend>
+ <label>Email <input type="email" name="email" required value="<%= form.email || '' %>"></label>
+ <label>Password (8+ chars) <input type="password" name="password" required minlength="8"></label>
+ </fieldset>
+ <fieldset>
+ <legend>Studio</legend>
+ <label>Business name <input type="text" name="business_name" required value="<%= form.business_name || '' %>"></label>
+ <label>Contact name <input type="text" name="contact_name" value="<%= form.contact_name || '' %>"></label>
+ <div class="row3">
+ <label>City <input type="text" name="city" required value="<%= form.city || '' %>"></label>
+ <label>State <input type="text" name="state" required maxlength="2" value="<%= form.state || '' %>"></label>
+ <label>ZIP <input type="text" name="zip" maxlength="10" value="<%= form.zip || '' %>"></label>
+ </div>
+ </fieldset>
+ <button type="submit" class="btn btn-primary btn-lg">Create account</button>
+ </form>
+ <p class="auth-alt">Already have an account? <a href="/login">Log in →</a></p>
+ </div>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
new file mode 100644
index 0000000..80204f0
--- /dev/null
+++ b/views/partials/footer.ejs
@@ -0,0 +1,37 @@
+<footer class="site-footer">
+ <div class="footer-inner">
+ <div class="footer-col">
+ <div class="footer-brand">National Paper Hangers</div>
+ <p class="footer-tag">Verified luxury wallcovering installers · scheduling · project oversight</p>
+ <p class="footer-contact">
+ <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a>
+ </p>
+ </div>
+ <div class="footer-col">
+ <h4>Marketplace</h4>
+ <a href="/find">Find an installer</a>
+ <a href="/find?segment=hospitality">Hospitality</a>
+ <a href="/find?segment=luxury_residential">Luxury residential</a>
+ <a href="/find?material=hand_painted">Hand-painted</a>
+ <a href="/find?material=grasscloth">Grasscloth</a>
+ </div>
+ <div class="footer-col">
+ <h4>Installers</h4>
+ <a href="/for-installers">List your business</a>
+ <a href="/login">Installer login</a>
+ <a href="/signup">Apply to list</a>
+ </div>
+ <div class="footer-col">
+ <h4>Company</h4>
+ <a href="/about">About</a>
+ <a href="/privacy">Privacy</a>
+ <a href="/terms">Terms</a>
+ </div>
+ </div>
+ <div class="footer-fineprint">
+ <span>© <%= new Date().getFullYear() %> National Paper Hangers</span>
+ <span>Verified installer credentials are reviewed and re-validated annually. Insurance and licensing self-reported by installers; clients should confirm directly before any contract.</span>
+ </div>
+</footer>
+</body>
+</html>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
new file mode 100644
index 0000000..00c5b38
--- /dev/null
+++ b/views/partials/head.ejs
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><%= typeof title !== 'undefined' ? title : 'National Paper Hangers' %></title>
+ <% /* Admin + auth pages must not be indexed by search engines (private surfaces) */ %>
+ <% if (typeof admin !== 'undefined' && admin) { %><meta name="robots" content="noindex,nofollow"><% } %>
+ <%
+ var _metaDesc = (typeof metaDescription !== 'undefined' && metaDescription)
+ ? metaDescription
+ : 'National Paper Hangers — verified luxury wallcovering installers across the United States. Concierge matching, scheduling, and project oversight for hand-painted, silk, grasscloth, and mural installations.';
+ var _canonicalBase = (typeof publicUrl !== 'undefined' && publicUrl) ? publicUrl.replace(/\/+$/, '') : 'https://www.nationalpaperhangers.com';
+ var _canonicalPath = (typeof canonicalPath !== 'undefined' && canonicalPath) ? canonicalPath : (typeof path !== 'undefined' ? path : '/');
+ var _canonicalUrl = _canonicalBase + _canonicalPath;
+ %>
+ <meta name="description" content="<%= _metaDesc %>">
+ <link rel="canonical" href="<%= _canonicalUrl %>">
+ <meta property="og:type" content="website">
+ <meta property="og:url" content="<%= _canonicalUrl %>">
+ <meta property="og:site_name" content="National Paper Hangers">
+ <meta property="og:title" content="<%= typeof title !== 'undefined' ? title : 'National Paper Hangers' %>">
+ <meta property="og:description" content="<%= _metaDesc %>">
+ <meta name="twitter:card" content="summary_large_image">
+ <meta name="twitter:title" content="<%= typeof title !== 'undefined' ? title : 'National Paper Hangers' %>">
+ <meta name="twitter:description" content="<%= _metaDesc %>">
+ <meta name="theme-color" content="#0e0e0e">
+ <meta name="csrf-token" content="<%= typeof csrfToken !== 'undefined' && csrfToken ? csrfToken : '' %>">
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;500;600&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="/css/theme.css">
+ <link rel="stylesheet" href="/css/<%= typeof admin !== 'undefined' && admin ? 'admin' : 'public' %>.css">
+ <% if (typeof bodyClass !== 'undefined' && bodyClass && /tpl-/.test(bodyClass)) { %><link rel="stylesheet" href="/css/templates.css"><% } %>
+ <!-- Google Analytics 4 · G-1WZ49HYY39 · nationalpaperhangers.com -->
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-1WZ49HYY39"></script>
+ <script>
+ window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', 'G-1WZ49HYY39');
+ </script>
+ <script>
+ (function(){
+ try {
+ var t = localStorage.getItem('nph-theme');
+ if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ document.documentElement.setAttribute('data-theme', t);
+ } catch (e) {}
+ })();
+ </script>
+</head>
+<body class="<%= typeof bodyClass !== 'undefined' ? bodyClass : '' %>">
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
new file mode 100644
index 0000000..dc707a5
--- /dev/null
+++ b/views/partials/header.ejs
@@ -0,0 +1,26 @@
+<header class="site-header">
+ <div class="header-inner">
+ <a href="/" class="brand">
+ <span class="brand-mark">N · P · H</span>
+ <span class="brand-name">National Paper Hangers</span>
+ </a>
+ <% const _navPath = (typeof path === 'string') ? path : ''; %>
+ <nav class="primary-nav" aria-label="Primary">
+ <a href="/find" class="<%= _navPath === '/find' ? 'is-current' : '' %>">Find an installer</a>
+ <a href="/map" class="<%= _navPath === '/map' ? 'is-current' : '' %>">Map</a>
+ <a href="/for-installers" class="<%= _navPath === '/for-installers' ? 'is-current' : '' %>">For installers</a>
+ <a href="/about" class="<%= _navPath === '/about' ? 'is-current' : '' %>">About</a>
+ </nav>
+ <div class="header-actions">
+ <button type="button" class="theme-toggle" aria-label="Toggle theme" data-theme-toggle>
+ <span class="t-light" aria-hidden="true">☼</span>
+ <span class="t-dark" aria-hidden="true">☾</span>
+ </button>
+ <% if (locals.installer) { %>
+ <a href="/admin" class="btn btn-ghost">Dashboard</a>
+ <% } else { %>
+ <a href="/login" class="btn btn-ghost">Installer login</a>
+ <% } %>
+ </div>
+ </div>
+</header>
diff --git a/views/public/404.ejs b/views/public/404.ejs
new file mode 100644
index 0000000..a3c6bd0
--- /dev/null
+++ b/views/public/404.ejs
@@ -0,0 +1,8 @@
+<%- include('../partials/head', { title: 'Not found' }) %>
+<%- include('../partials/header') %>
+<section class="long-form" style="text-align:center">
+ <p class="kicker">404</p>
+ <h1 class="display-sm">Not found</h1>
+ <p>That page isn't here. <a href="/">Back to home</a> or <a href="/find">browse installers</a>.</p>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/public/about.ejs b/views/public/about.ejs
new file mode 100644
index 0000000..8c7ea18
--- /dev/null
+++ b/views/public/about.ejs
@@ -0,0 +1,22 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <p class="kicker">About</p>
+ <h1 class="display-sm">A directory the trade can actually use.</h1>
+ <p class="lede">National Paper Hangers connects luxury wallcovering buyers — homeowners, designers, architects, and hospitality groups — with verified, insured installation specialists.</p>
+
+ <h2>What makes a listing "verified"</h2>
+ <p>Active listings on this site have submitted business identity documents, insurance certificates with expiration tracking, and trade references. We re-validate annually. Insurance and licensing remain self-reported by installers; clients should still verify directly before signing any contract.</p>
+
+ <h2>Who we serve</h2>
+ <p>We focus on premium and contract work where material cost, finish quality, and project documentation matter — luxury residential, hospitality, retail, museum and cultural, yacht, and aviation. The list is intentionally narrow because the installer pool is.</p>
+
+ <h2>How matching works</h2>
+ <p>You can search the public directory directly, or submit a project brief for a curated shortlist when the project is large or specialized. Either way, scheduling happens against the studio's live calendar.</p>
+
+ <h2>Contact</h2>
+ <p>Email <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a>.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/book.ejs b/views/public/book.ejs
new file mode 100644
index 0000000..2dfdb41
--- /dev/null
+++ b/views/public/book.ejs
@@ -0,0 +1,325 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+<style>
+ .intake-steps{counter-reset:step;display:flex;gap:8px;margin:0 0 24px;flex-wrap:wrap}
+ .intake-steps li{counter-increment:step;list-style:none;font-size:12px;letter-spacing:0.04em;text-transform:uppercase;color:var(--muted,#888);padding:8px 14px;border-radius:999px;background:var(--card-bg,#f3f1ea);font-weight:600}
+ .intake-steps li::before{content:counter(step) ". "}
+ .intake-steps li.is-done{color:#16a34a;background:#dcfce7}
+ .intake-steps li.is-active{color:#fff;background:var(--ink,#0a0a0a)}
+ .intake-step{display:none;padding:24px 28px;border-radius:14px;background:var(--card-bg,#fff);border:1px solid var(--border,#d4d2c8);margin:0 0 16px}
+ .intake-step.is-active{display:block}
+ .intake-step h2{font-size:22px;margin:0 0 6px}
+ .intake-step .helper{font-size:13px;color:var(--muted,#666);margin:0 0 18px}
+ .intake-step .nav{display:flex;justify-content:space-between;gap:12px;margin-top:20px}
+ .radio-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px;margin:0 0 12px}
+ .radio-cards label{display:block;border:2px solid var(--border,#d4d2c8);border-radius:10px;padding:14px 16px;cursor:pointer;background:var(--bg,#fff);transition:all .15s}
+ .radio-cards label:hover{border-color:#9aa39a}
+ .radio-cards input{position:absolute;opacity:0}
+ .radio-cards input:checked + span,
+ .radio-cards label:has(input:checked){border-color:var(--ink,#0a0a0a);background:#fff8e6}
+ .radio-cards strong{display:block;font-size:14px;margin:0 0 2px}
+ .radio-cards small{display:block;font-size:12px;color:var(--muted,#666);line-height:1.4}
+ .signin-card{display:flex;align-items:center;gap:14px;border:1px solid var(--border,#d4d2c8);border-radius:14px;padding:14px 18px;background:#f8f7f2;margin:0 0 18px}
+ .signin-card img{width:36px;height:36px;border-radius:50%;object-fit:cover;background:#ddd}
+ .signin-card .who{flex:1;font-size:14px}
+ .signin-card .who strong{display:block;font-size:15px}
+ .signin-card .gbtn{display:inline-flex;align-items:center;gap:8px;background:#fff;color:#222;border:1px solid #ccc;padding:10px 18px;border-radius:8px;font-weight:600;text-decoration:none;font-size:14px}
+ .signin-card .gbtn svg{width:18px;height:18px}
+</style>
+
+<section class="book-page">
+ <header class="book-head">
+ <p class="kicker">Book · <%= installer.business_name %> · <%= installer.city %>, <%= installer.state %></p>
+ <h1 class="display-sm">Schedule a consultation</h1>
+ <p class="lede">Five quick questions, then pick a time on <%= installer.business_name %>'s live calendar.</p>
+ </header>
+
+ <% if (consumer) { %>
+ <div class="signin-card" data-consumer-signin>
+ <% if (consumer.picture_url) { %><img src="<%= consumer.picture_url %>" alt=""><% } %>
+ <div class="who"><strong><%= consumer.name || consumer.email %></strong>Signed in with Google · <%= consumer.email %></div>
+ <form method="post" action="/auth/google/logout" style="margin:0">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="hidden" name="next" value="/installer/<%= installer.slug %>/book">
+ <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
+ </form>
+ </div>
+ <% } else { %>
+ <div class="signin-card">
+ <div class="who"><strong>Sign in to save your project history</strong>Or continue as a guest — sign-in is optional but recommended.</div>
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
+ <a class="gbtn" href="/auth/google/start?next=<%= encodeURIComponent('/installer/' + installer.slug + '/book') %>">
+ <svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3c-1.6 4.5-5.9 8-11.3 8-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3l5.7-5.7C34.5 6.1 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.7-.4-4z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 16 19 13 24 13c3.1 0 5.9 1.2 8 3l5.7-5.7C34.5 6.1 29.5 4 24 4 16.3 4 9.6 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.4 0 10.3-2 14-5.4l-6.5-5.5c-2.1 1.4-4.7 2.3-7.5 2.3-5.4 0-9.7-3.5-11.3-8L6 32.4C9.3 38.8 16 44 24 44z"/><path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.7 2.1-2 3.9-3.7 5.2L37.9 39c-.5.4 6.1-4.4 6.1-15 0-1.3-.1-2.7-.4-4z"/></svg>
+ Sign in with Google
+ </a>
+ <a class="gbtn" style="background:#0a66c2;color:#fff;border-color:#0a66c2" href="/auth/linkedin/start?next=<%= encodeURIComponent('/installer/' + installer.slug + '/book') %>">
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#fff"><path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"/></svg>
+ Sign in with LinkedIn
+ </a>
+ </div>
+ </div>
+ <% } %>
+
+ <% if (!calendarEnabled) { %>
+ <%
+ function _safeWeb(u) {
+ if (!u) return null;
+ try { var x = new URL(u); return (x.protocol === 'http:' || x.protocol === 'https:') ? x.toString() : null; }
+ catch(e){ return null; }
+ }
+ var _site = _safeWeb(installer.website);
+ %>
+ <div class="callout">
+ <h2 style="margin-top:0">Self-booking isn't enabled for this studio yet</h2>
+ <p>Reach <strong><%= installer.business_name %></strong> directly — they typically respond within <%= installer.response_time_hours %> hours:</p>
+ <div class="profile-actions" style="margin:16px 0">
+ <% if (_site) { %><a href="<%= _site %>" target="_blank" rel="noopener" class="btn btn-primary">Visit studio site ↗</a><% } %>
+ <% if (installer.instagram_handle) { %><a href="https://instagram.com/<%= encodeURIComponent(installer.instagram_handle) %>" target="_blank" rel="noopener" class="btn btn-ghost">@<%= installer.instagram_handle %></a><% } %>
+ <% if (installer.phone) { %><a href="tel:<%= installer.phone %>" class="btn btn-ghost">Call <%= installer.phone %></a><% } %>
+ </div>
+ <p class="muted">Want to book directly into a calendar instead? <a href="/find">Browse studios on Pro & Signature plans →</a></p>
+ <a href="/installer/<%= installer.slug %>" class="btn btn-ghost btn-sm" style="margin-top:8px">← Back to <%= installer.business_name %>'s profile</a>
+ </div>
+ <% } else { %>
+ <div class="book-layout">
+ <div class="book-calendar" data-installer-slug="<%= installer.slug %>">
+ <div class="calendar-controls">
+ <button type="button" class="btn btn-ghost" data-cal-prev>← Earlier</button>
+ <span data-cal-range>Loading…</span>
+ <button type="button" class="btn btn-ghost" data-cal-next>Later →</button>
+ </div>
+ <div class="slot-grid" data-cal-slots>
+ <p class="slot-loading">Loading available slots…</p>
+ </div>
+ </div>
+
+ <form class="book-form" id="book-form" autocomplete="on" data-intake-wizard>
+ <input type="hidden" name="installer_slug" value="<%= installer.slug %>">
+ <input type="hidden" name="scheduled_start" id="scheduled_start" required>
+ <input type="hidden" name="scheduled_end" id="scheduled_end" required>
+
+ <ol class="intake-steps">
+ <li class="is-active" data-step-pill="1">Scope</li>
+ <li data-step-pill="2">Wallpaper</li>
+ <li data-step-pill="3">Who</li>
+ <li data-step-pill="4">Where</li>
+ <li data-step-pill="5">Slot & details</li>
+ </ol>
+
+ <!-- ===== Step 1 · Scope ===== -->
+ <fieldset class="intake-step is-active" data-step="1">
+ <h2>What's the scope?</h2>
+ <p class="helper">Rough is fine — the studio confirms on the visit. This helps them arrive prepared (lift, paste table, crew size).</p>
+ <label>Rooms / walls being papered <input type="text" name="surfaces" placeholder="e.g. Dining room walls, primary suite, powder room"></label>
+ <div class="row3">
+ <label>Approx. square feet <input type="number" name="square_feet" min="0" step="10" placeholder="e.g. 320"></label>
+ <label>Ceiling height (ft) <input type="number" name="ceiling_height_ft" min="6" max="40" step="0.5" placeholder="9, 10, 12"></label>
+ <label>Estimated rolls <input type="number" name="roll_count_estimate" min="0" step="1" placeholder="optional"></label>
+ </div>
+ <label>Surface state
+ <select name="surface_state">
+ <option value="">Choose…</option>
+ <option value="new_plaster">New plaster (needs lining)</option>
+ <option value="painted_drywall">Painted drywall</option>
+ <option value="wallpaper_to_remove">Existing wallpaper to remove</option>
+ <option value="brick">Brick / masonry</option>
+ <option value="wood_panel">Wood panel</option>
+ <option value="other">Other</option>
+ </select>
+ </label>
+ <label>Access
+ <select name="access_constraints">
+ <option value="">Choose…</option>
+ <option value="ground_floor">Ground floor / accessible</option>
+ <option value="second_floor">Second floor (no lift)</option>
+ <option value="atrium_double_height">Atrium / double-height (lift required)</option>
+ <option value="high_rise">High-rise (freight elevator + COI)</option>
+ <option value="restricted_hours">Restricted hours / occupied space</option>
+ </select>
+ </label>
+ <div class="nav"><span></span><button type="button" class="btn btn-primary" data-step-next="2">Next →</button></div>
+ </fieldset>
+
+ <!-- ===== Step 2 · Has wallpaper been selected? ===== -->
+ <fieldset class="intake-step" data-step="2">
+ <h2>Has the wallpaper been selected?</h2>
+ <p class="helper">If yes, the studio can confirm they install that brand and that it's in stock; if no, they'll bring samples and recommendations.</p>
+ <div class="radio-cards">
+ <label><input type="radio" name="product_sourced" value="true"><span><strong>Yes — already chosen</strong><small>Brand and pattern are picked. May or may not be ordered yet.</small></span></label>
+ <label><input type="radio" name="product_sourced" value="false" checked><span><strong>Not yet</strong><small>Looking for guidance on brand and pattern.</small></span></label>
+ </div>
+ <div data-show-if='[name="product_sourced"]:checked[value="true"]' style="margin-top:8px">
+ <label>Material
+ <select name="material">
+ <option value="">Choose…</option>
+ <option value="hand_painted">Hand-painted</option>
+ <option value="silk">Silk</option>
+ <option value="grasscloth">Grasscloth</option>
+ <option value="vinyl">Vinyl</option>
+ <option value="mural">Mural</option>
+ <option value="metallic_leaf">Metallic leaf</option>
+ <option value="other">Other</option>
+ </select>
+ </label>
+ <label>Brand
+ <input type="text" name="brand" list="brand-suggestions" placeholder="e.g. Fromental, de Gournay, Phillip Jeffries">
+ <datalist id="brand-suggestions">
+ <option value="de Gournay"></option><option value="Fromental"></option>
+ <option value="Phillip Jeffries"></option><option value="Maya Romanoff"></option>
+ <option value="Schumacher"></option><option value="Cole & Son"></option>
+ <option value="Pierre Frey"></option><option value="Calico Wallpaper"></option>
+ <option value="Élitis"></option><option value="Holland & Sherry"></option>
+ <option value="Porter Teleo"></option><option value="Adelphi Paper Hangings"></option>
+ <option value="Mind the Gap"></option><option value="Designer Wallcoverings"></option>
+ <option value="Wallquest"></option><option value="York Wallcoverings"></option>
+ </datalist>
+ </label>
+ <label>Pattern / SKU (if known) <input type="text" name="brand_sku" placeholder="e.g. Earlham 7821 / Bois 113"></label>
+ </div>
+ <div class="nav"><button type="button" class="btn btn-ghost" data-step-prev="1">← Back</button><button type="button" class="btn btn-primary" data-step-next="3">Next →</button></div>
+ </fieldset>
+
+ <!-- ===== Step 3 · Who is ordering? ===== -->
+ <fieldset class="intake-step" data-step="3">
+ <h2>Who's commissioning the work?</h2>
+ <p class="helper">Studios price and brief differently for trade clients vs. homeowners — this is just so they can prep the right way.</p>
+ <div class="radio-cards">
+ <label><input type="radio" name="customer_role" value="homeowner" checked><span><strong>Homeowner</strong><small>I live in the home or own it.</small></span></label>
+ <label><input type="radio" name="customer_role" value="designer"><span><strong>Interior designer</strong><small>Designing on behalf of a client.</small></span></label>
+ <label><input type="radio" name="customer_role" value="architect"><span><strong>Architect</strong><small>Specifying for a project.</small></span></label>
+ <label><input type="radio" name="customer_role" value="contractor"><span><strong>General contractor</strong><small>Coordinating trades on a build.</small></span></label>
+ <label><input type="radio" name="customer_role" value="property_mgr"><span><strong>Property manager</strong><small>Hospitality, commercial, or multi-unit.</small></span></label>
+ <label><input type="radio" name="customer_role" value="other"><span><strong>Other</strong><small>Tell us in the notes.</small></span></label>
+ </div>
+ <div class="nav"><button type="button" class="btn btn-ghost" data-step-prev="2">← Back</button><button type="button" class="btn btn-primary" data-step-next="4">Next →</button></div>
+ </fieldset>
+
+ <!-- ===== Step 4 · Where is the project? ===== -->
+ <fieldset class="intake-step" data-step="4">
+ <h2>Where's the project?</h2>
+ <p class="helper">The address stays private — only <%= installer.business_name %> sees it after they accept. ZIP is enough to start; full address comes before the visit.</p>
+ <label>Street <input type="text" name="address_line1" autocomplete="address-line1"></label>
+ <label>Apt / Suite <input type="text" name="address_line2" autocomplete="address-line2"></label>
+ <div class="row3">
+ <label>City <input type="text" name="city" autocomplete="address-level2"></label>
+ <label>State <input type="text" name="state" maxlength="2" autocomplete="address-level1"></label>
+ <label>ZIP <input type="text" name="zip" maxlength="10" autocomplete="postal-code" required></label>
+ </div>
+ <div class="nav"><button type="button" class="btn btn-ghost" data-step-prev="3">← Back</button><button type="button" class="btn btn-primary" data-step-next="5">Next →</button></div>
+ </fieldset>
+
+ <!-- ===== Step 5 · Slot + contact details ===== -->
+ <fieldset class="intake-step" data-step="5">
+ <h2>Pick a time and confirm</h2>
+ <p class="helper">All available slots are pulled from <%= installer.business_name %>'s live calendar — you're not waiting for a callback.</p>
+
+ <div class="selected-slot" data-selected-slot><span>Pick a slot from the calendar at left to continue</span></div>
+
+ <% if (consumer) { %>
+ <p class="helper" style="background:#dcfce7;color:#166534;padding:8px 12px;border-radius:6px">Signed in as <%= consumer.email %> — name and email are pre-filled.</p>
+ <% } %>
+
+ <label>Name<input type="text" name="customer_name" required value="<%= consumer ? (consumer.name || '') : '' %>"></label>
+ <label>Email<input type="email" name="customer_email" required value="<%= consumer ? consumer.email : '' %>"></label>
+ <label>Phone<input type="tel" name="customer_phone" autocomplete="tel"></label>
+
+ <label>Project type
+ <select name="project_type">
+ <option value="consultation">Consultation</option>
+ <option value="site_visit">Site visit / measurement</option>
+ <option value="quote">Quote review</option>
+ <option value="install">Install</option>
+ </select>
+ </label>
+ <label>Budget band
+ <select name="budget_band">
+ <option value="">Prefer not to say</option>
+ <option value="under_5k">Under $5k</option>
+ <option value="5k_15k">$5k – $15k</option>
+ <option value="15k_50k">$15k – $50k</option>
+ <option value="50k_plus">$50k +</option>
+ </select>
+ </label>
+ <label>Anything else? <textarea name="customer_notes" rows="3" placeholder="Color preferences, deadlines, samples already in hand…"></textarea></label>
+
+ <div class="nav" style="margin-top:8px"><button type="button" class="btn btn-ghost" data-step-prev="4">← Back</button><span></span></div>
+ </fieldset>
+
+ <%
+ // Booking deposit pricing — env-tuneable. Defaults match lib/stripe.js.
+ var _depositCents = parseInt(process.env.NPH_DEFAULT_DEPOSIT_CENTS || '9900', 10);
+ var _depositLabel = '$' + (_depositCents / 100).toFixed(_depositCents % 100 ? 2 : 0);
+ var _hasPubKey = !!stripePublishableKey;
+ %>
+
+ <fieldset class="deposit-block">
+ <legend>Reserve your slot · <%= _depositLabel %> deposit</legend>
+ <p class="muted" style="margin:0 0 12px">
+ A <strong><%= _depositLabel %></strong> deposit secures the appointment and is fully credited toward your project.
+ Cancel up to 48 hours before the visit for a full refund.
+ <span class="muted-2">National Paper Hangers handles payment and forwards the balance to <%= installer.business_name %> after the visit.</span>
+ </p>
+ <% if (_hasPubKey) { %>
+ <div id="deposit-card-element" data-stripe-card aria-label="Card details"></div>
+ <div id="deposit-card-errors" role="alert" class="form-error"></div>
+ <% } else { %>
+ <p class="callout-soft">
+ Test mode — no real card needed. Submitting will register the booking; payment will be requested when Stripe keys are live.
+ </p>
+ <% } %>
+ </fieldset>
+
+ <button type="submit" class="btn btn-primary btn-lg" disabled data-submit>
+ <% if (_hasPubKey) { %>Reserve · pay <%= _depositLabel %><% } else { %>Confirm booking<% } %>
+ </button>
+ <p class="form-fineprint">By confirming, you agree to the studio's response and cancellation terms. Insurance and licensing are self-reported by installers; verify directly before any contract.</p>
+ </form>
+ </div>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
+
+<% if (stripePublishableKey) { %>
+ <script src="https://js.stripe.com/v3/"></script>
+ <script>window.NPH_STRIPE_PK = <%- JSON.stringify(stripePublishableKey) %>;</script>
+<% } %>
+<script src="/js/calendar-consumer.js" defer></script>
+<script>
+ // 5-step intake wizard navigation. The form itself still posts to the same
+ // /api/installers/:slug/book endpoint that calendar-consumer.js wires up —
+ // we're only managing which fieldset is visible.
+ (function(){
+ var form = document.querySelector('[data-intake-wizard]');
+ if (!form) return;
+ function go(n){
+ form.querySelectorAll('.intake-step').forEach(function(s){ s.classList.toggle('is-active', s.getAttribute('data-step') === String(n)); });
+ form.querySelectorAll('[data-step-pill]').forEach(function(p){
+ var v = parseInt(p.getAttribute('data-step-pill'), 10);
+ p.classList.toggle('is-active', v === n);
+ p.classList.toggle('is-done', v < n);
+ });
+ // Smooth scroll the active step into view (helps on mobile).
+ var active = form.querySelector('.intake-step.is-active');
+ if (active && active.scrollIntoView) active.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ form.addEventListener('click', function(e){
+ var n = e.target.closest('[data-step-next]'); if (n) { e.preventDefault(); go(parseInt(n.getAttribute('data-step-next'), 10)); return; }
+ var p = e.target.closest('[data-step-prev]'); if (p) { e.preventDefault(); go(parseInt(p.getAttribute('data-step-prev'), 10)); return; }
+ });
+
+ // Step 2 conditional reveal: brand fields only when product_sourced=true.
+ function syncProductFields(){
+ var on = form.querySelector('input[name="product_sourced"]:checked');
+ var box = form.querySelector('[data-show-if]');
+ if (box) box.style.display = (on && on.value === 'true') ? 'block' : 'none';
+ }
+ form.querySelectorAll('input[name="product_sourced"]').forEach(function(r){ r.addEventListener('change', syncProductFields); });
+ syncProductFields();
+ })();
+
+ gtag('event', 'booking_started', {
+ installer_slug: '<%= installer.slug %>',
+ installer_name: '<%= installer.business_name.replace(/'/g, "\\'") %>'
+ });
+</script>
diff --git a/views/public/booking.ejs b/views/public/booking.ejs
new file mode 100644
index 0000000..24fda84
--- /dev/null
+++ b/views/public/booking.ejs
@@ -0,0 +1,46 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="booking-confirmation">
+ <header>
+ <p class="kicker">Booking · <%= booking.business_name %></p>
+ <h1 class="display-sm">
+ <% if (booking.status === 'confirmed') { %>You're confirmed.<% } else if (booking.status === 'pending') { %>Booking received.<% } else if (booking.status === 'canceled' || booking.status === 'declined') { %>Booking canceled.<% } else if (booking.status === 'completed') { %>Project complete.<% } else { %>Your booking<% } %>
+ </h1>
+ </header>
+
+ <div class="booking-card">
+ <dl>
+ <dt>Studio</dt>
+ <dd><a href="/installer/<%= booking.installer_slug %>"><%= booking.business_name %></a><% if (booking.installer_phone) { %> · <%= booking.installer_phone %><% } %></dd>
+ <dt>When</dt>
+ <dd><%= new Date(booking.scheduled_start).toLocaleString('en-US', { weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }) %></dd>
+ <dt>Type</dt>
+ <dd><%= (booking.project_type || 'consultation').replace(/_/g,' ') %></dd>
+ <dt>Status</dt>
+ <dd><span class="status-badge status-<%= booking.status %>"><%= booking.status %></span></dd>
+ <% if (booking.address_line1) { %>
+ <dt>Address</dt>
+ <dd><%= [booking.address_line1, booking.city, booking.state, booking.zip].filter(Boolean).join(', ') %></dd>
+ <% } %>
+ <% if (booking.customer_notes) { %>
+ <dt>Notes</dt>
+ <dd><%= booking.customer_notes %></dd>
+ <% } %>
+ </dl>
+ </div>
+
+ <p>Need to reschedule or cancel? Reply to your confirmation email or write to <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a>.</p>
+</section>
+
+<%- include('../partials/footer') %>
+<% if (booking.status === 'confirmed') { %>
+<script>
+ // GA4 conversion: booking_confirmed
+ gtag('event', 'booking_confirmed', {
+ booking_uuid: '<%= booking.uuid %>',
+ installer_slug: '<%= booking.installer_slug %>',
+ installer_name: '<%= booking.business_name.replace(/'/g, "\\'") %>'
+ });
+</script>
+<% } %>
diff --git a/views/public/claim-complete.ejs b/views/public/claim-complete.ejs
new file mode 100644
index 0000000..d5472d9
--- /dev/null
+++ b/views/public/claim-complete.ejs
@@ -0,0 +1,30 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="auth-page">
+ <div class="auth-card auth-card-wide">
+ <p class="kicker">Final step</p>
+ <h1 class="display-sm">Set up your account</h1>
+ <p class="lede">Welcome to National Paper Hangers — you've verified ownership of <strong><%= installer.business_name %></strong>. Set a password and we'll send you to your dashboard.</p>
+
+ <% if (error) { %><p class="form-error"><%= error %></p><% } %>
+
+ <form method="post" action="/installer/<%= installer.slug %>/claim/complete">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <label>Account email <input type="email" name="email" required autofocus></label>
+ <label>Choose a password (8+ chars) <input type="password" name="password" required minlength="8"></label>
+ <button type="submit" class="btn btn-primary btn-lg">Finish & open dashboard</button>
+ </form>
+
+ <p class="muted" style="margin-top:24px">After this, your listing moves to <strong>pending review</strong>. NPH staff will activate it within 1 business day. You can edit your profile and (when ready) upgrade to Pro to enable calendar bookings.</p>
+ </div>
+</section>
+
+<%- include('../partials/footer') %>
+<script>
+ // GA4 conversion: claim_completed (domain-verified ownership, password setup page reached)
+ gtag('event', 'claim_completed', {
+ installer_slug: '<%= installer.slug %>',
+ installer_name: '<%= installer.business_name.replace(/'/g, "\\'") %>'
+ });
+</script>
diff --git a/views/public/claim.ejs b/views/public/claim.ejs
new file mode 100644
index 0000000..e716c98
--- /dev/null
+++ b/views/public/claim.ejs
@@ -0,0 +1,81 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="auth-page">
+ <div class="auth-card auth-card-wide">
+ <p class="kicker">Claim listing</p>
+ <h1 class="display-sm">Is <%= installer.business_name %> your studio?</h1>
+
+ <% if (sent) { %>
+ <div class="callout callout-success">
+ <strong>Verification email sent to <%= sentTo %>.</strong>
+ Click the link in that email to confirm. The link expires in 24 hours.
+ </div>
+
+ <ol class="claim-next-steps" aria-label="What happens next">
+ <li>
+ <strong>Your listing goes live under your control.</strong>
+ Once you verify your email you'll have full edit access — update your bio, materials, portfolio, and service area instantly.
+ </li>
+ <li>
+ <strong>Upgrade to Pro to unlock calendar bookings and a Verified badge.</strong>
+ Clients on the Pro tier can self-book consultations directly into your calendar and your listing displays a prominent Verified mark.
+ </li>
+ <li>
+ <strong>We'll email you a setup checklist.</strong>
+ After verification you'll receive a short guide covering profile completion, availability setup, and how to get your first booking.
+ </li>
+ </ol>
+
+ <p><a href="/installer/<%= installer.slug %>" class="btn btn-ghost">Back to profile</a></p>
+ <% } else { %>
+ <p class="lede">
+ This is an unclaimed public directory listing seeded from <%= installer.source_name === 'wia' ? 'the Wallcovering Installers Association directory' : 'a public source' %>. To claim it, verify ownership using an email address on the studio's domain.
+ </p>
+
+ <%
+ var _safeWebsite = (function(u){
+ if (!u) return null;
+ try { var x = new URL(u); return (x.protocol === 'http:' || x.protocol === 'https:') ? x.toString() : null; }
+ catch (e) { return null; }
+ })(installer.website);
+ %>
+ <% if (_safeWebsite) { %>
+ <p class="muted">Website on file: <a href="<%= _safeWebsite %>" target="_blank" rel="noopener"><%= _safeWebsite %></a> · Use an email on this domain.</p>
+ <% } %>
+
+ <% if (error) { %><p class="form-error"><%= error %></p><% } %>
+
+ <form method="post" action="/installer/<%= installer.slug %>/claim">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <label>Email on the studio's domain
+ <input type="email" name="email" required placeholder="info@yourdomain.com" autofocus>
+ </label>
+ <button type="submit" class="btn btn-primary btn-lg">Send verification email</button>
+ </form>
+
+ <hr style="margin:32px 0;border:none;border-top:1px solid var(--border)">
+ <p class="muted">
+ No matching email address? Email
+ <a href="mailto:info@nationalpaperhangers.com?subject=Manual%20claim%3A%20<%= encodeURIComponent(installer.business_name) %>">info@nationalpaperhangers.com</a>
+ for manual review (we accept proof via website live chat, public phone reply, or DNS TXT record).
+ </p>
+ <p class="muted">
+ To request removal of this listing,
+ <a href="mailto:info@nationalpaperhangers.com?subject=Opt-out%3A%20<%= encodeURIComponent(installer.business_name) %>">email us</a>
+ and we'll remove it within 5 business days per our public directory data policy.
+ </p>
+ <% } %>
+ </div>
+</section>
+
+<%- include('../partials/footer') %>
+<% if (!sent) { %>
+<script>
+ // GA4 conversion: claim_started (form is visible, verification email not yet sent)
+ gtag('event', 'claim_started', {
+ installer_slug: '<%= installer.slug %>',
+ installer_name: '<%= installer.business_name.replace(/'/g, "\\'") %>'
+ });
+</script>
+<% } %>
diff --git a/views/public/error.ejs b/views/public/error.ejs
new file mode 100644
index 0000000..991508e
--- /dev/null
+++ b/views/public/error.ejs
@@ -0,0 +1,9 @@
+<%- include('../partials/head', { title: 'Error' }) %>
+<%- include('../partials/header') %>
+<section class="long-form" style="text-align:center">
+ <p class="kicker">Something went wrong</p>
+ <h1 class="display-sm">We hit a snag</h1>
+ <p><%= message %></p>
+ <p><a href="/">Back to home</a></p>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/public/find.ejs b/views/public/find.ejs
new file mode 100644
index 0000000..dbecb9d
--- /dev/null
+++ b/views/public/find.ejs
@@ -0,0 +1,97 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="find-page">
+ <div class="find-head">
+ <h1 class="display-sm">Find a verified installer</h1>
+ <p class="lede">Filter by city, ZIP, segment, material, or brand experience. Every active listing is reviewed and re-validated by NPH staff.</p>
+ </div>
+
+ <form action="/find" method="get" class="find-filters" role="search">
+ <div class="filter-row">
+ <label>
+ <span>Search</span>
+ <input type="text" name="q" value="<%= q %>" placeholder="City, brand, specialty">
+ </label>
+ <label>
+ <span>ZIP</span>
+ <input type="text" name="zip" value="<%= zip %>" placeholder="90210" maxlength="10">
+ </label>
+ <label>
+ <span>State</span>
+ <input type="text" name="state" value="<%= state %>" placeholder="CA" maxlength="2">
+ </label>
+ <label>
+ <span>Segment</span>
+ <select name="segment">
+ <option value="">All</option>
+ <% ['luxury_residential','hospitality','retail','museum','yacht'].forEach(function(s){ %>
+ <option value="<%= s %>" <%= segment === s ? 'selected' : '' %>><%= s.replace(/_/g,' ') %></option>
+ <% }); %>
+ </select>
+ </label>
+ <label>
+ <span>Material</span>
+ <select name="material">
+ <option value="">All</option>
+ <% ['hand_painted','silk','grasscloth','vinyl','mural','metallic_leaf','hand_screened','digital_print'].forEach(function(m){ %>
+ <option value="<%= m %>" <%= material === m ? 'selected' : '' %>><%= m.replace(/_/g,' ') %></option>
+ <% }); %>
+ </select>
+ </label>
+ <button type="submit" class="btn btn-primary">Search</button>
+ </div>
+ </form>
+
+ <p class="result-count"><%= installers.length %> installer<%= installers.length === 1 ? '' : 's' %></p>
+
+ <div class="installer-grid">
+ <% installers.forEach(function(i){ %>
+ <a class="installer-card" href="/installer/<%= i.slug %>">
+ <% var _pdImg = pickSegmentImage(i); %>
+ <div class="card-image">
+ <% if (_pdImg) { %>
+ <img loading="lazy" decoding="async" src="<%= _pdImg.file %>" alt="<%= i.business_name %> — wallcovering installer in <%= [i.city,i.state].filter(Boolean).join(', ') %>">
+ <% } else { %>
+ <span class="card-image-placeholder"><%= (i.business_name || '').slice(0,2).toUpperCase() %></span>
+ <% } %>
+ </div>
+ <div class="card-body">
+ <h3 class="card-title"><%= i.business_name %></h3>
+ <p class="card-meta"><%= i.city %>, <%= i.state %><% if (i.zip) { %> · <%= i.zip %><% } %></p>
+ <% if (i.headline) { %><p class="card-headline"><%= i.headline %></p><% } %>
+ <ul class="card-tags">
+ <% (i.materials || []).slice(0,4).forEach(function(m){ %>
+ <li class="tag"><%= m.replace(/_/g,' ') %></li>
+ <% }); %>
+ </ul>
+ <% if (i.verified_brands_count > 0) { %>
+ <p class="card-creds" title="Brand-trained credentials verified by NPH ops">
+ <span class="cred-pip" aria-hidden="true">✦</span>
+ Brand-trained:
+ <%= (i.verified_brands || []).slice(0,2).join(', ') %><% if (i.verified_brands_count > 2) { %> +<%= i.verified_brands_count - 2 %><% } %>
+ </p>
+ <% } %>
+ <div class="card-foot">
+ <% if (i.claim_status === 'unclaimed') { %>
+ <span class="directory-badge" title="Public directory listing seeded from WIA. Studio has not yet claimed it.">Directory listing</span>
+ <% } else { %>
+ <% if (i.verified) { %><span class="verified-badge">Verified</span><% } %>
+ <% if (i.tier === 'signature' || i.tier === 'enterprise') { %><span class="signature-badge">Signature</span><% } %>
+ <span class="response-sla">Responds in < <%= i.response_time_hours %>h</span>
+ <% } %>
+ </div>
+ </div>
+ </a>
+ <% }); %>
+ <% if (installers.length === 0) { %>
+ <div class="empty-state">
+ <h3>No installers match those filters yet.</h3>
+ <p>Loosen a filter, or <a href="/find">view all active studios</a>. We're actively onboarding installers nationwide.</p>
+ </div>
+ <% } %>
+ </div>
+</section>
+
+<%- include('../partials/footer') %>
+<script src="/js/find-filter.js"></script>
diff --git a/views/public/for-installers.ejs b/views/public/for-installers.ejs
new file mode 100644
index 0000000..28a8f2c
--- /dev/null
+++ b/views/public/for-installers.ejs
@@ -0,0 +1,102 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="for-installers-page">
+ <header class="hero">
+ <div class="hero-inner">
+ <p class="kicker">For installers</p>
+ <h1 class="display">Your studio. Your calendar.<br>Booked direct.</h1>
+ <p class="lede">National Paper Hangers is the directory the trade actually uses — and the only one that turns your calendar into a real booking system. List your business, take direct bookings, manage your schedule from one dashboard.</p>
+ <div class="hero-actions">
+ <a href="/signup" class="btn btn-primary btn-lg">Apply to list</a>
+ <a href="/login" class="btn btn-ghost btn-lg">Already listed? Log in</a>
+ </div>
+ </div>
+ </header>
+
+ <section class="pricing">
+ <div class="section-head">
+ <h2 class="section-title">Pricing</h2>
+ <p class="section-sub">Simple, predictable. No commission on jobs.</p>
+ </div>
+ <div class="pricing-grid">
+ <article class="price-card">
+ <h3>Basic</h3>
+ <p class="price">Free / invite</p>
+ <ul>
+ <li>Profile listing</li>
+ <li>Search visibility</li>
+ <li>No inbound calendar bookings</li>
+ <li>No verified badge</li>
+ </ul>
+ </article>
+ <article class="price-card price-card-feature">
+ <h3>Pro</h3>
+ <p class="price">$39 / month <span>or $399 / year</span></p>
+ <ul>
+ <li>Full profile + portfolio</li>
+ <li><strong>Live calendar booking</strong></li>
+ <li>Lead notifications</li>
+ <li>Verification on file</li>
+ <li>Response SLA tracking</li>
+ </ul>
+ <a href="/signup" class="btn btn-primary">Get started</a>
+ </article>
+ <article class="price-card">
+ <h3>Signature</h3>
+ <p class="price">$149 / month <span>or $1,500 / year</span></p>
+ <ul>
+ <li>Everything in Pro</li>
+ <li>Premium placement</li>
+ <li>Concierge-matched leads</li>
+ <li>Document vault for COI / W-9</li>
+ <li>Case-study features</li>
+ </ul>
+ </article>
+ <article class="price-card">
+ <h3>Enterprise</h3>
+ <p class="price">$399 / month +</p>
+ <ul>
+ <li>Multi-installer team seats</li>
+ <li>Territory controls</li>
+ <li>API / CRM export</li>
+ <li>Dedicated success contact</li>
+ </ul>
+ </article>
+ </div>
+ </section>
+
+ <section class="benefits">
+ <div class="section-head">
+ <h2 class="section-title">What you get</h2>
+ </div>
+ <ul class="benefits-grid">
+ <li>
+ <h3>A calendar that pays for itself</h3>
+ <p>Set weekly availability, block vacations, accept or decline bookings — all from one dashboard. Customers self-book into your real openings.</p>
+ </li>
+ <li>
+ <h3>Trade-quality demand</h3>
+ <p>Designer Wallcoverings already routes its non-LA installation requests here. Our marketplace prioritizes luxury residential, hospitality, museum, and contract work — not low-margin DIY.</p>
+ </li>
+ <li>
+ <h3>Verification you can show clients</h3>
+ <p>Insurance, accreditations, brand experience, and references on file. Clients see the verified badge and the response SLA before they book.</p>
+ </li>
+ <li>
+ <h3>No commission</h3>
+ <p>Flat subscription. The booking goes to you, the customer pays you, you keep the entire fee.</p>
+ </li>
+ </ul>
+ </section>
+
+ <section class="for-installers-cta">
+ <div class="cta-inner">
+ <h2 class="display-sm">Apply to list</h2>
+ <p>Free to apply. We review every applicant.</p>
+ <a href="/signup" class="btn btn-primary btn-lg">Start application</a>
+ </div>
+ </section>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/home.ejs b/views/public/home.ejs
new file mode 100644
index 0000000..f7ea569
--- /dev/null
+++ b/views/public/home.ejs
@@ -0,0 +1,117 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="hero">
+ <div class="hero-inner">
+ <p class="kicker">Verified · Insured · Scheduled</p>
+ <h1 class="display">The luxury wallcovering<br>installer hub.</h1>
+ <p class="lede">Hand-painted papers, silk, grasscloth, murals — installed by verified specialists across every market in the country. Search, vet, and book directly into the installer's calendar.</p>
+ <form action="/find" method="get" class="hero-search" role="search">
+ <input type="text" name="q" placeholder="City, ZIP, brand, or specialty" aria-label="Search">
+ <select name="segment" aria-label="Segment">
+ <option value="">All segments</option>
+ <option value="luxury_residential">Luxury residential</option>
+ <option value="hospitality">Hospitality</option>
+ <option value="retail">Retail</option>
+ <option value="museum">Museum / cultural</option>
+ <option value="yacht">Yacht / aviation</option>
+ </select>
+ <button type="submit" class="btn btn-primary">Find an installer</button>
+ </form>
+ <p class="hero-trust">Already specifying a project? <a href="/find?segment=luxury_residential">Browse signature studios →</a></p>
+ </div>
+</section>
+
+<section class="trust-strip">
+ <div class="trust-inner">
+ <div><strong><%= stats.total_listings %>+</strong><span>installer studios listed</span></div>
+ <div><strong><%= stats.state_count %></strong><span>states covered</span></div>
+ <div><strong><%= stats.accredited_count %></strong><span>WIA-accredited members</span></div>
+ <div><strong>Concierge</strong><span>For architects, designers, hospitality</span></div>
+ </div>
+</section>
+
+<section class="featured">
+ <div class="section-head">
+ <h2 class="section-title">Featured studios</h2>
+ <a href="/find" class="section-link">Browse all installers →</a>
+ </div>
+ <div class="installer-grid">
+ <% featured.forEach(function(i){ %>
+ <a class="installer-card" href="/installer/<%= i.slug %>">
+ <% var _pdImg = pickSegmentImage(i); %>
+ <div class="card-image">
+ <% if (_pdImg) { %>
+ <img loading="lazy" decoding="async" src="<%= _pdImg.file %>" alt="<%= i.business_name %> — wallcovering installer in <%= [i.city,i.state].filter(Boolean).join(', ') %>">
+ <% } else { %>
+ <span class="card-image-placeholder"><%= (i.business_name || '').slice(0,2).toUpperCase() %></span>
+ <% } %>
+ </div>
+ <div class="card-body">
+ <h3 class="card-title"><%= i.business_name %></h3>
+ <p class="card-meta"><%= i.city %>, <%= i.state %></p>
+ <p class="card-headline"><%= i.headline %></p>
+ <ul class="card-tags">
+ <% (i.materials || []).slice(0,3).forEach(function(m){ %>
+ <li class="tag"><%= m.replace(/_/g,' ') %></li>
+ <% }); %>
+ </ul>
+ <% if (i.verified) { %><span class="verified-badge">Verified</span><% } %>
+ </div>
+ </a>
+ <% }); %>
+ </div>
+</section>
+
+<section class="how-it-works">
+ <div class="section-head">
+ <h2 class="section-title">How it works</h2>
+ </div>
+ <ol class="how-grid">
+ <li>
+ <span class="step">01</span>
+ <h3>Search by location, specialty, or brand</h3>
+ <p>Filter for hand-painted papers, silk, grasscloth, mural, or specific manufacturers like Fromental, Maya Romanoff, or de Gournay.</p>
+ </li>
+ <li>
+ <span class="step">02</span>
+ <h3>Vet the studio</h3>
+ <p>Every active installer profile shows portfolio, insurance status, accreditations, brand experience, response SLA, and travel radius.</p>
+ </li>
+ <li>
+ <span class="step">03</span>
+ <h3>Book the calendar</h3>
+ <p>Pick an open slot directly from the installer's live calendar — consultations, site visits, and installs scheduled in one place.</p>
+ </li>
+ </ol>
+</section>
+
+<section class="for-installers-cta">
+ <div class="cta-inner">
+ <h2 class="display-sm">Run a wallcovering studio?</h2>
+ <p>Get listed in the directory the trade actually uses, and turn your calendar into your booking system. From $39/month.</p>
+ <a href="/for-installers" class="btn btn-primary">List your business</a>
+ </div>
+</section>
+
+<%
+ var _orgBase = (typeof publicUrl !== 'undefined' && publicUrl) ? publicUrl.replace(/\/+$/, '') : 'https://www.nationalpaperhangers.com';
+%>
+<script type="application/ld+json"><%- JSON.stringify({
+ '@context':'https://schema.org','@type':'Organization',
+ name: 'National Paper Hangers',
+ url: _orgBase,
+ sameAs: [],
+ description: 'Verified luxury wallcovering installers across the United States. Concierge matching, scheduling, and project oversight.'
+}) %></script>
+<script type="application/ld+json"><%- JSON.stringify({
+ '@context':'https://schema.org','@type':'WebSite',
+ name: 'National Paper Hangers',
+ url: _orgBase,
+ potentialAction: {
+ '@type':'SearchAction',
+ target: _orgBase + '/find?q={search_term_string}',
+ 'query-input':'required name=search_term_string'
+ }
+}) %></script>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-bilingue.ejs b/views/public/installer-tpl-bilingue.ejs
new file mode 100644
index 0000000..3cfc3b7
--- /dev/null
+++ b/views/public/installer-tpl-bilingue.ejs
@@ -0,0 +1,107 @@
+<%- include('../partials/head', { title, bodyClass: 'tpl-bilingue' }) %>
+<%- include('../partials/header') %>
+<%
+ function _heroUrl() {
+ var s = installer.template_settings || {};
+ if (s.hero_url) return s.hero_url;
+ if (portfolio.length && portfolio[0].image_url) return portfolio[0].image_url;
+ var seg = (typeof pickSegmentImage === 'function') ? pickSegmentImage(installer) : null;
+ return seg ? seg.file : null;
+ }
+ var heroUrl = _heroUrl();
+ var s = installer.template_settings || {};
+ var esBio = s.bio_es || null; // optional pre-translated ES bio (manual or AI)
+ var esHeadline = s.headline_es || null;
+%>
+<article class="tpl-root">
+ <div class="bl-langtoggle" role="tablist" aria-label="Language">
+ <button type="button" class="active" data-set-lang="en" role="tab">EN</button>
+ <button type="button" data-set-lang="es" role="tab">ES</button>
+ </div>
+
+ <div class="bl-hero">
+ <% if (heroUrl) { %><img class="bl-hero-img" src="<%= heroUrl %>" alt="<%= installer.business_name %>"><% } %>
+ <div>
+ <p data-lang="en" style="color:#b85c2b;font-weight:700;letter-spacing:0.2em;text-transform:uppercase;font-size:12px">Wallcovering Studio</p>
+ <p data-lang="es" style="color:#b85c2b;font-weight:700;letter-spacing:0.2em;text-transform:uppercase;font-size:12px">Estudio de Empapelado</p>
+ <h1 class="bl-name"><%= installer.business_name %></h1>
+ <p class="bl-place"><%= [installer.city, installer.state].filter(Boolean).join(' · ') %><% if (installer.founded_year) { %> · <span data-lang="en">Est.</span><span data-lang="es">Desde</span> <%= installer.founded_year %><% } %></p>
+ <% if (installer.headline) { %>
+ <p class="bl-headline" data-lang="en"><%= installer.headline %></p>
+ <p class="bl-headline" data-lang="es"><%= esHeadline || installer.headline %></p>
+ <% } %>
+ <a class="bl-cta" href="/installer/<%= installer.slug %>/book"><span data-lang="en">Book a consultation</span><span data-lang="es">Solicitar consulta</span></a>
+ </div>
+ </div>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="tpl-claim-cta" style="color:#b85c2b;border-color:#b85c2b;background:#fff;border-radius:16px">
+ <div style="flex:1">
+ <span data-lang="en">If you operate <%= installer.business_name %>, claim this listing to enable bookings.</span>
+ <span data-lang="es">Si usted opera <%= installer.business_name %>, reclame este listado para habilitar reservas.</span>
+ </div>
+ <a href="/installer/<%= installer.slug %>/claim" style="background:#b85c2b;color:#fff7ec;padding:10px 22px;border-radius:999px;text-decoration:none;font-weight:700"><span data-lang="en">Claim →</span><span data-lang="es">Reclamar →</span></a>
+ </div>
+ <% } %>
+
+ <div class="bl-stats">
+ <div><div class="num"><%= installer.service_radius_miles || '—' %></div><div class="lbl"><span data-lang="en">Service radius (mi)</span><span data-lang="es">Radio de servicio (mi)</span></div></div>
+ <div><div class="num"><%= installer.team_size || '—' %></div><div class="lbl"><span data-lang="en">Team</span><span data-lang="es">Equipo</span></div></div>
+ <div><div class="num"><%= installer.response_time_hours ? '<' + installer.response_time_hours + 'h' : '—' %></div><div class="lbl"><span data-lang="en">Response</span><span data-lang="es">Respuesta</span></div></div>
+ <div><div class="num"><%= acceptance ? acceptance.rate + '%' : (credentials || []).length %></div><div class="lbl"><%= acceptance ? '<span data-lang="en">Accept rate</span><span data-lang="es">Aceptación</span>' : '<span data-lang="en">Brand-trained</span><span data-lang="es">Certificaciones</span>' %></div></div>
+ </div>
+
+ <section class="bl-section">
+ <div class="bl-callout">
+ <span data-lang="en">Specialist in <%= (installer.materials || ['wallcoverings']).slice(0,3).map(function(m){return m.replace(/_/g,' ');}).join(', ') %> installations.</span>
+ <span data-lang="es">Especialista en instalaciones de <%= (installer.materials || ['empapelado']).slice(0,3).map(function(m){return m.replace(/_/g,' ');}).join(', ') %>.</span>
+ </div>
+ <h2><span data-lang="en">About the studio</span><span data-lang="es">Sobre el estudio</span></h2>
+ <% if (installer.bio) { %>
+ <p class="bl-bio" data-lang="en"><%= installer.bio %></p>
+ <p class="bl-bio" data-lang="es"><%= esBio || installer.bio %></p>
+ <% } %>
+ </section>
+
+ <% if (portfolio.length) { %>
+ <section class="bl-section">
+ <h2><span data-lang="en">Recent projects</span><span data-lang="es">Proyectos recientes</span></h2>
+ <div class="bl-pf-grid">
+ <% portfolio.slice(0,9).forEach(function(p){ %>
+ <article class="bl-pf-card">
+ <% if (p.image_url) { %><img src="<%= p.image_url %>" alt="<%= p.title %>" loading="lazy"><% } %>
+ <div class="meta"><h3><%= p.title %></h3><p><%= [p.city, p.state, p.year].filter(Boolean).join(' · ') %><% if (p.brand) { %> · <%= p.brand %><% } %></p></div>
+ </article>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <section class="bl-cta-final">
+ <h2><span data-lang="en">Ready to book <%= installer.business_name %>?</span><span data-lang="es">¿Listo para contratar a <%= installer.business_name %>?</span></h2>
+ <a href="/installer/<%= installer.slug %>/book"><span data-lang="en">Book a consultation</span><span data-lang="es">Solicitar consulta</span></a>
+ </section>
+</article>
+<script>
+ (function(){
+ var body = document.body;
+ var saved = null;
+ try { saved = localStorage.getItem('nph-lang'); } catch (e) {}
+ if (saved === 'es') { body.classList.add('lang-es'); }
+ document.querySelectorAll('[data-set-lang]').forEach(function(b){
+ b.addEventListener('click', function(){
+ var lang = b.getAttribute('data-set-lang');
+ body.classList.toggle('lang-es', lang === 'es');
+ document.querySelectorAll('[data-set-lang]').forEach(function(x){ x.classList.toggle('active', x === b); });
+ try { localStorage.setItem('nph-lang', lang); } catch (e) {}
+ });
+ });
+ if (saved === 'es') {
+ var btn = document.querySelector('[data-set-lang="es"]');
+ var btnEn = document.querySelector('[data-set-lang="en"]');
+ if (btn) btn.classList.add('active');
+ if (btnEn) btnEn.classList.remove('active');
+ }
+ })();
+</script>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-concierge.ejs b/views/public/installer-tpl-concierge.ejs
new file mode 100644
index 0000000..2877dff
--- /dev/null
+++ b/views/public/installer-tpl-concierge.ejs
@@ -0,0 +1,62 @@
+<%- include('../partials/head', { title, bodyClass: 'tpl-concierge' }) %>
+<%- include('../partials/header') %>
+<%
+ function _heroUrl() {
+ var s = installer.template_settings || {};
+ if (s.hero_url) return s.hero_url;
+ if (portfolio.length && portfolio[0].image_url) return portfolio[0].image_url;
+ var seg = (typeof pickSegmentImage === 'function') ? pickSegmentImage(installer) : null;
+ return seg ? seg.file : null;
+ }
+ var heroUrl = _heroUrl();
+ var founded = installer.founded_year || null;
+%>
+<article class="tpl-root">
+ <p class="cc-eyebrow">National Paper Hangers</p>
+ <div class="cc-rule"></div>
+ <h1 class="cc-name"><%= installer.business_name %></h1>
+ <p class="cc-place"><%= [installer.city, installer.state].filter(Boolean).join(' · ') %><% if (founded) { %> · Est. <%= founded %><% } %></p>
+
+ <% if (installer.headline) { %><p class="cc-line">"<%= installer.headline %>"</p><% } %>
+
+ <% if (heroUrl) { %><img class="cc-hero" src="<%= heroUrl %>" alt="<%= installer.business_name %>"><% } %>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="tpl-claim-cta" style="color:#c8a96a;border-color:#c8a96a;text-align:left;font-family:Inter,sans-serif;font-size:13px">
+ <div style="flex:1">If you operate <%= installer.business_name %>, claim this listing to enable bookings, the Verified mark, and reply tracking.</div>
+ <a href="/installer/<%= installer.slug %>/claim" style="color:#c8a96a;text-decoration:underline">Claim →</a>
+ </div>
+ <% } %>
+
+ <% if (installer.bio) { %><div class="cc-bio"><%= installer.bio %></div><% } %>
+
+ <div class="cc-strip">
+ <div>Service radius<b><%= installer.service_radius_miles || '—' %> mi</b></div>
+ <% if (installer.team_size) { %><div>Team<b><%= installer.team_size %></b></div><% } %>
+ <% if (acceptance) { %><div>Accept rate<b><%= acceptance.rate %>%</b></div><% } %>
+ <% if ((credentials || []).length) { %><div>Brand-trained<b><%= credentials.length %></b></div><% } %>
+ </div>
+
+ <% if (portfolio.length) { %>
+ <div class="cc-portfolio">
+ <% portfolio.slice(0,4).forEach(function(p){ %>
+ <figure>
+ <% if (p.image_url) { %><img class="tpl-portfolio-img" src="<%= p.image_url %>" alt="<%= p.title %>" loading="lazy" style="filter:grayscale(0.1) contrast(1.05)"><% } %>
+ <figcaption><%= p.title %><% if (p.brand) { %> · <%= p.brand %><% } %></figcaption>
+ </figure>
+ <% }); %>
+ </div>
+ <% } %>
+
+ <% if ((credentials || []).length) { %>
+ <p class="cc-eyebrow" style="margin-top:0">Brand-trained</p>
+ <p style="font-family:Inter,sans-serif;font-size:13px;letter-spacing:0.15em;text-transform:uppercase;color:#aaa097;margin:0 0 32px">
+ <% credentials.slice(0,8).forEach(function(c, i){ %><%= i ? ' · ' : '' %><%= c.brand %><% }); %>
+ </p>
+ <% } %>
+
+ <div class="cc-cta">
+ <a href="/installer/<%= installer.slug %>/book">Reserve a consultation</a>
+ </div>
+</article>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-editorial.ejs b/views/public/installer-tpl-editorial.ejs
new file mode 100644
index 0000000..cfb426e
--- /dev/null
+++ b/views/public/installer-tpl-editorial.ejs
@@ -0,0 +1,84 @@
+<%- include('../partials/head', { title, bodyClass: 'tpl-editorial' }) %>
+<%- include('../partials/header') %>
+<%
+ function _heroUrl() {
+ var s = installer.template_settings || {};
+ if (s.hero_url) return s.hero_url;
+ if (portfolio.length && portfolio[0].image_url) return portfolio[0].image_url;
+ var seg = (typeof pickSegmentImage === 'function') ? pickSegmentImage(installer) : null;
+ return seg ? seg.file : null;
+ }
+ var heroUrl = _heroUrl();
+ var founded = installer.founded_year ? ('Est. ' + installer.founded_year) : null;
+%>
+<article class="tpl-root">
+ <p class="ed-kicker"><%= [installer.city, installer.state].filter(Boolean).join(' · ') %><% if (founded) { %> · <%= founded %><% } %></p>
+ <h1 class="ed-title"><%= installer.business_name %></h1>
+ <% if (installer.headline) { %><p class="ed-deck"><%= installer.headline %></p><% } %>
+
+ <% if (heroUrl) { %><img class="ed-hero" src="<%= heroUrl %>" alt="<%= installer.business_name %> — feature project" loading="eager"><% } %>
+ <p class="ed-hero-cap"><% if (portfolio[0] && portfolio[0].title) { %>Above: <%= portfolio[0].title %><% if (portfolio[0].brand) { %> · <%= portfolio[0].brand %><% } %><% } else { %>Studio profile · National Paper Hangers<% } %></p>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="tpl-claim-cta" style="color:#8c1d1d">
+ <div style="flex:1">Trade buyers are finding this studio. If you operate <%= installer.business_name %>, claim your listing in 2 minutes.</div>
+ <a href="/installer/<%= installer.slug %>/claim" class="btn-ed" style="padding:10px 22px">Claim listing →</a>
+ </div>
+ <% } %>
+
+ <div class="ed-body">
+ <div class="ed-prose">
+ <% if (installer.bio) { %><p><%= installer.bio %></p><% } else { %><p><em>This studio has not yet shared a long-form profile. Their work below speaks first.</em></p><% } %>
+ <% if ((installer.materials || []).length) { %>
+ <h2>Materials & methods</h2>
+ <p><%= installer.business_name %> works in <%= installer.materials.map(function(m){return m.replace(/_/g,' ');}).join(', ') %><% if ((installer.brands_handled||[]).length) { %>, with installations of <%= installer.brands_handled.slice(0,5).join(', ') %><% } %>.</p>
+ <% } %>
+ <% if ((installer.market_segments || []).length) { %>
+ <h2>Project mix</h2>
+ <p>Active in <%= installer.market_segments.map(function(s){return s.replace(/_/g,' ');}).join(', ') %><% if (installer.travel_available) { %>; available to travel for projects<% } else if (installer.service_radius_miles) { %>; <%= installer.service_radius_miles %>-mile service radius from <%= installer.city || 'home base' %><% } %>.</p>
+ <% } %>
+ </div>
+ <aside class="ed-side">
+ <h3>At a glance</h3>
+ <dl>
+ <% if (installer.team_size) { %><dt>Team</dt><dd><%= installer.team_size %></dd><% } %>
+ <dt>Radius</dt><dd><%= installer.service_radius_miles || '—' %> mi</dd>
+ <% if (installer.response_time_hours) { %><dt>Replies in</dt><dd>< <%= installer.response_time_hours %>h</dd><% } %>
+ <% if (installer.insurance_on_file) { %><dt>Insured</dt><dd>Yes</dd><% } %>
+ <% if (acceptance) { %><dt>Books</dt><dd><%= acceptance.rate %>% of inquiries</dd><% } %>
+ </dl>
+ <% if ((credentials || []).length) { %>
+ <h3>Credentials</h3>
+ <dl>
+ <% credentials.slice(0,8).forEach(function(c){ %>
+ <dt><%= c.brand %></dt><dd><%= c.credential_type.replace(/_/g,' ') %><% if (c.year_issued) { %> · <%= c.year_issued %><% } %></dd>
+ <% }); %>
+ </dl>
+ <% } %>
+ <% if ((installer.accreditations || []).length) { %>
+ <h3>Memberships</h3>
+ <p style="margin:0"><%= installer.accreditations.join(' · ') %></p>
+ <% } %>
+ </aside>
+ </div>
+
+ <% if (portfolio.length) { %>
+ <section class="ed-portfolio">
+ <h2>Selected projects</h2>
+ <div class="ed-pf-grid">
+ <% portfolio.slice(0,9).forEach(function(p){ %>
+ <figure class="ed-pf-card">
+ <% if (p.image_url) { %><img class="tpl-portfolio-img" src="<%= p.image_url %>" alt="<%= p.title %>" loading="lazy"><% } %>
+ <figcaption><strong><%= p.title %></strong><br><%= [p.city, p.state, p.year].filter(Boolean).join(' · ') %><% if (p.brand) { %> · <%= p.brand %><% } %></figcaption>
+ </figure>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <section class="ed-cta">
+ <h2>Book <%= installer.business_name %></h2>
+ <a class="btn-ed" href="/installer/<%= installer.slug %>/book">Open the calendar →</a>
+ </section>
+</article>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-heritage.ejs b/views/public/installer-tpl-heritage.ejs
new file mode 100644
index 0000000..f503061
--- /dev/null
+++ b/views/public/installer-tpl-heritage.ejs
@@ -0,0 +1,90 @@
+<%- include('../partials/head', { title, bodyClass: 'tpl-heritage' }) %>
+<%- include('../partials/header') %>
+<%
+ function _heroUrl() {
+ var s = installer.template_settings || {};
+ if (s.hero_url) return s.hero_url;
+ if (portfolio.length && portfolio[0].image_url) return portfolio[0].image_url;
+ var seg = (typeof pickSegmentImage === 'function') ? pickSegmentImage(installer) : null;
+ return seg ? seg.file : null;
+ }
+ var heroUrl = _heroUrl();
+%>
+<article class="tpl-root">
+ <% if (installer.founded_year) { %>
+ <div class="hr-emblem-wrap">
+ <div class="hr-emblem">
+ <div class="hr-est">Established</div>
+ <div class="hr-year"><%= installer.founded_year %></div>
+ <div class="hr-est"><%= [installer.city, installer.state].filter(Boolean).join(' · ') %></div>
+ </div>
+ </div>
+ <% } %>
+
+ <h1 class="hr-name"><%= installer.business_name %></h1>
+ <p class="hr-place"><% if (installer.team_size) { %><%= installer.team_size %>-person studio · <% } %><%= [installer.city, installer.state].filter(Boolean).join(' · ') %></p>
+
+ <% if (heroUrl) { %><img class="hr-hero" src="<%= heroUrl %>" alt="<%= installer.business_name %>"><% } %>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="tpl-claim-cta" style="color:#6b4a1f;border-color:#6b4a1f;font-family:Inter,sans-serif">
+ <div style="flex:1">If this is your studio, claim the listing to update history, photos, and credentials.</div>
+ <a href="/installer/<%= installer.slug %>/claim" style="background:#6b4a1f;color:#f7f0e2;padding:10px 22px;text-decoration:none;font-weight:600">Claim →</a>
+ </div>
+ <% } %>
+
+ <div class="hr-body">
+ <div class="hr-prose">
+ <% if (installer.bio) { %><p><%= installer.bio %></p><% } else { %><p><em>Profile not yet completed. The studio's work below speaks first.</em></p><% } %>
+ <% if ((installer.brands_handled || []).length) { %>
+ <p style="margin-top:24px"><em>Wallcoverings installed include works from <%= installer.brands_handled.slice(0,8).join(', ') %><% if (installer.brands_handled.length > 8) { %>, among others<% } %>.</em></p>
+ <% } %>
+ </div>
+ <aside class="hr-side">
+ <h3>Studio</h3>
+ <ul>
+ <% if (installer.founded_year) { %><li>Founded<span><%= installer.founded_year %></span></li><% } %>
+ <% if (installer.team_size) { %><li>Crew<span><%= installer.team_size %></span></li><% } %>
+ <li>Radius<span><%= installer.service_radius_miles || '—' %> mi</span></li>
+ <% if (installer.travel_available) { %><li>Travel<span>Yes</span></li><% } %>
+ <% if (installer.insurance_on_file) { %><li>Insured<span>On file</span></li><% } %>
+ <% if (acceptance) { %><li>Books<span><%= acceptance.rate %>% of inquiries</span></li><% } %>
+ </ul>
+ <% if ((credentials || []).length) { %>
+ <h3>Credentials</h3>
+ <ul>
+ <% credentials.slice(0,8).forEach(function(c){ %>
+ <li><%= c.brand %><span><%= c.year_issued || c.credential_type.replace(/_/g,' ') %></span></li>
+ <% }); %>
+ </ul>
+ <% } %>
+ <% if ((installer.accreditations || []).length) { %>
+ <h3>Memberships</h3>
+ <ul>
+ <% installer.accreditations.slice(0,6).forEach(function(a){ %><li><%= a %><span></span></li><% }); %>
+ </ul>
+ <% } %>
+ </aside>
+ </div>
+
+ <% if (portfolio.length) { %>
+ <section class="hr-portfolio">
+ <h2>Selected works</h2>
+ <p class="hr-portfolio-sub">A small archive of recent installations</p>
+ <div class="hr-pf-grid">
+ <% portfolio.slice(0,9).forEach(function(p){ %>
+ <figure class="hr-pf-card">
+ <% if (p.image_url) { %><img class="tpl-portfolio-img" src="<%= p.image_url %>" alt="<%= p.title %>" loading="lazy"><% } %>
+ <figcaption><strong><%= p.title %></strong><br><em><%= [p.city, p.state, p.year].filter(Boolean).join(' · ') %><% if (p.brand) { %> · <%= p.brand %><% } %></em></figcaption>
+ </figure>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <section class="hr-cta">
+ <h2>Commission a project</h2>
+ <a href="/installer/<%= installer.slug %>/book">Open the consultation calendar</a>
+ </section>
+</article>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-studio.ejs b/views/public/installer-tpl-studio.ejs
new file mode 100644
index 0000000..c02e9fe
--- /dev/null
+++ b/views/public/installer-tpl-studio.ejs
@@ -0,0 +1,90 @@
+<%- include('../partials/head', { title, bodyClass: 'tpl-studio' }) %>
+<%- include('../partials/header') %>
+<%
+ function _heroUrl() {
+ var s = installer.template_settings || {};
+ if (s.hero_url) return s.hero_url;
+ if (portfolio.length && portfolio[0].image_url) return portfolio[0].image_url;
+ var seg = (typeof pickSegmentImage === 'function') ? pickSegmentImage(installer) : null;
+ return seg ? seg.file : null;
+ }
+ var heroUrl = _heroUrl();
+%>
+<article class="tpl-root">
+ <header class="st-hero">
+ <div class="st-hero-text">
+ <span class="st-pill"><%= installer.claim_status === 'claimed' ? 'Verified studio' : 'Directory listing' %></span>
+ <h1 class="st-name"><%= installer.business_name %></h1>
+ <p class="st-headline"><%= installer.headline || (installer.bio || '').split(/\.\s/)[0] %></p>
+ <div class="st-actions">
+ <a class="primary" href="/installer/<%= installer.slug %>/book">Book a consultation</a>
+ <% if (installer.website) { %><a class="ghost" href="<%= installer.website %>" target="_blank" rel="noopener">Studio site ↗</a><% } %>
+ <% if (installer.instagram_handle) { %><a class="ghost" href="https://instagram.com/<%= encodeURIComponent(installer.instagram_handle) %>" target="_blank" rel="noopener">@<%= installer.instagram_handle %></a><% } %>
+ </div>
+ </div>
+ <div class="st-hero-img" style="background-image:url('<%= heroUrl || '' %>')"></div>
+ </header>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="tpl-claim-cta" style="color:#0a0a0a;border-color:#d4d2c8;margin:0 64px 32px;border-radius:16px;background:#fff8e6">
+ <div style="flex:1">If this is your studio, claim it in 2 minutes to enable bookings and the verified badge.</div>
+ <a href="/installer/<%= installer.slug %>/claim" style="background:#0a0a0a;color:#fff;padding:10px 20px;border-radius:10px;text-decoration:none;font-weight:600;font-size:14px">Claim listing</a>
+ </div>
+ <% } %>
+
+ <div class="st-strip">
+ <div><div class="num"><%= installer.service_radius_miles || '—' %></div><div class="lbl">Service radius (mi)</div></div>
+ <div><div class="num"><%= installer.team_size || '—' %></div><div class="lbl">Team</div></div>
+ <div><div class="num"><%= installer.response_time_hours ? '<' + installer.response_time_hours + 'h' : '—' %></div><div class="lbl">Reply SLA</div></div>
+ <div><div class="num"><%= acceptance ? acceptance.rate + '%' : ((credentials||[]).length ? credentials.length : '—') %></div><div class="lbl"><%= acceptance ? 'Accept rate' : 'Brand-trained' %></div></div>
+ </div>
+
+ <section class="st-section">
+ <div class="st-bio">
+ <h2>About</h2>
+ <div>
+ <p><%= installer.bio || 'Profile not yet completed by the studio.' %></p>
+ <% if ((installer.materials || []).length) { %>
+ <p style="margin-top:18px;color:#666;font-size:14px"><strong>Materials</strong> — <%= installer.materials.map(function(m){return m.replace(/_/g,' ');}).join(', ') %></p>
+ <% } %>
+ <% if ((installer.brands_handled || []).length) { %>
+ <p style="margin-top:6px;color:#666;font-size:14px"><strong>Brands installed</strong> — <%= installer.brands_handled.join(', ') %></p>
+ <% } %>
+ </div>
+ </div>
+ </section>
+
+ <% if (portfolio.length) { %>
+ <section class="st-section">
+ <h2>Selected projects</h2>
+ <div class="st-tiles">
+ <% portfolio.slice(0,9).forEach(function(p){ %>
+ <article class="st-tile">
+ <% if (p.image_url) { %><img src="<%= p.image_url %>" alt="<%= p.title %>" loading="lazy"><% } %>
+ <div class="st-tile-meta">
+ <h3><%= p.title %></h3>
+ <p><%= [p.city, p.state, p.year].filter(Boolean).join(' · ') %><% if (p.brand) { %> · <%= p.brand %><% } %></p>
+ </div>
+ </article>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <% if ((credentials || []).length) { %>
+ <section class="st-section" style="padding-top:0">
+ <h2>Brand-trained</h2>
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
+ <% credentials.forEach(function(c){ %>
+ <span style="background:#efece5;border-radius:999px;padding:8px 16px;font-size:14px;font-weight:600"><%= c.brand %><% if (c.year_issued) { %> · <%= c.year_issued %><% } %></span>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <section class="st-cta">
+ <h2>Bring <%= installer.business_name %> on the next project</h2>
+ <a href="/installer/<%= installer.slug %>/book">Book a consultation</a>
+ </section>
+</article>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-trade-pro.ejs b/views/public/installer-tpl-trade-pro.ejs
new file mode 100644
index 0000000..c87bf33
--- /dev/null
+++ b/views/public/installer-tpl-trade-pro.ejs
@@ -0,0 +1,94 @@
+<%- include('../partials/head', { title, bodyClass: 'tpl-trade-pro' }) %>
+<%- include('../partials/header') %>
+<%
+ function _heroUrl() {
+ var s = installer.template_settings || {};
+ if (s.hero_url) return s.hero_url;
+ if (portfolio.length && portfolio[0].image_url) return portfolio[0].image_url;
+ var seg = (typeof pickSegmentImage === 'function') ? pickSegmentImage(installer) : null;
+ return seg ? seg.file : null;
+ }
+ var heroUrl = _heroUrl();
+ var eq = installer.equipment || {};
+ var founded = installer.founded_year || '—';
+ var jobNo = 'NPH-' + ((installer.id || '0').toString()).padStart(5, '0');
+%>
+<article class="tpl-root">
+ <div class="tp-banner">
+ <span>Studio file · <%= jobNo %></span>
+ <span><b>STATUS</b> <%= installer.claim_status === 'claimed' ? 'VERIFIED · ACCEPTING WORK' : 'DIRECTORY LISTING — UNCLAIMED' %></span>
+ </div>
+
+ <header class="tp-headerblock">
+ <div class="tp-job-no">FILE / <%= jobNo %> / OPENED <%= founded %></div>
+ <h1 class="tp-name"><%= installer.business_name %></h1>
+ <div class="tp-meta">
+ <%= [installer.city, installer.state, installer.zip].filter(Boolean).join(' · ') %><br>
+ Service radius: <%= installer.service_radius_miles || '—' %> mi<%= installer.travel_available ? ' · Travels for projects' : '' %><br>
+ <% if (installer.team_size) { %>Crew: <%= installer.team_size %> installer(s)<br><% } %>
+ <% if (installer.response_time_hours) { %>SLA: replies < <%= installer.response_time_hours %> hr<br><% } %>
+ </div>
+ </header>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="tpl-claim-cta" style="color:#14110b;background:#ffeebf;border-color:#14110b">
+ <div style="flex:1;font-family:Inter,sans-serif"><strong>This studio has not yet claimed its file.</strong> If you operate <%= installer.business_name %>, claim it to update specs, accept inbound work, and verify credentials.</div>
+ <a href="/installer/<%= installer.slug %>/claim" style="background:#14110b;color:#ffaa00;font-family:Inter,sans-serif;padding:10px 18px;text-decoration:none;font-weight:700">Claim file →</a>
+ </div>
+ <% } %>
+
+ <div class="tp-grid">
+ <div class="tp-bio">
+ <h2>About the shop</h2>
+ <p><%= installer.bio || 'No long-form bio on file. Specs and project log below.' %></p>
+ <% if ((installer.brands_handled || []).length) { %>
+ <h2 style="margin-top:24px">Brands installed</h2>
+ <p style="font-size:14px"><%= installer.brands_handled.join(' · ') %></p>
+ <% } %>
+ <% if ((installer.accreditations || []).length) { %>
+ <h2 style="margin-top:24px">Memberships</h2>
+ <p style="font-size:14px"><%= installer.accreditations.join(' · ') %></p>
+ <% } %>
+ </div>
+
+ <div class="tp-specs">
+ <h3>Studio capacity</h3>
+ <table>
+ <tr><th>Reach</th><td><%= eq.max_reach_ft ? eq.max_reach_ft + ' ft' : '—' %></td></tr>
+ <tr><th>Lift</th><td><%= eq.lift_type ? eq.lift_type.replace(/_/g,' ') : '—' %></td></tr>
+ <tr><th>Pasting</th><td><%= eq.paper_table ? eq.paper_table.replace(/_/g,' ') : '—' %></td></tr>
+ <tr><th>Vehicle</th><td><%= eq.vehicle ? eq.vehicle.replace(/_/g,' ') : '—' %></td></tr>
+ <tr><th>HEPA dust</th><td><%= eq.dust_extraction ? 'Yes — on-site' : '—' %></td></tr>
+ <tr><th>Materials</th><td><%= (installer.materials || []).map(function(m){return m.replace(/_/g,' ');}).join(', ') || '—' %></td></tr>
+ <tr><th>Segments</th><td><%= (installer.market_segments || []).map(function(s){return s.replace(/_/g,' ');}).join(', ') || '—' %></td></tr>
+ <% if ((credentials || []).length) { %>
+ <tr><th>Credentials</th><td><% credentials.slice(0,5).forEach(function(c, i){ %><%= i ? ' · ' : '' %><%= c.brand %><% }); %></td></tr>
+ <% } %>
+ <% if (acceptance) { %><tr><th>Accept rate</th><td><%= acceptance.rate %>% (<%= acceptance.accepted %>/<%= acceptance.total %>)</td></tr><% } %>
+ </table>
+ </div>
+ </div>
+
+ <% if (heroUrl) { %>
+ <div class="tp-detail-row">
+ <h2>Project log</h2>
+ <p>Featured installs. Tabs surface seam, corner, and ceiling shots — where craft shows.</p>
+ <div class="tp-detail-grid">
+ <% var _items = portfolio.length ? portfolio.slice(0,8) : [{title:'Featured project', image_url: heroUrl, city:installer.city, state:installer.state, year:installer.founded_year}]; %>
+ <% _items.forEach(function(p, i){ %>
+ <article class="tp-detail-card">
+ <div class="label"><span><%= String(i+1).padStart(2,'0') %> · <%= [p.city, p.state, p.year].filter(Boolean).join(' · ') %></span><span class="stamp"><%= (p.brand || 'INSTALL').toUpperCase().slice(0,12) %></span></div>
+ <% if (p.image_url) { %><img class="tpl-portfolio-img" src="<%= p.image_url %>" alt="<%= p.title %>" loading="lazy"><% } %>
+ <div style="padding:12px 16px;font-family:Inter,sans-serif;font-size:13px"><strong><%= p.title %></strong><% if (p.material) { %> · <%= p.material.replace(/_/g,' ') %><% } %></div>
+ </article>
+ <% }); %>
+ </div>
+ </div>
+ <% } %>
+
+ <section class="tp-cta">
+ <p style="margin:0 0 18px;letter-spacing:0.15em;text-transform:uppercase;font-size:13px">Specifying with <%= installer.business_name %>?</p>
+ <a href="/installer/<%= installer.slug %>/book">Open the work calendar →</a>
+ </section>
+</article>
+<%- include('../partials/footer') %>
diff --git a/views/public/installer.ejs b/views/public/installer.ejs
new file mode 100644
index 0000000..ac82d2a
--- /dev/null
+++ b/views/public/installer.ejs
@@ -0,0 +1,276 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+<%
+ function safeWebsite(u) {
+ if (!u) return null;
+ try { var x = new URL(u); return (x.protocol === 'http:' || x.protocol === 'https:') ? x.toString() : null; }
+ catch (e) { return null; }
+ }
+ var safeSiteUrl = safeWebsite(installer.website);
+%>
+
+<article class="installer-profile">
+ <%
+ var _calendarTier = ['pro','signature','enterprise'].indexOf(installer.tier) !== -1;
+ var _selfBookable = _calendarTier && installer.subscription_status === 'active';
+ var _depositCents = parseInt(process.env.NPH_DEFAULT_DEPOSIT_CENTS || '9900', 10);
+ var _depositLabel = '$' + (_depositCents / 100).toFixed(0);
+ var _feeBps = parseInt(process.env.NPH_PLATFORM_FEE_BPS || '1000', 10);
+ var _feePct = Math.round(_feeBps / 100);
+ %>
+ <% if (_selfBookable) { %>
+ <aside class="reserve-banner" role="region" aria-label="Reserve a visit">
+ <div class="reserve-banner-inner">
+ <div class="reserve-copy">
+ <p class="reserve-kicker">Reserve a visit · <%= _depositLabel %> deposit</p>
+ <p class="reserve-body">Pay through NPH — your share goes straight to <%= installer.business_name %>. NPH retains a <%= _feePct %>% marketplace fee.</p>
+ </div>
+ <a href="/installer/<%= installer.slug %>/book" class="btn btn-primary btn-lg reserve-cta">Book a consultation →</a>
+ </div>
+ </aside>
+ <% } %>
+ <% var _heroImg = (portfolio.length === 0) ? pickSegmentImage(installer) : null; %>
+ <% if (_heroImg) {
+ var _attr = imageAttribution(_heroImg.file);
+ var _firstSeg = (installer.market_segments && installer.market_segments[0]) || 'interior';
+ %>
+ <div class="installer-hero-image">
+ <img src="<%= _heroImg.file %>" alt="Wallcovering installation, <%= _firstSeg.replace(/_/g,' ') %>" loading="eager" decoding="async">
+ <% if (_attr) { %>
+ <a class="photo-credit" href="<%= _attr.source_url %>" target="_blank" rel="noopener">
+ Photo<% if (_attr.creator) { %>: <%= _attr.creator.slice(0, 60) %><% } %> · <%= _attr.license %> · <%= _attr.source %>
+ </a>
+ <% } %>
+ </div>
+ <% } %>
+ <header class="profile-hero">
+ <div class="profile-hero-inner">
+ <p class="kicker"><%= installer.city %>, <%= installer.state %> · est. <%= installer.founded_year || '—' %></p>
+ <h1 class="display-sm"><%= installer.business_name %></h1>
+ <% if (installer.headline) { %><p class="profile-headline"><%= installer.headline %></p><% } %>
+ <div class="profile-badges">
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <span class="directory-badge" title="Public directory listing — studio has not yet claimed.">Directory listing</span>
+ <% } %>
+ <% if (installer.verified) { %><span class="verified-badge">Verified</span><% } %>
+ <% if (installer.insurance_on_file) { %><span class="badge">Insurance on file</span><% } %>
+ <% if (installer.travel_available) { %><span class="badge">Travels for projects</span><% } %>
+ <% if (installer.tier === 'signature' || installer.tier === 'enterprise') { %><span class="signature-badge">Signature</span><% } %>
+ <% if (installer.claim_status !== 'unclaimed') { %><span class="badge">Responds in < <%= installer.response_time_hours %>h</span><% } %>
+ </div>
+
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <div class="callout" style="margin:20px 0">
+ <strong>Trade buyers are finding this studio on NPH.</strong>
+ If you operate <%= installer.business_name %>, claim your listing in 2 minutes to enable direct bookings, the Verified badge, and reply-tracking on inbound leads.
+ <a href="/installer/<%= installer.slug %>/claim" class="btn btn-primary btn-sm" style="margin-left:8px">Claim this listing</a>
+ <p class="muted" style="margin:8px 0 0;font-size:13px">Seeded from <%= installer.source_name === 'wia' ? 'the WIA public directory' : 'a public source' %>. <a href="mailto:info@nationalpaperhangers.com?subject=Opt-out%3A%20<%= encodeURIComponent(installer.business_name) %>">Request removal →</a></p>
+ </div>
+ <% } %>
+
+ <div class="profile-actions">
+ <% if (installer.claim_status === 'unclaimed') { %>
+ <% if (safeSiteUrl) { %><a href="<%= safeSiteUrl %>" target="_blank" rel="noopener" class="btn btn-primary btn-lg">Visit studio site ↗</a><% } %>
+ <% if (installer.instagram_handle) { %><a href="https://instagram.com/<%= encodeURIComponent(installer.instagram_handle) %>" target="_blank" rel="noopener" class="btn btn-ghost btn-lg">@<%= installer.instagram_handle %></a><% } %>
+ <% } else { %>
+ <a href="/installer/<%= installer.slug %>/book" class="btn btn-primary btn-lg">Book a consultation</a>
+ <% if (safeSiteUrl) { %><a href="<%= safeSiteUrl %>" target="_blank" rel="noopener" class="btn btn-ghost">Visit studio site ↗</a><% } %>
+ <% if (installer.instagram_handle) { %><a href="https://instagram.com/<%= encodeURIComponent(installer.instagram_handle) %>" target="_blank" rel="noopener" class="btn btn-ghost">@<%= installer.instagram_handle %></a><% } %>
+ <% } %>
+ </div>
+ </div>
+ </header>
+
+ <div class="profile-body">
+ <section class="profile-bio">
+ <h2 class="section-title">About the studio</h2>
+ <p><%= installer.bio %></p>
+ </section>
+
+ <aside class="profile-sidebar">
+ <h3>At a glance</h3>
+ <dl>
+ <dt>Service radius</dt>
+ <dd><%= installer.service_radius_miles %> miles<%= installer.travel_available ? ' · travels for projects' : '' %></dd>
+ <% if (installer.team_size) { %>
+ <dt>Team</dt><dd><%= installer.team_size %> installer<%= installer.team_size === 1 ? '' : 's' %></dd>
+ <% } %>
+ <% if ((installer.market_segments || []).length) { %>
+ <dt>Segments</dt><dd><%= installer.market_segments.map(s => s.replace(/_/g,' ')).join(', ') %></dd>
+ <% } %>
+ <% if ((installer.materials || []).length) { %>
+ <dt>Materials</dt><dd><%= installer.materials.map(s => s.replace(/_/g,' ')).join(', ') %></dd>
+ <% } %>
+ <% if ((installer.brands_handled || []).length) { %>
+ <dt>Brands installed</dt><dd><%= installer.brands_handled.join(', ') %></dd>
+ <% } %>
+ <% if ((installer.accreditations || []).length) { %>
+ <dt>Accreditations</dt><dd><%= installer.accreditations.join(', ') %></dd>
+ <% } %>
+ </dl>
+
+ <%
+ var eq = installer.equipment;
+ var hasEq = eq && (eq.max_reach_ft || eq.lift_type || eq.paper_table || eq.vehicle || eq.dust_extraction || eq.notes);
+ var liftLabel = {
+ extension_ladder: 'Extension ladder',
+ scaffold: 'Scaffold',
+ scissor_lift: 'Scissor lift',
+ boom_lift: 'Boom lift'
+ };
+ var tableLabel = {
+ none: 'No dedicated table',
+ folding: 'Folding table',
+ dedicated_60: 'Dedicated 60" pasting table',
+ dedicated_72_plus: 'Dedicated 72"+ pasting table'
+ };
+ var vehicleLabel = { van: 'Van', box_truck: 'Box truck', trailer: 'Trailer-equipped' };
+ %>
+ <% if (hasEq) { %>
+ <h3 style="margin-top:24px">Studio capacity</h3>
+ <dl>
+ <% if (eq.max_reach_ft) { %><dt>Reach</dt><dd>Up to <%= eq.max_reach_ft %> ft</dd><% } %>
+ <% if (eq.lift_type) { %><dt>Lift</dt><dd><%= liftLabel[eq.lift_type] || eq.lift_type %></dd><% } %>
+ <% if (eq.paper_table) { %><dt>Pasting</dt><dd><%= tableLabel[eq.paper_table] || eq.paper_table %></dd><% } %>
+ <% if (eq.vehicle) { %><dt>Vehicle</dt><dd><%= vehicleLabel[eq.vehicle] || eq.vehicle %></dd><% } %>
+ <% if (eq.dust_extraction) { %><dt>Dust</dt><dd>HEPA extraction on-site</dd><% } %>
+ <% if (eq.notes) { %><dt>Notes</dt><dd><%= eq.notes %></dd><% } %>
+ </dl>
+ <% } %>
+
+ <% if (typeof credentials !== 'undefined' && credentials && credentials.length) {
+ var _credLabel = {
+ brand_trained: 'trained',
+ brand_certified: 'certified',
+ brand_approved: 'approved',
+ manufacturer_partner: 'partner',
+ trade_member: 'member'
+ };
+ %>
+ <h3 style="margin-top:24px">Brand credentials</h3>
+ <ul class="credential-list" style="list-style:none;padding:0;margin:0">
+ <% credentials.forEach(function(c){ %>
+ <li style="padding:8px 0;border-bottom:1px solid var(--border)">
+ <strong><%= c.brand %></strong>
+ <span class="muted">· <%= _credLabel[c.credential_type] || c.credential_type %><% if (c.year_issued) { %> <%= c.year_issued %><% } %></span>
+ <% if (c.certificate_url) { %>
+ <a href="<%= c.certificate_url %>" target="_blank" rel="noopener" style="font-size:12px;margin-left:6px">cert ↗</a>
+ <% } %>
+ </li>
+ <% }); %>
+ </ul>
+ <% } %>
+
+ <% if (typeof acceptance !== 'undefined' && acceptance) { %>
+ <h3 style="margin-top:24px">Selectivity</h3>
+ <p style="margin:0">
+ <strong>Accepts <%= acceptance.rate %>%</strong> of inquiries
+ <span class="muted">(<%= acceptance.accepted %> taken / <%= acceptance.declined %> declined, last 12 mo)</span>
+ </p>
+ <p class="muted" style="font-size:12px;margin:6px 0 0">A studio booked solid enough to be selective.</p>
+ <% } %>
+ </aside>
+ </div>
+
+ <% if (portfolio.length > 0) { %>
+ <section class="profile-portfolio">
+ <h2 class="section-title">Selected projects</h2>
+ <p class="muted" style="margin:-12px 0 18px;font-size:13px">Tap <em>Seams</em>, <em>Corners</em>, or <em>Ceiling</em> on any project to see craft detail — where the difference shows.</p>
+ <div class="portfolio-grid">
+ <% portfolio.forEach(function(p, idx){
+ var shots = [
+ { key: 'hero', label: 'Project', url: p.image_url },
+ { key: 'seam', label: 'Seams', url: p.detail_seam_url },
+ { key: 'corner', label: 'Corners', url: p.detail_corner_url },
+ { key: 'ceiling', label: 'Ceiling', url: p.detail_ceiling_url }
+ ].filter(s => s.url);
+ %>
+ <article class="portfolio-card portfolio-card-tabbed" data-portfolio-card="<%= idx %>">
+ <div class="portfolio-image-wrap">
+ <% if (shots.length === 0) { %>
+ <span class="portfolio-image-placeholder"><%= (p.brand || '').slice(0,2).toUpperCase() %></span>
+ <% } else { %>
+ <% shots.forEach(function(s, i){ %>
+ <img class="portfolio-shot <%= i === 0 ? 'is-active' : '' %>"
+ data-shot="<%= s.key %>"
+ src="<%= s.url %>"
+ alt="<%= p.title %> — <%= s.label %>"
+ loading="lazy">
+ <% }); %>
+ <% } %>
+ </div>
+ <% if (shots.length > 1) { %>
+ <nav class="portfolio-tabs" aria-label="Project detail">
+ <% shots.forEach(function(s, i){ %>
+ <button type="button"
+ class="portfolio-tab <%= i === 0 ? 'is-active' : '' %>"
+ data-shot-target="<%= s.key %>"
+ data-card="<%= idx %>"><%= s.label %></button>
+ <% }); %>
+ </nav>
+ <% } %>
+ <h3><%= p.title %></h3>
+ <p class="portfolio-meta">
+ <%= [p.city, p.state, p.year].filter(Boolean).join(' · ') %>
+ <% if (p.brand) { %> · <%= p.brand %><% } %>
+ </p>
+ <% if (p.description) { %><p class="portfolio-desc"><%= p.description %></p><% } %>
+ </article>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <% if (reviews.length > 0) { %>
+ <section class="profile-reviews">
+ <h2 class="section-title">Verified reviews</h2>
+ <div class="reviews-grid">
+ <% reviews.forEach(function(r){ %>
+ <article class="review-card">
+ <div class="review-rating"><%= '★'.repeat(r.rating) %><%= '☆'.repeat(5 - r.rating) %></div>
+ <% if (r.title) { %><h4><%= r.title %></h4><% } %>
+ <p><%= r.body %></p>
+ <p class="review-author"><%= r.customer_name || 'Verified client' %><% if (r.verified) { %> · Verified<% } %></p>
+ </article>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
+ <section class="profile-cta">
+ <h2 class="display-sm">Ready to book <%= installer.business_name %>?</h2>
+ <a href="/installer/<%= installer.slug %>/book" class="btn btn-primary btn-lg">Open the calendar</a>
+ </section>
+
+ <%
+ var _ldBase = (typeof publicUrl !== 'undefined' && publicUrl) ? publicUrl.replace(/\/+$/, '') : 'https://www.nationalpaperhangers.com';
+ var _ld = {
+ '@context': 'https://schema.org',
+ '@type': 'LocalBusiness',
+ name: installer.business_name,
+ description: (installer.bio || '').slice(0, 500),
+ url: _ldBase + '/installer/' + installer.slug,
+ address: {
+ '@type': 'PostalAddress',
+ addressLocality: installer.city || undefined,
+ addressRegion: installer.state || undefined,
+ postalCode: installer.zip || undefined,
+ addressCountry: installer.country || 'US'
+ },
+ knowsAbout: (installer.materials || []).map(function(m){ return m.replace(/_/g,' '); }),
+ hasCredential: (installer.accreditations || [])
+ };
+ if (safeSiteUrl) _ld.sameAs = [safeSiteUrl];
+ %>
+ <script type="application/ld+json"><%- JSON.stringify(_ld) %></script>
+ <script type="application/ld+json"><%- JSON.stringify({
+ '@context':'https://schema.org','@type':'BreadcrumbList',
+ itemListElement: [
+ { '@type':'ListItem', position:1, name:'Find an installer', item: _ldBase + '/find' },
+ { '@type':'ListItem', position:2, name: installer.business_name, item: _ldBase + '/installer/' + installer.slug }
+ ]
+ }) %></script>
+</article>
+
+<%- include('../partials/footer') %>
+<script src="/js/portfolio-tabs.js" defer></script>
diff --git a/views/public/legal.ejs b/views/public/legal.ejs
new file mode 100644
index 0000000..d52b909
--- /dev/null
+++ b/views/public/legal.ejs
@@ -0,0 +1,18 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <% if (kind === 'privacy') { %>
+ <p class="kicker">Privacy</p>
+ <h1 class="display-sm">Privacy policy</h1>
+ <p>This is a placeholder privacy policy. Before launch, replace with the final policy reviewed by counsel — it must cover personal data we collect from consumers booking installs, business data we hold on installers, retention windows, and California / EU/UK rights where applicable.</p>
+ <p>Contact: <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a></p>
+ <% } else { %>
+ <p class="kicker">Terms</p>
+ <h1 class="display-sm">Terms of service</h1>
+ <p>This is a placeholder terms-of-service page. Final terms — installer subscription terms, consumer booking terms, cancellation/refund policy, dispute resolution, and platform liability disclaimers — must be reviewed by counsel before launch.</p>
+ <p>Contact: <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a></p>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/map.ejs b/views/public/map.ejs
new file mode 100644
index 0000000..2a9c12c
--- /dev/null
+++ b/views/public/map.ejs
@@ -0,0 +1,214 @@
+<%- include('../partials/head', { title }) %>
+<link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
+<link rel="stylesheet" href="/vendor/markercluster/MarkerCluster.css">
+<link rel="stylesheet" href="/vendor/markercluster/MarkerCluster.Default.css">
+<style>
+ .map-page { display: grid; grid-template-rows: auto 1fr; min-height: calc(100vh - 64px); }
+ .map-head {
+ display: flex; align-items: center; gap: 1rem;
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--border, #2a2a2a);
+ background: var(--surface, #111);
+ flex-wrap: wrap;
+ }
+ .map-head h1 { margin: 0; font-family: 'Cormorant Garamond', serif; font-weight: 500; font-size: 1.5rem; }
+ .map-head .map-stats { color: var(--muted, #999); font-size: 0.85rem; }
+ .map-head .map-controls { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
+ .map-head input, .map-head select {
+ padding: 0.45rem 0.75rem; border-radius: 4px;
+ border: 1px solid var(--border, #2a2a2a);
+ background: var(--bg, #0e0e0e); color: var(--text, #eee);
+ font-family: inherit; font-size: 0.9rem;
+ }
+ #map { width: 100%; height: 100%; min-height: 540px; background: #0e0e0e; }
+ .pin-popup { font-family: 'Inter', sans-serif; min-width: 220px; }
+ .pin-popup h3 {
+ margin: 0 0 0.25rem; font-family: 'Cormorant Garamond', serif;
+ font-weight: 500; font-size: 1.1rem; color: #0e0e0e;
+ }
+ .pin-popup .pin-loc { color: #555; font-size: 0.82rem; margin: 0 0 0.5rem; }
+ .pin-popup .pin-badges { display: flex; gap: 0.3rem; flex-wrap: wrap; margin: 0.5rem 0; }
+ .pin-popup .badge {
+ display: inline-block; padding: 0.15rem 0.45rem;
+ font-size: 0.7rem; letter-spacing: 0.04em; text-transform: uppercase;
+ border-radius: 3px; background: #f3f3f3; color: #333;
+ }
+ .pin-popup .badge.tier-signature { background: #1a1a1a; color: #fff; }
+ .pin-popup .badge.tier-pro { background: #6b4f2c; color: #fff; }
+ .pin-popup .badge.verified { background: #1f5e3a; color: #fff; }
+ .pin-popup .badge.unclaimed { background: #b8860b; color: #fff; }
+ .pin-popup .badge.brand-trained { background: #2a3550; color: #fff; }
+ .pin-popup .pin-actions { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
+ .pin-popup .pin-actions a {
+ flex: 1; text-align: center;
+ padding: 0.4rem 0.5rem; font-size: 0.8rem; font-weight: 500;
+ border-radius: 4px; text-decoration: none;
+ }
+ .pin-popup .pin-actions a.primary { background: #0e0e0e; color: #fff; }
+ .pin-popup .pin-actions a.ghost { background: transparent; color: #0e0e0e; border: 1px solid #ccc; }
+ .pin-popup .pin-thumb { width: 100%; aspect-ratio: 4/3; overflow: hidden; margin: -0.75rem -0.9rem 0.6rem; }
+ .pin-popup .pin-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .leaflet-popup-content { margin: 0.75rem 0.9rem; }
+ .pin-marker { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
+ .pin-marker.signature { background: #d4a847; }
+ .pin-marker.pro { background: #6b4f2c; }
+ .pin-marker.basic { background: #555; }
+ .pin-marker.unclaimed { background: #999; opacity: 0.85; }
+</style>
+
+<%- include('../partials/header') %>
+
+<section class="map-page">
+ <div class="map-head">
+ <div>
+ <h1>Find an installer on the map</h1>
+ <p class="map-stats">
+ <%= stats.pinned %> of <%= stats.total %> studios pinned · Click a pin to view the profile, request a quote, or book a visit
+ </p>
+ </div>
+ <div class="map-controls">
+ <input type="text" id="map-search" placeholder="City, ZIP, brand, business name" autocomplete="off">
+ <select id="map-segment">
+ <option value="">All segments</option>
+ <option value="luxury_residential">Luxury residential</option>
+ <option value="hospitality">Hospitality</option>
+ <option value="retail">Retail</option>
+ <option value="museum">Museum</option>
+ <option value="yacht">Yacht</option>
+ </select>
+ <select id="map-material">
+ <option value="">All materials</option>
+ <option value="hand_painted">Hand-painted</option>
+ <option value="silk">Silk</option>
+ <option value="grasscloth">Grasscloth</option>
+ <option value="mural">Mural</option>
+ <option value="vinyl">Vinyl</option>
+ <option value="metallic_leaf">Metallic leaf</option>
+ </select>
+ </div>
+ </div>
+ <div id="map" role="application" aria-label="Map of installer studios"></div>
+</section>
+
+<%- include('../partials/footer') %>
+
+<script src="/vendor/leaflet/leaflet.js"></script>
+<script src="/vendor/markercluster/leaflet.markercluster.js"></script>
+<script>
+(function(){
+ var US_CENTER = [39.5, -98.35];
+ var map = L.map('map', { worldCopyJump: true }).setView(US_CENTER, 4);
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+ }).addTo(map);
+
+ var cluster = L.markerClusterGroup({
+ showCoverageOnHover: false,
+ spiderfyOnMaxZoom: true,
+ chunkedLoading: true,
+ maxClusterRadius: 60
+ });
+ map.addLayer(cluster);
+
+ var allMarkers = [];
+
+ function pinClass(i){
+ if (i.claim_status === 'unclaimed') return 'unclaimed';
+ if (i.tier === 'signature' || i.tier === 'enterprise') return 'signature';
+ if (i.tier === 'pro') return 'pro';
+ return 'basic';
+ }
+
+ function pinIcon(i){
+ var cls = pinClass(i);
+ return L.divIcon({
+ className: '',
+ html: '<div class="pin-marker '+cls+'"></div>',
+ iconSize: [14,14], iconAnchor: [7,7]
+ });
+ }
+
+ function popupHtml(i){
+ var loc = [i.city, i.state].filter(Boolean).join(', ');
+ var badges = '';
+ if (i.tier === 'signature' || i.tier === 'enterprise')
+ badges += '<span class="badge tier-signature">Signature</span>';
+ else if (i.tier === 'pro')
+ badges += '<span class="badge tier-pro">Pro</span>';
+ if (i.verified) badges += '<span class="badge verified">Verified</span>';
+ if (i.claim_status === 'unclaimed') badges += '<span class="badge unclaimed">Unclaimed</span>';
+ if (i.verified_brands_count > 0) {
+ var bn = (i.verified_brands || []).slice(0,2).join(', ');
+ var more = i.verified_brands_count > 2 ? ' +' + (i.verified_brands_count - 2) : '';
+ badges += '<span class="badge brand-trained" title="Brand-trained credentials verified by NPH">✦ ' + escapeHtml(bn + more) + '</span>';
+ }
+ var thumb = i.thumb
+ ? '<div class="pin-thumb"><img src="'+escapeHtml(i.thumb)+'" alt="" loading="lazy" decoding="async"></div>'
+ : '';
+ return '<div class="pin-popup">' +
+ thumb +
+ '<h3>'+escapeHtml(i.business_name)+'</h3>' +
+ '<p class="pin-loc">'+escapeHtml(loc)+'</p>' +
+ (badges ? '<div class="pin-badges">'+badges+'</div>' : '') +
+ '<div class="pin-actions">' +
+ '<a class="primary" href="/installer/'+encodeURIComponent(i.slug)+'">View profile</a>' +
+ (i.claim_status === 'unclaimed'
+ ? '<a class="ghost" href="/installer/'+encodeURIComponent(i.slug)+'#claim">Claim</a>'
+ : '<a class="ghost" href="/installer/'+encodeURIComponent(i.slug)+'/book">Book visit</a>') +
+ '</div>' +
+ '</div>';
+ }
+
+ function escapeHtml(s){
+ return String(s||'').replace(/[&<>"']/g, function(c){
+ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
+ });
+ }
+
+ function applyFilters(){
+ var q = (document.getElementById('map-search').value || '').toLowerCase().trim();
+ var seg = document.getElementById('map-segment').value;
+ var mat = document.getElementById('map-material').value;
+ cluster.clearLayers();
+ var n = 0;
+ allMarkers.forEach(function(rec){
+ var i = rec.data;
+ if (q) {
+ var hay = (i.business_name + ' ' + i.city + ' ' + i.state + ' ' + (i.materials||[]).join(' ') + ' ' + (i.market_segments||[]).join(' ')).toLowerCase();
+ if (hay.indexOf(q) === -1) return;
+ }
+ if (seg && (!i.market_segments || i.market_segments.indexOf(seg) === -1)) return;
+ if (mat && (!i.materials || i.materials.indexOf(mat) === -1)) return;
+ cluster.addLayer(rec.marker);
+ n++;
+ });
+ var statsEl = document.querySelector('.map-stats');
+ if (statsEl) statsEl.textContent = n + ' studios match · click a pin for details';
+ }
+
+ fetch('/api/installers.geo')
+ .then(function(r){ return r.json(); })
+ .then(function(data){
+ (data.installers || []).forEach(function(i){
+ if (typeof i.lat !== 'number' || typeof i.lng !== 'number') return;
+ var m = L.marker([i.lat, i.lng], { icon: pinIcon(i) });
+ m.bindPopup(popupHtml(i));
+ cluster.addLayer(m);
+ allMarkers.push({ data: i, marker: m });
+ });
+ if (allMarkers.length) {
+ var bounds = L.latLngBounds(allMarkers.map(function(r){ return r.marker.getLatLng(); }));
+ map.fitBounds(bounds, { padding: [40,40], maxZoom: 7 });
+ }
+ ['map-search','map-segment','map-material'].forEach(function(id){
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener('input', applyFilters);
+ el.addEventListener('change', applyFilters);
+ });
+ })
+ .catch(function(e){ console.error('[map] load failed', e); });
+})();
+</script>
diff --git a/views/public/unsubscribed.ejs b/views/public/unsubscribed.ejs
new file mode 100644
index 0000000..c971ee7
--- /dev/null
+++ b/views/public/unsubscribed.ejs
@@ -0,0 +1,20 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="auth-page">
+ <div class="auth-card auth-card-wide">
+ <p class="kicker">Unsubscribed</p>
+ <h1 class="display-sm">You're off the list.</h1>
+ <p class="lede">
+ <strong><%= identifier %></strong> has been removed from
+ <%= campaign ? campaign.replace(/_/g, ' ') : 'National Paper Hangers' %> outreach.
+ You won't hear from us at this address again.
+ </p>
+ <p class="muted">
+ If this was a mistake, email <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a> and we'll re-enable.
+ </p>
+ <p style="margin-top:24px"><a href="/" class="btn btn-ghost">National Paper Hangers home →</a></p>
+ </div>
+</section>
+
+<%- include('../partials/footer') %>
(oldest)
·
back to NationalPaperHangers
·
post-launch v0.2 design fixes — sticky reserve banner, drop 4606c77 →