[object Object]

← back to Small Business Builder

yolo: baseline before overnight

ff56b97a2f7a86ac675f22dd76eca4f700438ed1 · 2026-05-03 23:46:26 -0700 · local

Files touched

Diff

commit ff56b97a2f7a86ac675f22dd76eca4f700438ed1
Author: local <local@local>
Date:   Sun May 3 23:46:26 2026 -0700

    yolo: baseline before overnight
---
 .env.example                       |    4 +
 .gitignore                         |    7 +
 README.md                          |   41 +
 ecosystem.config.cjs               |   22 +
 migrations/001_init.sql            |  114 +++
 package-lock.json                  | 1481 ++++++++++++++++++++++++++++++++++++
 package.json                       |   26 +
 seed-data/README.md                |  118 +++
 seed-data/la-salons.json           | 1159 ++++++++++++++++++++++++++++
 src/lib/db.js                      |   33 +
 src/lib/escape.js                  |   11 +
 src/lib/log.js                     |   12 +
 src/lib/slug.js                    |    9 +
 src/render/edit.js                 |  144 ++++
 src/render/grid.js                 |  229 ++++++
 src/render/templates/_helpers.js   |   59 ++
 src/render/templates/index.js      |   17 +
 src/render/templates/template-1.js |  291 +++++++
 src/render/templates/template-2.js |  312 ++++++++
 src/render/templates/template-3.js |   47 ++
 src/render/templates/template-4.js |   31 +
 src/render/templates/template-5.js |   54 ++
 src/scrape/instagram_public.js     |  110 +++
 src/scrape/website_meta.js         |  123 +++
 src/server/index.js                |  287 +++++++
 25 files changed, 4741 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..94f66c0
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+DATABASE_URL=postgres://stevestudio2@localhost:5432/small_business_directory
+PORT=9760
+NODE_ENV=development
+ADMIN_TOKEN=change-me-please
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..62657e1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+logs/
+.env
+.env.local
+*.log
+tmp/
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..99bc47f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# small-business-builder
+
+Template-driven landing-page builder for small businesses.
+First vertical: LA salons / barbershops / nail salons. Generic for any small business after that.
+
+- Viewer + admin: `http://127.0.0.1:9760`
+- DB: standalone PG `small_business_directory`
+- 5 design templates per business
+- 3 pricing tiers: free / plus $19 / premium $79
+
+## Quick start
+
+```bash
+createdb small_business_directory
+psql -d small_business_directory -f migrations/001_init.sql
+npm install
+npm run server
+```
+
+Test in another tab:
+
+```bash
+curl -s http://127.0.0.1:9760/healthz
+curl -s -X POST http://127.0.0.1:9760/api/scrape-website \
+  -H 'content-type: application/json' \
+  -d '{"url":"https://www.rudysbarbershop.com","category":"barbershop"}' | jq
+```
+
+Then open `http://127.0.0.1:9760/` in a browser to see the grid populate.
+
+## pm2
+
+```bash
+pm2 start ecosystem.config.cjs && pm2 save
+```
+
+## Watchdog
+
+```bash
+launchctl load ~/Library/LaunchAgents/com.steve.smb-builder-hawk.plist
+```
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..bbe4810
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,22 @@
+/**
+ * small-business-builder — pm2 master config
+ *
+ *   pm2 start ecosystem.config.cjs && pm2 save
+ *
+ * Ports:
+ *   9760  smb-builder    public Express viewer + admin + scrape APIs
+ */
+module.exports = {
+  apps: [
+    {
+      name: 'smb-builder',
+      script: 'src/server/index.js',
+      cwd: __dirname,
+      env: { NODE_ENV: 'production', PORT: 9760 },
+      max_memory_restart: '512M',
+      autorestart: true,
+      out_file: 'logs/smb-builder.out.log',
+      error_file: 'logs/smb-builder.err.log',
+    },
+  ],
+};
diff --git a/migrations/001_init.sql b/migrations/001_init.sql
new file mode 100644
index 0000000..6afdcd5
--- /dev/null
+++ b/migrations/001_init.sql
@@ -0,0 +1,114 @@
+-- small-business-builder — initial schema
+-- DB: small_business_directory  (standalone, NOT dw_unified)
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS businesses (
+  id BIGSERIAL PRIMARY KEY,
+  slug TEXT UNIQUE,
+  name TEXT NOT NULL,
+  category TEXT, -- salon, barbershop, nail_salon, generic
+  address TEXT,
+  city TEXT,
+  state TEXT,
+  zip TEXT,
+  latitude NUMERIC,
+  longitude NUMERIC,
+  phone TEXT,
+  email TEXT,
+  website TEXT,
+  years_in_business INT,
+  owner_name TEXT,
+  hero_image_url TEXT,
+  color_primary TEXT,
+  color_accent TEXT,
+  about_text TEXT,
+  hours_json JSONB,
+  tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free','plus','premium')),
+  ranking_score NUMERIC,
+  source_data_json JSONB,
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_businesses_slug ON businesses(slug);
+CREATE INDEX IF NOT EXISTS idx_businesses_category ON businesses(category);
+CREATE INDEX IF NOT EXISTS idx_businesses_city_state ON businesses(city, state);
+CREATE INDEX IF NOT EXISTS idx_businesses_tier ON businesses(tier);
+
+CREATE TABLE IF NOT EXISTS business_socials (
+  id BIGSERIAL PRIMARY KEY,
+  business_id BIGINT REFERENCES businesses(id) ON DELETE CASCADE,
+  platform TEXT, -- instagram, tiktok, twitter, linkedin, facebook, youtube
+  handle TEXT,
+  url TEXT,
+  follower_count INT,
+  last_post_at TIMESTAMPTZ,
+  raw_json JSONB,
+  scraped_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_socials_business ON business_socials(business_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_socials_business_platform
+  ON business_socials(business_id, platform);
+
+CREATE TABLE IF NOT EXISTS business_inputs (
+  -- free-form structured input from owner: "anything clients should know" + arbitrary key-value
+  id BIGSERIAL PRIMARY KEY,
+  business_id BIGINT REFERENCES businesses(id) ON DELETE CASCADE,
+  field_key TEXT NOT NULL,
+  field_value TEXT,
+  created_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_inputs_business ON business_inputs(business_id);
+
+CREATE TABLE IF NOT EXISTS business_mockups (
+  id BIGSERIAL PRIMARY KEY,
+  business_id BIGINT REFERENCES businesses(id) ON DELETE CASCADE,
+  template_id INT, -- 1..5
+  preview_url TEXT,
+  full_html TEXT,
+  generated_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_mockups_business ON business_mockups(business_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mockups_business_template
+  ON business_mockups(business_id, template_id);
+
+CREATE TABLE IF NOT EXISTS tiers (
+  id SERIAL PRIMARY KEY,
+  code TEXT UNIQUE,
+  name TEXT,
+  monthly_usd NUMERIC,
+  features_json JSONB
+);
+
+INSERT INTO tiers (code, name, monthly_usd, features_json) VALUES
+  ('free', 'Free Listing', 0,
+    '["directory listing", "name + address + hours + phone", "1 hero image"]'),
+  ('plus', 'Plus', 19,
+    '["full website (5 templates)", "custom domain via Cloudflare", "lead form", "Stripe payments", "no builder branding"]'),
+  ('premium', 'Premium', 79,
+    '["everything in Plus", "booking/calendar integration", "SEO autosubmission", "social-post auto-scheduler", "monthly performance email"]')
+ON CONFLICT (code) DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS leads (
+  id BIGSERIAL PRIMARY KEY,
+  business_id BIGINT REFERENCES businesses(id),
+  client_email TEXT,
+  client_phone TEXT,
+  message TEXT,
+  status TEXT DEFAULT 'new',
+  created_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_leads_business ON leads(business_id);
+CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
+
+-- updated_at trigger
+CREATE OR REPLACE FUNCTION smb_set_updated_at() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_businesses_updated_at ON businesses;
+CREATE TRIGGER trg_businesses_updated_at
+  BEFORE UPDATE ON businesses
+  FOR EACH ROW EXECUTE FUNCTION smb_set_updated_at();
+
+COMMIT;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6392cd5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1481 @@
+{
+  "name": "small-business-builder",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "small-business-builder",
+      "version": "0.1.0",
+      "license": "UNLICENSED",
+      "dependencies": {
+        "cheerio": "^1.0.0",
+        "compression": "^1.8.1",
+        "cookie-parser": "^1.4.7",
+        "dotenv": "^16.4.7",
+        "express": "^4.21.2",
+        "multer": "^2.1.1",
+        "pg": "^8.13.1",
+        "undici": "^7.2.0"
+      }
+    },
+    "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/accepts/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/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/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/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/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/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/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "license": "ISC"
+    },
+    "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/cheerio": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+      "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+      "license": "MIT",
+      "dependencies": {
+        "cheerio-select": "^2.1.0",
+        "dom-serializer": "^2.0.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "encoding-sniffer": "^0.2.1",
+        "htmlparser2": "^10.1.0",
+        "parse5": "^7.3.0",
+        "parse5-htmlparser2-tree-adapter": "^7.1.0",
+        "parse5-parser-stream": "^7.1.2",
+        "undici": "^7.19.0",
+        "whatwg-mimetype": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=20.18.1"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+      }
+    },
+    "node_modules/cheerio-select": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+      "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-select": "^5.1.0",
+        "css-what": "^6.1.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/compressible": {
+      "version": "2.0.18",
+      "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+      "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": ">= 1.43.0 < 2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/compression": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+      "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "compressible": "~2.0.18",
+        "debug": "2.6.9",
+        "negotiator": "~0.6.4",
+        "on-headers": "~1.1.0",
+        "safe-buffer": "5.2.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "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/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-parser": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+      "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "0.7.2",
+        "cookie-signature": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+      "license": "MIT"
+    },
+    "node_modules/css-select": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+      "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-what": "^6.1.0",
+        "domhandler": "^5.0.2",
+        "domutils": "^3.0.1",
+        "nth-check": "^2.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/css-what": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+      "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "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/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/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=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/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/encoding-sniffer": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+      "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "^0.6.3",
+        "whatwg-encoding": "^3.1.1"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "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/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/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/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/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/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/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/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-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/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/htmlparser2": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+      "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "entities": "^7.0.1"
+      }
+    },
+    "node_modules/htmlparser2/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "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/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "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/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/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.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "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/mime-types/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/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.4",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+      "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "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/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-htmlparser2-tree-adapter": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+      "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+      "license": "MIT",
+      "dependencies": {
+        "domhandler": "^5.0.3",
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-parser-stream": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+      "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+      "license": "MIT",
+      "dependencies": {
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=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-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/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/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/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/raw-body/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/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/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/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/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/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/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/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/undici": {
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+      "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "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/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "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"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..154c0ba
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+  "name": "small-business-builder",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Template-driven landing-page builder for small businesses. First vertical: LA salons / barbershops / nail salons. 5 templates per business, scrape existing website + Instagram for initial data, three pricing tiers (free / plus $19 / premium $79).",
+  "license": "UNLICENSED",
+  "type": "module",
+  "scripts": {
+    "migrate": "psql -d small_business_directory -f migrations/001_init.sql",
+    "server": "node src/server/index.js",
+    "dev": "node --watch src/server/index.js",
+    "pm2:start": "pm2 start ecosystem.config.cjs && pm2 save",
+    "pm2:stop": "pm2 stop ecosystem.config.cjs",
+    "pm2:logs": "pm2 logs smb-builder"
+  },
+  "dependencies": {
+    "cheerio": "^1.0.0",
+    "compression": "^1.8.1",
+    "cookie-parser": "^1.4.7",
+    "dotenv": "^16.4.7",
+    "express": "^4.21.2",
+    "multer": "^2.1.1",
+    "pg": "^8.13.1",
+    "undici": "^7.2.0"
+  }
+}
diff --git a/seed-data/README.md b/seed-data/README.md
new file mode 100644
index 0000000..898601a
--- /dev/null
+++ b/seed-data/README.md
@@ -0,0 +1,118 @@
+# LA-Area Salons Dataset for Small Business Builder
+
+## Overview
+This dataset contains 41 high-quality salon, barbershop, and spa businesses across the Los Angeles area, curated for the small-business-builder outreach pipeline. The data covers Beverly Hills, West Hollywood, Santa Monica, Venice, Silver Lake, Los Feliz, Hollywood, DTLA, Culver City, and surrounding neighborhoods.
+
+## Data Collection Methodology
+
+### Primary Search Queries Used
+1. "best hair salons Beverly Hills Los Angeles"
+2. "top rated barbershops West Hollywood DTLA"
+3. "highest rated nail salons Santa Monica Venice California"
+4. "luxury spa salons Silver Lake Los Feliz Hollywood"
+5. "brow lash salon studio city culver city Los Angeles"
+6. Targeted searches per salon name (e.g., "Cristophe Salon Beverly Hills 35 years")
+
+### Data Sources (All Public)
+- **Yelp**: Review counts, star ratings, business hours, verified addresses, phone numbers
+- **Official Websites**: Business info, services, hours, contact details
+- **Instagram**: Follower counts, handles, verification of social presence
+- **Google Maps**: Address verification, map links
+- **Business Directories**: Fresha, BestProsInTown, Chamber of Commerce listings
+- **News/Articles**: Historical context (e.g., Manley's Barber "since 1959", Hiroshi Hair "since 1978")
+
+### Information Per Entry
+- **Core**: Name, address, city, neighborhood, ZIP, website, phone
+- **Social**: Instagram handle/URL/followers, TikTok, LinkedIn, Twitter (when available)
+- **Reviews**: Yelp URL, review count, star rating
+- **History**: Years in business (from founding date or "since X" statements on websites)
+- **Ranking Score**: Composite metric (see formula below)
+
+## Ranking Score Formula
+
+```
+ranking_score = 0.40 × normalized(yelp_reviews, 0-1000)
+              + 0.30 × normalized(instagram_followers, 0-100k)
+              + 0.15 × min(years_in_business, 30) / 30
+              + 0.10 × (has_website ? 1.0 : 0.0)
+              + 0.05 × (has_yelp ? 1.0 : 0.0)
+```
+
+**Why this weighting?**
+- **Yelp reviews (40%)**: Strong proxy for business maturity and customer satisfaction; local businesses live & die by Yelp in LA
+- **Instagram followers (30%)**: Direct indicator of marketing reach and customer engagement; salons with large followings are likely doing active social/content marketing
+- **Years in business (15%)**: Stability signal; longer-standing businesses have proven staying power
+- **Website (10%)**: Basic digital infrastructure; not having a website is a red flag
+- **Yelp presence (5%)**: Online discoverability
+
+**Normalization approach:**
+- Yelp: capped at 1,000 reviews (anything above = 1.0)
+- Instagram: capped at 100k followers (anything above = 1.0)
+- Years: capped at 30 years (anything above = 1.0)
+
+### Top 10 by Ranking Score
+
+| Rank | Name | Category | Neighborhood | Score |
+|------|------|----------|--------------|-------|
+| 1 | Blind Barber (Culver City) | Barbershop | Culver City | 0.88 |
+| 2 | Fellow Barber (Silver Lake) | Barbershop | Silver Lake | 0.85 |
+| 3 | Bolt Barbers | Barbershop | DTLA | 0.84 |
+| 4 | Cristophe Salon Beverly Hills | Salon | Beverly Hills | 0.84 |
+| 5 | Gamine Beauty | Salon | Silver Lake | 0.83 |
+| 6 | Nelson J Salon Beverly Hills | Salon | Beverly Hills | 0.81 |
+| 7 | Rock Paper Salon | Salon | Silver Lake | 0.81 |
+| 8 | House of AANUKO | Barbershop | Culver City | 0.80 |
+| 9 | The NOW Massage (Silver Lake) | Spa | Silver Lake | 0.78 |
+| 10 | Chris McMillan (The Salon) | Salon | Beverly Hills | 0.79 |
+
+## Data Quality Notes
+
+### Completeness
+- **41 entries total** (41% coverage of 100-entry goal; see gaps below)
+- **38 with full website + phone**: 93% completeness on core contact data
+- **35 with Instagram handles**: 85% social media capture
+- **36 with Yelp links**: 88% review platform presence
+
+### What We Didn't Capture
+- **LinkedIn owner pages**: Most salon owners don't maintain public LinkedIn profiles; omitted where not discoverable
+- **TikTok**: Only 2 salons in final dataset had active TikTok (salon social is Instagram-first in LA)
+- **Twitter**: Nearly 0% adoption in this vertical
+- **Website traffic proxies**: No access to Analytics; Yelp reviews are best available proxy
+- **Exact founding dates**: Many salons state "since 19XX" but not all; "years_in_business" is rounded where based on published text
+
+### Excluded Salons
+- **Yelp blocks WebFetch**: Direct Yelp search pages return 403; used search API + business pages instead
+- **DTLA Cuts**: Listed as CLOSED (March 2026) — included with warning flag
+- **Multiple incomplete entries**: Mèche Salon, Bicolor Nail Boutique, Nailtique — insufficient address/phone; kept with lower scores as candidates
+
+## Named Gaps & Recommendations
+
+### Gap 1: Studio City / Sherman Oaks Coverage
+**Status**: Minimal. Found references to Lash & Brow LA (now closed per Yelp), but no active, highly-rated alternatives with full contact data.
+**Recommendation**: Direct outreach to Studio City barber/salon directories; this is a high-income neighborhood likely underrepresented.
+
+### Gap 2: Complete Lash + Brow Specialization
+**Status**: Only 2 entries (The Brow Fixx, Belris). Brow/lash is fast-growing in LA but most businesses are one-off indie practitioners on Sola Salons, not standalone storefronts.
+**Recommendation**: Expand outreach to Sola Salons franchisees (shared salon space models); these are high-volume but harder to rank individually.
+
+### Gap 3: Luxury Spa Penetration (Echo Park, Mid-City)
+**Status**: Weighted heavily toward Silver Lake/Los Feliz spas. Echo Park and Mid-Wilshire lack high-review-count entries.
+**Recommendation**: Search "luxury spa [neighborhood]" + check Fresha/ClassPass listings; many use booking platforms instead of standalone websites.
+
+## Usage in Small Business Builder
+
+This dataset is ordered by `ranking_score` (descending). Suggested outreach tiers:
+
+1. **Tier 1 (0.80+)**: 10 salons — highest confidence, proven marketing maturity
+2. **Tier 2 (0.70-0.79)**: 15 salons — solid candidates, established presence
+3. **Tier 3 (0.60-0.69)**: 10 salons — emerging, opportunity to be early adopter
+4. **Tier 4 (<0.60)**: 6 salons — incomplete data, but real businesses; verify before outreach
+
+---
+
+**Generated**: 2026-05-02  
+**Research Hours**: ~4 hours (WebSearch + WebFetch, manual validation)  
+**Next Steps**: 
+- Validate phone numbers via direct call (sample n=5 from each tier)
+- Check for site-builder competitors already serving these businesses
+- Pilot outreach to Tier 2 (proven demand, lower churn risk)
diff --git a/seed-data/la-salons.json b/seed-data/la-salons.json
new file mode 100644
index 0000000..d9ea25b
--- /dev/null
+++ b/seed-data/la-salons.json
@@ -0,0 +1,1159 @@
+{
+  "metadata": {
+    "total_entries": 62,
+    "generated_at": "2026-05-02T23:45:00Z",
+    "source": "Web research + public business data (Yelp, Instagram, Google Maps)",
+    "coverage": "Beverly Hills, West Hollywood, Santa Monica, Venice, Silver Lake, Los Feliz, Hollywood, DTLA, Culver City",
+    "categories": ["salon", "barbershop", "nail", "brow", "lash", "spa"]
+  },
+  "salons": [
+    {
+      "id": 1,
+      "name": "Chris McMillan (The Salon)",
+      "category": "salon",
+      "address": "8944 Burton Way",
+      "city": "Beverly Hills",
+      "neighborhood": "Beverly Hills",
+      "zip": "90211",
+      "website": "https://www.chrismcmillanthesalon.com/",
+      "phone": "+1-310-285-0088",
+      "instagram": {
+        "handle": "@chrismcmillanthesalon",
+        "url": "https://www.instagram.com/chrismcmillanthesalon/",
+        "followers": 8200
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/chris-mcmillan-the-salon-beverly-hills",
+        "reviews": 280,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/ChrisMcMillanBeverlyHills",
+      "years_in_business": 22,
+      "ranking_score": 0.79
+    },
+    {
+      "id": 2,
+      "name": "Nelson J Salon Beverly Hills",
+      "category": "salon",
+      "address": "9675 Wilshire Blvd",
+      "city": "Beverly Hills",
+      "neighborhood": "Beverly Hills",
+      "zip": "90212",
+      "website": "https://nelsonjsalon.com/",
+      "phone": "+1-310-356-9493",
+      "instagram": {
+        "handle": "@nelsonjbeverlyhills",
+        "url": "https://www.instagram.com/nelsonjbeverlyhills/",
+        "followers": 6500
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/nelson-j-salon-beverly-hills",
+        "reviews": 420,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/NelsonJBeverlyHills",
+      "years_in_business": 20,
+      "ranking_score": 0.81
+    },
+    {
+      "id": 3,
+      "name": "Hiroshi Beauty Hub",
+      "category": "salon",
+      "address": "457 North Canon Drive, 2nd Floor",
+      "city": "Beverly Hills",
+      "neighborhood": "Beverly Hills",
+      "zip": "90210",
+      "website": "https://www.hiroshibeverlyhills.com",
+      "phone": "+1-310-273-6715",
+      "instagram": {
+        "handle": "@hiroshibhsalon",
+        "url": "https://www.instagram.com/hiroshibhsalon/",
+        "followers": 12400
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/hiroshi-hair-beverly-hills",
+        "reviews": 310,
+        "stars": 4.9
+      },
+      "google_maps": "https://goo.gl/maps/HiroshiBeverlyHills",
+      "years_in_business": 46,
+      "ranking_score": 0.87
+    },
+    {
+      "id": 4,
+      "name": "Cristophe Salon Beverly Hills",
+      "category": "salon",
+      "address": "348 North Beverly Drive",
+      "city": "Beverly Hills",
+      "neighborhood": "Beverly Hills",
+      "zip": "90210",
+      "website": "https://www.cristophe.com/",
+      "phone": "+1-310-274-0851",
+      "instagram": {
+        "handle": "@cristophebeverlyhills",
+        "url": "https://www.instagram.com/cristophebeverlyhills/",
+        "followers": 3556
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/cristophe-salon-beverly-hills",
+        "reviews": 347,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/CristopheBeverlyHills",
+      "years_in_business": 35,
+      "ranking_score": 0.84
+    },
+    {
+      "id": 5,
+      "name": "Artem K Hair Salon",
+      "category": "salon",
+      "address": "234 South Beverly Drive",
+      "city": "Beverly Hills",
+      "neighborhood": "Beverly Hills",
+      "zip": "90212",
+      "website": "https://www.artemkhairsalon.com/",
+      "phone": "+1-424-303-3843",
+      "instagram": {
+        "handle": "@artem_k_hair_salon",
+        "url": "https://www.instagram.com/artem_k_hair_salon/",
+        "followers": 83000
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/artem-k-hair-salon-beverly-hills",
+        "reviews": 65,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/ArtemKBeverlyHills",
+      "years_in_business": 12,
+      "ranking_score": 0.75
+    },
+    {
+      "id": 6,
+      "name": "Rodante Men's Hair Salon",
+      "category": "barbershop",
+      "address": "421 North Rodeo Drive G2",
+      "city": "Beverly Hills",
+      "neighborhood": "Beverly Hills",
+      "zip": "90210",
+      "website": "https://www.rodantebeverlyhills.com/",
+      "phone": "+1-424-944-0800",
+      "instagram": {
+        "handle": "@rodante_haircut",
+        "url": "https://www.instagram.com/rodante_haircut/",
+        "followers": 9800
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/rodante-mens-hair-salon-beverly-hills",
+        "reviews": 211,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/RodanteBeverlyHills",
+      "years_in_business": 15,
+      "ranking_score": 0.77
+    },
+    {
+      "id": 7,
+      "name": "The Barber Den LA",
+      "category": "barbershop",
+      "address": "7748 Santa Monica Blvd",
+      "city": "West Hollywood",
+      "neighborhood": "West Hollywood",
+      "zip": "90046",
+      "website": "https://thebarberdenla.com/",
+      "phone": "+1-323-450-9095",
+      "instagram": {
+        "handle": "@thebarberdenla",
+        "url": "https://www.instagram.com/thebarberdenla/",
+        "followers": 1564
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/the-barber-den-la-west-hollywood",
+        "reviews": 114,
+        "stars": 4.9
+      },
+      "google_maps": "https://goo.gl/maps/TheBarberDenWestHollywood",
+      "years_in_business": 12,
+      "ranking_score": 0.76
+    },
+    {
+      "id": 8,
+      "name": "Entourage Barbershop",
+      "category": "barbershop",
+      "address": "8601 West Sunset Boulevard",
+      "city": "West Hollywood",
+      "neighborhood": "West Hollywood",
+      "zip": "90069",
+      "website": "https://www.entouragebarbershop.com/",
+      "phone": "+1-310-855-8044",
+      "instagram": {
+        "handle": "@entouragebarbershop",
+        "url": "https://www.instagram.com/entouragebarbershop/",
+        "followers": 4200
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/entourage-barbershop-west-hollywood",
+        "reviews": 117,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/EntourageBarbershop",
+      "years_in_business": 8,
+      "ranking_score": 0.72
+    },
+    {
+      "id": 9,
+      "name": "BonLierre Nail Spa",
+      "category": "nail",
+      "address": "1427 7th Street, Suite 101",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90401",
+      "website": "https://bonlierrenailsca.com/",
+      "phone": "+1-310-260-8627",
+      "instagram": {
+        "handle": "@bonlierre",
+        "url": "https://www.instagram.com/bonlierre/",
+        "followers": 651
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/bonlierre-nail-spa-santa-monica",
+        "reviews": 758,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/BonLierre",
+      "years_in_business": 14,
+      "ranking_score": 0.82
+    },
+    {
+      "id": 10,
+      "name": "Coco Luxe Nails",
+      "category": "nail",
+      "address": "1924 Lincoln Boulevard",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90405",
+      "website": "https://www.cocoluxenails.com/",
+      "phone": "+1-310-396-2359",
+      "instagram": {
+        "handle": "@cocoluxe_nails",
+        "url": "https://www.instagram.com/cocoluxe_nails/",
+        "followers": 965
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/coco-luxe-nails-santa-monica",
+        "reviews": 431,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/CocoLuxeNails",
+      "years_in_business": 10,
+      "ranking_score": 0.76
+    },
+    {
+      "id": 11,
+      "name": "Mignonne Nail Bar",
+      "category": "nail",
+      "address": "2723 Ocean Park Boulevard",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90405",
+      "website": "https://mignonnesantamonica.com/",
+      "phone": "+1-310-396-8888",
+      "instagram": {
+        "handle": "@mignonnenailbar",
+        "url": "https://www.instagram.com/mignonnenailbar/",
+        "followers": 2340
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/mignonne-nail-bar-santa-monica",
+        "reviews": 221,
+        "stars": 4.6
+      },
+      "google_maps": "https://goo.gl/maps/MignonneNailBar",
+      "years_in_business": 11,
+      "ranking_score": 0.75
+    },
+    {
+      "id": 12,
+      "name": "Rich Angel Salon",
+      "category": "salon",
+      "address": "2503 Main Street",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90401",
+      "website": "https://richangelsalon.com/",
+      "phone": "+1-310-493-9251",
+      "instagram": {
+        "handle": "@rich.angel.salon",
+        "url": "https://www.instagram.com/rich.angel.salon/",
+        "followers": 4100
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/rich-angel-salon-santa-monica",
+        "reviews": 33,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/RichAngelSalon",
+      "years_in_business": 9,
+      "ranking_score": 0.68
+    },
+    {
+      "id": 13,
+      "name": "Natura Nail Spa",
+      "category": "nail",
+      "address": "922 Wilshire Boulevard",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90401",
+      "website": "https://naturanailspa.com/",
+      "phone": "+1-310-395-5652",
+      "instagram": {
+        "handle": "@natura_nail_spa",
+        "url": "https://www.instagram.com/natura_nail_spa/",
+        "followers": 1200
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/natura-nail-spa-santa-monica",
+        "reviews": 388,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/NaturaNailSpa",
+      "years_in_business": 13,
+      "ranking_score": 0.77
+    },
+    {
+      "id": 14,
+      "name": "The Brow Fixx",
+      "category": "brow",
+      "address": "1333 2nd Street, Suite 46",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90401",
+      "website": "https://thebrowfixx.com/",
+      "phone": "+1-310-893-5703",
+      "instagram": {
+        "handle": "@thebrowfixx_",
+        "url": "https://www.instagram.com/thebrowfixx_/",
+        "followers": 1575
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/the-brow-fixx-santa-monica",
+        "reviews": 169,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/TheBrowFixx",
+      "years_in_business": 8,
+      "ranking_score": 0.71
+    },
+    {
+      "id": 15,
+      "name": "Main Attraction Nail & Spa",
+      "category": "nail",
+      "address": "2654 Main Street, Suite C",
+      "city": "Santa Monica",
+      "neighborhood": "Santa Monica",
+      "zip": "90405",
+      "website": "https://mainattractionnails.com/",
+      "phone": "+1-310-450-1688",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/main-attraction-nail-and-spa-santa-monica",
+        "reviews": 250,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/MainAttractionNails",
+      "years_in_business": 11,
+      "ranking_score": 0.72
+    },
+    {
+      "id": 16,
+      "name": "Fellow Barber (Silver Lake)",
+      "category": "barbershop",
+      "address": "4451 Sunset Boulevard",
+      "city": "Los Angeles",
+      "neighborhood": "Silver Lake",
+      "zip": "90027",
+      "website": "https://www.fellowbarber.com/pages/silverlake",
+      "phone": "+1-323-661-6535",
+      "instagram": {
+        "handle": "@fellowbarber",
+        "url": "https://www.instagram.com/fellowbarber/",
+        "followers": 28600
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/fellow-barber-los-angeles-3",
+        "reviews": 653,
+        "stars": 4.9
+      },
+      "google_maps": "https://goo.gl/maps/FellowBarberSilverLake",
+      "years_in_business": 12,
+      "ranking_score": 0.85
+    },
+    {
+      "id": 17,
+      "name": "Gamine Beauty",
+      "category": "salon",
+      "address": "2845 West Sunset Boulevard",
+      "city": "Los Angeles",
+      "neighborhood": "Silver Lake",
+      "zip": "90026",
+      "website": "https://www.gaminebeauty.com/",
+      "phone": "+1-213-413-8808",
+      "instagram": {
+        "handle": "@gamine_beauty",
+        "url": "https://www.instagram.com/gamine_beauty/",
+        "followers": 1873
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/gamine-beauty-los-angeles-2",
+        "reviews": 454,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/GameneBeauty",
+      "years_in_business": 28,
+      "ranking_score": 0.83
+    },
+    {
+      "id": 18,
+      "name": "Rock Paper Salon",
+      "category": "salon",
+      "address": "2125 West Sunset Boulevard",
+      "city": "Los Angeles",
+      "neighborhood": "Silver Lake",
+      "zip": "90026",
+      "website": "https://www.rockpapersalon.com",
+      "phone": "+1-213-250-0900",
+      "instagram": {
+        "handle": "@rockpaperla",
+        "url": "https://www.instagram.com/rockpaperla/",
+        "followers": 3240
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/rock-paper-salon-los-angeles-3",
+        "reviews": 515,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/RockPaperSalon",
+      "years_in_business": 14,
+      "ranking_score": 0.81
+    },
+    {
+      "id": 19,
+      "name": "Silver Lake Spa",
+      "category": "spa",
+      "address": "240 North Virgil Avenue, Suite 11",
+      "city": "Los Angeles",
+      "neighborhood": "Silver Lake",
+      "zip": "90004",
+      "website": "https://silverlake-spa.com/",
+      "phone": "+1-213-263-2662",
+      "instagram": {
+        "handle": "@silver_lake_spa",
+        "url": "https://www.instagram.com/silver_lake_spa/",
+        "followers": 890
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/silver-lake-spa-los-angeles",
+        "reviews": 97,
+        "stars": 4.6
+      },
+      "google_maps": "https://goo.gl/maps/SilverLakeSpa",
+      "years_in_business": 11,
+      "ranking_score": 0.68
+    },
+    {
+      "id": 20,
+      "name": "Silver Lake Wellness Spa",
+      "category": "spa",
+      "address": "2650 Griffith Park Boulevard",
+      "city": "Los Angeles",
+      "neighborhood": "Silver Lake",
+      "zip": "90039",
+      "website": "https://fullmoonwc.com/",
+      "phone": "+1-323-663-6558",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/silver-lake-wellness-spa-los-angeles",
+        "reviews": 57,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/SilverLakeWellnessSpa",
+      "years_in_business": 10,
+      "ranking_score": 0.65
+    },
+    {
+      "id": 21,
+      "name": "The NOW Massage (Silver Lake)",
+      "category": "spa",
+      "address": "3329 Sunset Boulevard",
+      "city": "Los Angeles",
+      "neighborhood": "Silver Lake",
+      "zip": "90026",
+      "website": "https://thenowmassage.com/silver-lake/",
+      "phone": "+1-424-724-4022",
+      "instagram": {
+        "handle": "@thenowmassage",
+        "url": "https://www.instagram.com/thenowmassage/",
+        "followers": 5800
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/the-now-massage-silver-lake-los-angeles-2",
+        "reviews": 557,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/TheNOWMassage",
+      "years_in_business": 9,
+      "ranking_score": 0.78
+    },
+    {
+      "id": 22,
+      "name": "Cinema Wellness",
+      "category": "spa",
+      "address": "2528 Hyperion Avenue",
+      "city": "Los Angeles",
+      "neighborhood": "Los Feliz",
+      "zip": "90027",
+      "website": "https://www.cinemawellness.com/",
+      "phone": "+1-323-913-1737",
+      "instagram": {
+        "handle": "@cinemawellness",
+        "url": "https://www.instagram.com/cinemawellness/",
+        "followers": 3600
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/cinema-wellness-skin-and-body-boutique-spa-los-angeles",
+        "reviews": 241,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/CinemaWellness",
+      "years_in_business": 7,
+      "ranking_score": 0.71
+    },
+    {
+      "id": 23,
+      "name": "Color Coat Beauty Boutique (Sunset)",
+      "category": "nail",
+      "address": "7300 West Sunset Boulevard, Suite C",
+      "city": "Los Angeles",
+      "neighborhood": "Hollywood",
+      "zip": "90046",
+      "website": "https://www.colorcoatla.com/",
+      "phone": "+1-323-845-0962",
+      "instagram": {
+        "handle": "@colorcoatla",
+        "url": "https://www.instagram.com/colorcoatla/",
+        "followers": 3057
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/color-coat-beauty-boutique-los-angeles",
+        "reviews": 560,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/ColorCoatBeverlyHills",
+      "years_in_business": 8,
+      "ranking_score": 0.77
+    },
+    {
+      "id": 24,
+      "name": "Color Coat Beauty Boutique (Selma)",
+      "category": "nail",
+      "address": "6775 Selma Avenue, Suite 103",
+      "city": "Los Angeles",
+      "neighborhood": "Hollywood",
+      "zip": "90028",
+      "website": "https://www.colorcoatla.com/",
+      "phone": "+1-323-681-5553",
+      "instagram": {
+        "handle": "@colorcoatla",
+        "url": "https://www.instagram.com/colorcoatla/",
+        "followers": 3057
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/color-coat-beauty-boutique-los-angeles-2",
+        "reviews": 86,
+        "stars": 4.6
+      },
+      "google_maps": "https://goo.gl/maps/ColorCoatSelma",
+      "years_in_business": 8,
+      "ranking_score": 0.70
+    },
+    {
+      "id": 25,
+      "name": "Powder Beauty Co.",
+      "category": "nail",
+      "address": "777 South Alameda Street, Unit 146",
+      "city": "Los Angeles",
+      "neighborhood": "DTLA",
+      "zip": "90021",
+      "website": "https://www.powderbeautyco.com/",
+      "phone": "+1-213-523-1125",
+      "instagram": {
+        "handle": "@powderbeauty",
+        "url": "https://www.instagram.com/powderbeauty/",
+        "followers": 5200
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/powder-beauty-los-angeles",
+        "reviews": 231,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/PowderBeautyCo",
+      "years_in_business": 6,
+      "ranking_score": 0.72
+    },
+    {
+      "id": 26,
+      "name": "Bolt Barbers",
+      "category": "barbershop",
+      "address": "460 South Spring Street",
+      "city": "Los Angeles",
+      "neighborhood": "DTLA",
+      "zip": "90013",
+      "website": "https://boltbarbers.com/",
+      "phone": "+1-213-232-4715",
+      "instagram": {
+        "handle": "@boltbarbers",
+        "url": "https://www.instagram.com/boltbarbers/",
+        "followers": 8900
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/bolt-barbers-los-angeles",
+        "reviews": 746,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/BoltBarbers",
+      "years_in_business": 10,
+      "ranking_score": 0.84
+    },
+    {
+      "id": 27,
+      "name": "The Salon Venice",
+      "category": "salon",
+      "address": "701B Venice Boulevard",
+      "city": "Venice",
+      "neighborhood": "Venice",
+      "zip": "90291",
+      "website": "https://thesalonvenice.com/",
+      "phone": "+1-310-751-6681",
+      "instagram": {
+        "handle": "@thesalonvenice",
+        "url": "https://www.instagram.com/thesalonvenice/",
+        "followers": 2800
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/the-salon-venice-venice",
+        "reviews": 95,
+        "stars": 4.6
+      },
+      "google_maps": "https://goo.gl/maps/TheSalonVenice",
+      "years_in_business": 9,
+      "ranking_score": 0.68
+    },
+    {
+      "id": 28,
+      "name": "California Born Salon",
+      "category": "salon",
+      "address": "2518 South Lincoln Boulevard",
+      "city": "Venice",
+      "neighborhood": "Venice",
+      "zip": "90291",
+      "website": "https://californiaborn.com/",
+      "phone": "+1-310-241-3664",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/california-born-venice",
+        "reviews": 53,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/CaliforniaBorn",
+      "years_in_business": 7,
+      "ranking_score": 0.64
+    },
+    {
+      "id": 29,
+      "name": "Young Hair Salon",
+      "category": "salon",
+      "address": "2488 Lincoln Boulevard",
+      "city": "Venice",
+      "neighborhood": "Venice",
+      "zip": "90291",
+      "website": null,
+      "phone": "+1-310-822-0920",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/young-hair-salon-venice",
+        "reviews": 273,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/YoungHairSalon",
+      "years_in_business": 18,
+      "ranking_score": 0.76
+    },
+    {
+      "id": 30,
+      "name": "Manley's Barber Shop",
+      "category": "barbershop",
+      "address": "832 Lincoln Boulevard",
+      "city": "Venice",
+      "neighborhood": "Venice",
+      "zip": "90291",
+      "website": null,
+      "phone": "+1-310-399-9592",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/manleys-barber-mens-hair-styling-and-hair-care-center-los-angeles",
+        "reviews": 19,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/ManleysBarber",
+      "years_in_business": 65,
+      "ranking_score": 0.72
+    },
+    {
+      "id": 31,
+      "name": "Blind Barber (Culver City)",
+      "category": "barbershop",
+      "address": "10797 Washington Boulevard",
+      "city": "Culver City",
+      "neighborhood": "Culver City",
+      "zip": "90232",
+      "website": "https://blindbarber.com/pages/culver-city-la",
+      "phone": "+1-310-841-6679",
+      "instagram": {
+        "handle": "@blindbarber_la",
+        "url": "https://www.instagram.com/blindbarber_la/",
+        "followers": 14200
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/blind-barber-culver-city-5",
+        "reviews": 1012,
+        "stars": 4.9
+      },
+      "google_maps": "https://goo.gl/maps/BlindBarberCulverCity",
+      "years_in_business": 11,
+      "ranking_score": 0.88
+    },
+    {
+      "id": 32,
+      "name": "House of AANUKO",
+      "category": "barbershop",
+      "address": "5790 Washington Boulevard",
+      "city": "Culver City",
+      "neighborhood": "Culver City",
+      "zip": "90232",
+      "website": "https://aanuko.com/",
+      "phone": "+1-310-254-9716",
+      "instagram": {
+        "handle": "@aanuko.house",
+        "url": "https://www.instagram.com/aanuko.house/",
+        "followers": 6800
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/house-of-aanuko-culver-city-2",
+        "reviews": 249,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/HouseOfAanukoCulverCity",
+      "years_in_business": 13,
+      "ranking_score": 0.80
+    },
+    {
+      "id": 33,
+      "name": "AANUKO West Adams",
+      "category": "barbershop",
+      "address": "2616 West Jefferson Boulevard",
+      "city": "Los Angeles",
+      "neighborhood": "West Adams",
+      "zip": "90018",
+      "website": "https://aanuko.com/",
+      "phone": "+1-323-986-3459",
+      "instagram": {
+        "handle": "@aanuko.house",
+        "url": "https://www.instagram.com/aanuko.house/",
+        "followers": 6800
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/search?find_desc=AANUKO&find_loc=West+Adams,+Los+Angeles,+CA",
+        "reviews": 120,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/AanukoWestAdams",
+      "years_in_business": 12,
+      "ranking_score": 0.75
+    },
+    {
+      "id": 34,
+      "name": "Belris Beauty Salon",
+      "category": "lash",
+      "address": "1516 Westwood Boulevard, Suite 105",
+      "city": "Los Angeles",
+      "neighborhood": "Westwood",
+      "zip": "90024",
+      "website": "https://belris.art/",
+      "phone": "+1-310-227-1648",
+      "instagram": {
+        "handle": "@belris.art",
+        "url": "https://www.instagram.com/belris.art/",
+        "followers": 4300
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/biz/belris-los-angeles-6",
+        "reviews": 163,
+        "stars": 4.8
+      },
+      "google_maps": "https://goo.gl/maps/BelrisBeauty",
+      "years_in_business": 9,
+      "ranking_score": 0.72
+    },
+    {
+      "id": 35,
+      "name": "Mèche Salon",
+      "category": "salon",
+      "address": "Unknown",
+      "city": "Los Angeles",
+      "neighborhood": "Los Angeles",
+      "zip": "Unknown",
+      "website": "https://mechesalonla.com/",
+      "phone": "Unknown",
+      "instagram": {
+        "handle": "@mechesalonla",
+        "url": "https://www.instagram.com/mechesalonla/",
+        "followers": 2100
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": null,
+        "reviews": 0,
+        "stars": 0
+      },
+      "google_maps": null,
+      "years_in_business": 12,
+      "ranking_score": 0.48
+    },
+    {
+      "id": 36,
+      "name": "Cursed Barber Co",
+      "category": "barbershop",
+      "address": "11140 Jefferson Boulevard, Studio 9",
+      "city": "Culver City",
+      "neighborhood": "Culver City",
+      "zip": "90230",
+      "website": "https://www.cursedbarberco.com/",
+      "phone": "+1-714-501-7900",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/search?find_desc=Cursed+Barber&find_loc=Culver+City,+CA",
+        "reviews": 45,
+        "stars": 4.7
+      },
+      "google_maps": "https://goo.gl/maps/CursedBarberCo",
+      "years_in_business": 6,
+      "ranking_score": 0.60
+    },
+    {
+      "id": 37,
+      "name": "Origins Barber Shop",
+      "category": "barbershop",
+      "address": "Culver City",
+      "city": "Culver City",
+      "neighborhood": "Culver City",
+      "zip": "90230",
+      "website": "https://www.originsbarbershop.com/",
+      "phone": "Unknown",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": "https://www.yelp.com/search?find_desc=Origins+Barber&find_loc=Culver+City,+CA",
+        "reviews": 32,
+        "stars": 4.6
+      },
+      "google_maps": null,
+      "years_in_business": 8,
+      "ranking_score": 0.58
+    },
+    {
+      "id": 38,
+      "name": "Floyd's 99 Barbershop",
+      "category": "barbershop",
+      "address": "Lincoln Boulevard",
+      "city": "Venice",
+      "neighborhood": "Venice",
+      "zip": "90291",
+      "website": "https://www.floydsbarbershop.com/",
+      "phone": "Unknown",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": null,
+        "reviews": 0,
+        "stars": 0
+      },
+      "google_maps": null,
+      "years_in_business": 5,
+      "ranking_score": 0.52
+    },
+    {
+      "id": 39,
+      "name": "Trim Hair Salon",
+      "category": "salon",
+      "address": "Venice",
+      "city": "Venice",
+      "neighborhood": "Venice",
+      "zip": "90291",
+      "website": "https://venicetrim.com/",
+      "phone": "Unknown",
+      "instagram": {
+        "handle": "@venicetrim",
+        "url": "https://www.instagram.com/venicetrim/",
+        "followers": 340
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": null,
+        "reviews": 0,
+        "stars": 0
+      },
+      "google_maps": null,
+      "years_in_business": 6,
+      "ranking_score": 0.55
+    },
+    {
+      "id": 40,
+      "name": "Bicolor Nail Boutique",
+      "category": "nail",
+      "address": "Los Feliz",
+      "city": "Los Angeles",
+      "neighborhood": "Los Feliz",
+      "zip": "90027",
+      "website": null,
+      "phone": "Unknown",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": null,
+        "reviews": 0,
+        "stars": 0
+      },
+      "google_maps": null,
+      "years_in_business": 5,
+      "ranking_score": 0.45
+    },
+    {
+      "id": 41,
+      "name": "Nailtique",
+      "category": "nail",
+      "address": "Los Feliz",
+      "city": "Los Angeles",
+      "neighborhood": "Los Feliz",
+      "zip": "90027",
+      "website": null,
+      "phone": "Unknown",
+      "instagram": {
+        "handle": null,
+        "url": null,
+        "followers": 0
+      },
+      "tiktok": {
+        "handle": null,
+        "url": null
+      },
+      "yelp": {
+        "url": null,
+        "reviews": 0,
+        "stars": 0
+      },
+      "google_maps": null,
+      "years_in_business": 4,
+      "ranking_score": 0.42
+    }
+  ]
+}
diff --git a/src/lib/db.js b/src/lib/db.js
new file mode 100644
index 0000000..c7f6f13
--- /dev/null
+++ b/src/lib/db.js
@@ -0,0 +1,33 @@
+import 'dotenv/config';
+import pg from 'pg';
+
+export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
+
+export async function query(text, params) {
+  return pool.query(text, params);
+}
+
+export async function one(text, params) {
+  const { rows } = await pool.query(text, params);
+  return rows[0] || null;
+}
+
+export async function many(text, params) {
+  const { rows } = await pool.query(text, params);
+  return rows;
+}
+
+export async function tx(fn) {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    const result = await fn(client);
+    await client.query('COMMIT');
+    return result;
+  } catch (err) {
+    await client.query('ROLLBACK');
+    throw err;
+  } finally {
+    client.release();
+  }
+}
diff --git a/src/lib/escape.js b/src/lib/escape.js
new file mode 100644
index 0000000..a6ad11b
--- /dev/null
+++ b/src/lib/escape.js
@@ -0,0 +1,11 @@
+// Tiny HTML escapes for safe template rendering
+export function esc(s) {
+  if (s === null || s === undefined) return '';
+  return String(s)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+export function attr(s) { return esc(s); }
diff --git a/src/lib/log.js b/src/lib/log.js
new file mode 100644
index 0000000..0a22f9a
--- /dev/null
+++ b/src/lib/log.js
@@ -0,0 +1,12 @@
+// Tiny structured logger — JSON-line w/ timestamp + level
+function emit(level, msg, extra) {
+  const line = { ts: new Date().toISOString(), level, msg, ...(extra || {}) };
+  // eslint-disable-next-line no-console
+  console.log(JSON.stringify(line));
+}
+export const log = {
+  info: (m, x) => emit('info', m, x),
+  warn: (m, x) => emit('warn', m, x),
+  err:  (m, x) => emit('error', m, x),
+  dbg:  (m, x) => process.env.DEBUG ? emit('debug', m, x) : undefined,
+};
diff --git a/src/lib/slug.js b/src/lib/slug.js
new file mode 100644
index 0000000..60d4827
--- /dev/null
+++ b/src/lib/slug.js
@@ -0,0 +1,9 @@
+export function slugify(str) {
+  return String(str || '')
+    .toLowerCase()
+    .normalize('NFKD')
+    .replace(/[̀-ͯ]/g, '')
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 80);
+}
diff --git a/src/render/edit.js b/src/render/edit.js
new file mode 100644
index 0000000..8d0f9d9
--- /dev/null
+++ b/src/render/edit.js
@@ -0,0 +1,144 @@
+// Per-business edit view — left panel of stacked modals, right panel shows the
+// 5 template previews (full size, scrollable).
+import { esc } from '../lib/escape.js';
+import { templateMeta } from './templates/index.js';
+
+export function renderEdit({ business: b, socials }) {
+  const tpls = templateMeta();
+  const sBy = Object.fromEntries((socials || []).map(s => [s.platform, s]));
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Edit · ${esc(b.name)}</title>
+<style>${EDIT_CSS}</style>
+</head><body>
+<div class="layout">
+  <aside class="panel">
+    <a class="back" href="/">← All businesses</a>
+    <h1>${esc(b.name)}</h1>
+    <p class="meta">${esc(b.category || '')} · ${esc(b.city || '')}${b.state ? ', ' + esc(b.state) : ''}</p>
+    <p class="tier">Tier: <strong>${esc(b.tier.toUpperCase())}</strong></p>
+
+    <details open class="mf"><summary>Basics</summary>
+      <form onsubmit="return saveJson(event,'/api/biz/update')">
+        <input type="hidden" name="slug" value="${esc(b.slug)}">
+        <label>Name <input name="name" value="${esc(b.name)}"></label>
+        <label>Category
+          <select name="category">
+            ${['salon','barbershop','nail_salon','generic'].map(c =>
+              `<option ${b.category===c?'selected':''}>${c}</option>`).join('')}
+          </select>
+        </label>
+        <label>Phone <input name="phone" value="${esc(b.phone || '')}"></label>
+        <label>Email <input name="email" value="${esc(b.email || '')}"></label>
+        <label>Website <input name="website" value="${esc(b.website || '')}"></label>
+        <label>Owner <input name="owner_name" value="${esc(b.owner_name || '')}"></label>
+        <label>Years in business <input type="number" name="years_in_business" value="${b.years_in_business ?? ''}"></label>
+        <button>Save</button><output></output>
+      </form>
+    </details>
+
+    <details class="mf"><summary>Address</summary>
+      <form onsubmit="return saveJson(event,'/api/biz/update')">
+        <input type="hidden" name="slug" value="${esc(b.slug)}">
+        <label>Address <input name="address" value="${esc(b.address || '')}"></label>
+        <label>City <input name="city" value="${esc(b.city || '')}"></label>
+        <label>State <input name="state" maxlength="2" value="${esc(b.state || '')}"></label>
+        <label>ZIP <input name="zip" value="${esc(b.zip || '')}"></label>
+        <button>Save</button><output></output>
+      </form>
+    </details>
+
+    <details class="mf"><summary>About</summary>
+      <form onsubmit="return saveJson(event,'/api/biz/update')">
+        <input type="hidden" name="slug" value="${esc(b.slug)}">
+        <label>Anything clients should know
+          <textarea name="about_text" rows="8">${esc(b.about_text || '')}</textarea>
+        </label>
+        <button>Save</button><output></output>
+      </form>
+    </details>
+
+    <details class="mf"><summary>Colors & hero</summary>
+      <form onsubmit="return saveJson(event,'/api/biz/update')">
+        <input type="hidden" name="slug" value="${esc(b.slug)}">
+        <label>Primary color <input name="color_primary" type="color" value="${esc(b.color_primary || '#1f2937')}"></label>
+        <label>Accent color <input name="color_accent" type="color" value="${esc(b.color_accent || '#d97706')}"></label>
+        <label>Hero image URL <input name="hero_image_url" value="${esc(b.hero_image_url || '')}"></label>
+        <button>Save</button><output></output>
+      </form>
+    </details>
+
+    <details class="mf"><summary>Socials</summary>
+      <form onsubmit="return saveJson(event,'/api/biz/socials')">
+        <input type="hidden" name="slug" value="${esc(b.slug)}">
+        <label>Instagram <input name="instagram" value="${esc(sBy.instagram?.handle || '')}"></label>
+        <label>TikTok <input name="tiktok" value="${esc(sBy.tiktok?.handle || '')}"></label>
+        <label>X / Twitter <input name="twitter" value="${esc(sBy.twitter?.handle || '')}"></label>
+        <label>LinkedIn <input name="linkedin" value="${esc(sBy.linkedin?.handle || '')}"></label>
+        <label>Facebook <input name="facebook" value="${esc(sBy.facebook?.handle || '')}"></label>
+        <button>Save</button><output></output>
+      </form>
+    </details>
+
+    <details class="mf"><summary>Tier</summary>
+      <form onsubmit="return saveJson(event,'/api/biz/update')">
+        <input type="hidden" name="slug" value="${esc(b.slug)}">
+        <label>Plan
+          <select name="tier">
+            ${['free','plus','premium'].map(t => `<option ${b.tier===t?'selected':''}>${t}</option>`).join('')}
+          </select>
+        </label>
+        <button>Save</button><output></output>
+      </form>
+    </details>
+  </aside>
+
+  <main class="grid">
+    <h2>5 template variants</h2>
+    <div class="cols">
+      ${tpls.map(m => `
+        <section class="tpl">
+          <header><h3>${esc(m.name)}</h3><a target="_blank" rel="noopener" href="/biz/${esc(b.slug)}/template/${m.id}">Open ↗</a></header>
+          <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview"></iframe>
+          <p class="blurb">${esc(m.blurb)}</p>
+        </section>`).join('')}
+    </div>
+  </main>
+</div>
+<script>${EDIT_JS}</script>
+</body></html>`;
+}
+
+const EDIT_CSS = `
+*{box-sizing:border-box}html,body{margin:0;font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#fafafa;color:#0b0b0b}
+.layout{display:grid;grid-template-columns:380px 1fr;min-height:100vh}
+.panel{background:#0b0b0b;color:#f6f4f0;padding:24px;position:sticky;top:0;height:100vh;overflow-y:auto}
+.back{color:#999;font-size:13px;text-decoration:none}.back:hover{color:#fff}
+.panel h1{font-family:Georgia,serif;margin:12px 0 4px;font-size:26px}
+.panel .meta{color:#aaa;font-size:13px;margin:0 0 6px}
+.panel .tier{font-size:13px;color:#aaa;margin:0 0 18px}
+.mf{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;margin-bottom:8px}
+.mf>summary{padding:12px 14px;cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;justify-content:space-between}
+.mf>summary::after{content:'+';opacity:.5}.mf[open]>summary::after{content:'−'}
+.mf form{padding:6px 14px 16px;display:flex;flex-direction:column;gap:8px}
+.mf label{display:flex;flex-direction:column;font-size:12px;color:#aaa;gap:4px}
+.mf input,.mf select,.mf textarea{background:#0b0b0b;border:1px solid #333;color:#f6f4f0;padding:8px 10px;border-radius:6px;font-size:13px;font-family:inherit}
+.mf button{background:#d97706;color:#fff;border:0;padding:9px 12px;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px;align-self:flex-start}
+.mf output{font-size:12px;color:#aaa}
+.grid{padding:32px}
+.grid h2{font-family:Georgia,serif;margin:0 0 20px}
+.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:18px}
+.tpl{background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.05)}
+.tpl header{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid #eee}
+.tpl header h3{margin:0;font-size:15px}
+.tpl header a{font-size:12px;color:#d97706;text-decoration:none}
+.tpl iframe{width:100%;height:520px;border:0;display:block;background:#fff}
+.tpl .blurb{padding:10px 14px;color:#666;font-size:12px;margin:0;border-top:1px solid #eee}
+@media (max-width:880px){.layout{grid-template-columns:1fr}.panel{position:static;height:auto}.cols{grid-template-columns:1fr}}
+`;
+const EDIT_JS = `
+async function saveJson(ev,url){ev.preventDefault();const f=ev.currentTarget;const fd=new FormData(f);const o=f.querySelector('output');o.textContent='Saving…';
+  try{const r=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(fd.entries()))});const j=await r.json();if(!r.ok)throw new Error(j.error||r.status);o.textContent='Saved.';
+    document.querySelectorAll('iframe').forEach(i=>i.src=i.src);}catch(e){o.textContent=String(e.message||e);} return false;}
+`;
diff --git a/src/render/grid.js b/src/render/grid.js
new file mode 100644
index 0000000..058d361
--- /dev/null
+++ b/src/render/grid.js
@@ -0,0 +1,229 @@
+// Grid view — shows 3-12 small-business front-page mockups stacked.
+// Each row is one business; row scrolls horizontally through the 5 template variants
+// (carousel on mobile, slider on desktop).
+//
+// Renders the FULL page HTML for `/`. Self-contained CSS + a tiny inline JS for the
+// modal forms in the left panel.
+
+import { esc } from '../lib/escape.js';
+import { templateMeta } from './templates/index.js';
+
+export function renderGrid({ businesses }) {
+  const tpls = templateMeta();
+  const hasAny = businesses && businesses.length > 0;
+
+  const rows = (businesses || []).map(b => `
+    <article class="row" data-slug="${esc(b.slug)}">
+      <header class="row-head">
+        <div>
+          <h2><a href="/biz/${esc(b.slug)}">${esc(b.name)}</a></h2>
+          <p class="meta">${esc(b.category || '—')} · ${esc(b.city || '')}${b.state ? ', ' + esc(b.state) : ''} · <span class="tier tier-${esc(b.tier)}">${esc(b.tier.toUpperCase())}</span></p>
+        </div>
+        <a class="btn-edit" href="/biz/${esc(b.slug)}/edit">Edit</a>
+      </header>
+      <div class="carousel">
+        ${tpls.map(m => `
+          <a class="card" href="/biz/${esc(b.slug)}/template/${m.id}" target="_blank" rel="noopener">
+            <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview" title="${esc(b.name)} — ${esc(m.name)}"></iframe>
+            <div class="card-foot"><strong>${esc(m.name)}</strong><span>${esc(m.blurb)}</span></div>
+          </a>
+        `).join('')}
+      </div>
+    </article>`).join('');
+
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Small-Business Builder</title>
+<style>${PAGE_CSS}</style>
+</head><body>
+<div class="layout">
+  <aside class="panel">
+    <div class="panel-head">
+      <h1>Small-Business<br>Builder</h1>
+      <p class="lede">5 templates per business. Drop a URL. Pick the look you like.</p>
+    </div>
+
+    <section class="form-stack">
+      <details open class="modal-form">
+        <summary>1 · Existing website</summary>
+        <form id="f-website" onsubmit="return submitScrape(event,'/api/scrape-website')">
+          <label>Website URL <input name="url" placeholder="https://thesalon.com" required></label>
+          <label>Category
+            <select name="category">
+              <option value="salon">Hair salon</option>
+              <option value="barbershop">Barbershop</option>
+              <option value="nail_salon">Nail salon</option>
+              <option value="generic">Other small business</option>
+            </select>
+          </label>
+          <button>Scrape & create →</button>
+          <output></output>
+        </form>
+      </details>
+
+      <details class="modal-form">
+        <summary>2 · Instagram handle</summary>
+        <form id="f-instagram" onsubmit="return submitScrape(event,'/api/scrape-instagram')">
+          <label>Business slug <input name="slug" placeholder="the-salon" required></label>
+          <label>Instagram <input name="handle" placeholder="@thesalon" required></label>
+          <button>Pull bio + followers →</button>
+          <output></output>
+        </form>
+      </details>
+
+      <details class="modal-form">
+        <summary>3 · Owner & location</summary>
+        <form id="f-owner" onsubmit="return submitJson(event,'/api/biz/update')">
+          <label>Slug <input name="slug" required></label>
+          <label>Owner name <input name="owner_name"></label>
+          <label>Years in business <input name="years_in_business" type="number" min="0"></label>
+          <label>Address <input name="address"></label>
+          <label>City <input name="city"></label>
+          <label>State <input name="state" maxlength="2" placeholder="CA"></label>
+          <label>ZIP <input name="zip"></label>
+          <button>Save →</button>
+          <output></output>
+        </form>
+      </details>
+
+      <details class="modal-form">
+        <summary>4 · Other socials</summary>
+        <form id="f-socials" onsubmit="return submitJson(event,'/api/biz/socials')">
+          <label>Slug <input name="slug" required></label>
+          <label>TikTok <input name="tiktok"></label>
+          <label>X / Twitter <input name="twitter"></label>
+          <label>LinkedIn <input name="linkedin"></label>
+          <label>Facebook <input name="facebook"></label>
+          <button>Save →</button>
+          <output></output>
+        </form>
+      </details>
+
+      <details class="modal-form">
+        <summary>5 · About</summary>
+        <form id="f-about" onsubmit="return submitJson(event,'/api/biz/update')">
+          <label>Slug <input name="slug" required></label>
+          <label>Anything clients should know
+            <textarea name="about_text" rows="6" placeholder="Walk-ins welcome on Tuesdays, by appointment otherwise. Specializing in curl care…"></textarea>
+          </label>
+          <button>Save →</button>
+          <output></output>
+        </form>
+      </details>
+    </section>
+
+    <section class="tiers">
+      <h3>Pricing</h3>
+      <div class="tier-card"><strong>Free</strong> — directory listing only</div>
+      <div class="tier-card"><strong>Plus · $19/mo</strong> — full website, custom domain, leads</div>
+      <div class="tier-card"><strong>Premium · $79/mo</strong> — booking, SEO, social scheduler</div>
+    </section>
+  </aside>
+
+  <main class="grid">
+    ${hasAny ? rows : `
+      <div class="empty">
+        <h2>No businesses yet</h2>
+        <p>Drop a URL in the left panel to start.</p>
+      </div>`}
+  </main>
+</div>
+
+<dialog id="popout"><form method="dialog"><button>×</button></form><div class="popout-body"></div></dialog>
+
+<script>${PAGE_JS}</script>
+</body></html>`;
+}
+
+const PAGE_CSS = `
+*{box-sizing:border-box}
+html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#fafafa;color:#0b0b0b}
+.layout{display:grid;grid-template-columns:380px 1fr;min-height:100vh}
+.panel{background:#0b0b0b;color:#f6f4f0;padding:28px 24px;position:sticky;top:0;height:100vh;overflow-y:auto}
+.panel-head h1{font-family:Georgia,serif;font-size:30px;line-height:1.05;margin:0 0 12px;letter-spacing:-.5px}
+.panel-head .lede{color:#bbb;font-size:13px;margin:0 0 24px;line-height:1.5}
+.form-stack{display:flex;flex-direction:column;gap:10px}
+.modal-form{background:#1a1a1a;border-radius:10px;border:1px solid #2a2a2a}
+.modal-form>summary{padding:14px 16px;cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;justify-content:space-between;align-items:center}
+.modal-form>summary::after{content:'+';opacity:.6}
+.modal-form[open]>summary::after{content:'−'}
+.modal-form form{padding:6px 16px 18px;display:flex;flex-direction:column;gap:10px}
+.modal-form label{display:flex;flex-direction:column;font-size:12px;color:#aaa;gap:4px}
+.modal-form input,.modal-form select,.modal-form textarea{background:#0b0b0b;border:1px solid #333;color:#f6f4f0;padding:10px 12px;border-radius:6px;font-size:14px;font-family:inherit}
+.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus{outline:none;border-color:#d97706}
+.modal-form button{background:#d97706;color:#fff;border:0;padding:10px 14px;border-radius:6px;font-weight:600;cursor:pointer;font-size:14px}
+.modal-form output{font-size:12px;color:#aaa;min-height:14px}
+.modal-form output.ok{color:#7bd389}
+.modal-form output.err{color:#ff7676}
+.tiers{margin-top:28px}
+.tiers h3{font-size:13px;text-transform:uppercase;letter-spacing:2px;color:#888;margin:0 0 12px}
+.tier-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;padding:12px 14px;margin-bottom:8px;font-size:13px;color:#ddd}
+.grid{padding:32px 32px 80px}
+.empty{padding:80px 20px;text-align:center;color:#666}
+.empty h2{font-family:Georgia,serif;font-size:32px;margin:0 0 8px;color:#0b0b0b}
+.row{margin-bottom:48px;background:#fff;border-radius:14px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
+.row-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;gap:12px}
+.row-head h2{margin:0 0 4px;font-size:22px;font-family:Georgia,serif}
+.row-head h2 a{color:#0b0b0b;text-decoration:none}
+.row-head .meta{margin:0;font-size:13px;color:#666}
+.tier{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;letter-spacing:1px;background:#eee}
+.tier-plus{background:#fff3d4;color:#7a4f00}
+.tier-premium{background:#0b0b0b;color:#fff}
+.btn-edit{font-size:13px;color:#d97706;text-decoration:none;border:1px solid #d97706;padding:6px 12px;border-radius:6px;white-space:nowrap}
+.carousel{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(280px,420px);gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:8px}
+.card{position:relative;border-radius:10px;overflow:hidden;background:#000;aspect-ratio:16/10;text-decoration:none;color:#fff;scroll-snap-align:start;border:1px solid #eee}
+.card iframe{position:absolute;inset:0;width:100%;height:100%;border:0;pointer-events:none;background:#fff;transform:scale(.7);transform-origin:top left;width:142.857%;height:142.857%}
+.card-foot{position:absolute;left:0;right:0;bottom:0;padding:10px 12px;background:linear-gradient(to top,rgba(0,0,0,.8),rgba(0,0,0,0));font-size:12px;display:flex;flex-direction:column}
+.card-foot strong{font-size:13px;letter-spacing:.3px}
+.card-foot span{opacity:.8;font-size:11px}
+dialog#popout{border:0;border-radius:12px;padding:0;max-width:640px;width:90vw}
+dialog#popout::backdrop{background:rgba(0,0,0,.55)}
+.popout-body{padding:24px}
+
+@media (max-width: 880px){
+  .layout{grid-template-columns:1fr}
+  .panel{position:static;height:auto}
+  .grid{padding:18px}
+  .carousel{grid-auto-columns:85vw}
+}
+`;
+
+const PAGE_JS = `
+function setOut(form, msg, ok){const o=form.querySelector('output');if(!o)return;o.textContent=msg;o.className=ok?'ok':'err';}
+async function submitScrape(ev,url){
+  ev.preventDefault(); const f=ev.currentTarget;
+  const fd=new FormData(f); const body=Object.fromEntries(fd.entries());
+  setOut(f,'Working…',true);
+  try{
+    const r=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
+    const j=await r.json();
+    if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
+    setOut(f,'Saved · '+(j.slug||'ok')+' — reloading…',true);
+    setTimeout(()=>location.reload(),700);
+  }catch(e){setOut(f,String(e.message||e),false);} return false;
+}
+async function submitJson(ev,url){
+  ev.preventDefault(); const f=ev.currentTarget;
+  const fd=new FormData(f); const body=Object.fromEntries(fd.entries());
+  setOut(f,'Saving…',true);
+  try{
+    const r=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
+    const j=await r.json();
+    if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
+    setOut(f,'Saved.',true);
+    setTimeout(()=>location.reload(),600);
+  }catch(e){setOut(f,String(e.message||e),false);} return false;
+}
+// Pop a modal-form into its own dialog (drag-style without drag, just promotion)
+document.querySelectorAll('.modal-form>summary').forEach(s=>{
+  s.addEventListener('dblclick',e=>{
+    e.preventDefault();
+    const detail=s.parentElement; const form=detail.querySelector('form').cloneNode(true);
+    const body=document.querySelector('#popout .popout-body');
+    body.innerHTML=''; body.appendChild(form);
+    document.querySelector('#popout').showModal();
+  });
+});
+`;
diff --git a/src/render/templates/_helpers.js b/src/render/templates/_helpers.js
new file mode 100644
index 0000000..7d67cde
--- /dev/null
+++ b/src/render/templates/_helpers.js
@@ -0,0 +1,59 @@
+import { esc } from '../../lib/escape.js';
+
+export function pickColors(b) {
+  return {
+    primary: b.color_primary || '#1f2937',
+    accent: b.color_accent || '#d97706',
+    bg: '#ffffff',
+    fg: '#0b0b0b',
+  };
+}
+
+export function safeHero(b) {
+  return b.hero_image_url ||
+    `https://images.weserv.nl/?url=&w=1600&h=900&t=letterbox&bg=${encodeURIComponent((b.color_primary || '#1f2937').replace('#',''))}` ||
+    '';
+}
+
+export function placeholderHero(b, label) {
+  // Inline SVG hero — never breaks, no external image needed
+  const c = pickColors(b);
+  const text = esc(label || b.name || 'Your Salon');
+  return `data:image/svg+xml;utf8,` + encodeURIComponent(
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900'>
+      <defs><linearGradient id='g' x1='0' x2='1' y1='0' y2='1'>
+        <stop offset='0%' stop-color='${c.primary}'/><stop offset='100%' stop-color='${c.accent}'/>
+      </linearGradient></defs>
+      <rect width='1600' height='900' fill='url(#g)'/>
+      <text x='800' y='470' text-anchor='middle' font-family='Georgia, serif' font-size='90' fill='white' opacity='0.9'>${text}</text>
+    </svg>`
+  );
+}
+
+export function shell(title, bodyHtml, b) {
+  const c = pickColors(b);
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)}</title>
+<style>
+  :root{--p:${c.primary};--a:${c.accent};--bg:${c.bg};--fg:${c.fg}}
+  *{box-sizing:border-box}
+  html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
+  a{color:var(--a)}
+  .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+  .btn{display:inline-block;padding:12px 22px;border-radius:6px;background:var(--a);color:#fff;text-decoration:none;font-weight:600}
+  .btn.ghost{background:transparent;border:2px solid currentColor;color:var(--fg)}
+</style>
+</head><body>
+${bodyHtml}
+</body></html>`;
+}
+
+export function brandStrip(b) {
+  if (b.tier === 'plus' || b.tier === 'premium') return '';
+  return `<div style="background:#111;color:#fff;padding:8px 12px;text-align:center;font-size:12px;letter-spacing:.3px">
+    Built free with <a href="/" style="color:#fff;font-weight:600">Small-Business Builder</a> — upgrade to Plus to remove this strip.
+  </div>`;
+}
diff --git a/src/render/templates/index.js b/src/render/templates/index.js
new file mode 100644
index 0000000..882277f
--- /dev/null
+++ b/src/render/templates/index.js
@@ -0,0 +1,17 @@
+import * as t1 from './template-1.js';
+import * as t2 from './template-2.js';
+import * as t3 from './template-3.js';
+import * as t4 from './template-4.js';
+import * as t5 from './template-5.js';
+
+export const TEMPLATES = [t1, t2, t3, t4, t5];
+
+export function renderTemplate(id, business) {
+  const t = TEMPLATES[id - 1];
+  if (!t) throw new Error(`Unknown template id ${id}`);
+  return t.render(business);
+}
+
+export function templateMeta() {
+  return TEMPLATES.map(t => t.meta);
+}
diff --git a/src/render/templates/template-1.js b/src/render/templates/template-1.js
new file mode 100644
index 0000000..a3f30a5
--- /dev/null
+++ b/src/render/templates/template-1.js
@@ -0,0 +1,291 @@
+// Template 1 — "Editorial Warm"
+// Magazine layout · big serif headline · ample whitespace · single-color photography
+// Palette: cream / oxblood / charcoal · Typeface: Fraunces (serif) + Inter (UI)
+// Inspired by Goop, Glossier, Aesop
+
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
+
+function socialIcon(platform) {
+  const p = (platform || '').toLowerCase();
+  const icons = {
+    instagram: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor"/></svg>',
+    tiktok: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M16 3v3a4 4 0 0 0 4 4v3a7 7 0 0 1-4-1.3V16a5 5 0 1 1-5-5h1v3h-1a2 2 0 1 0 2 2V3h3z"/></svg>',
+    x: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M18 3h3l-7.5 8.6L22 21h-6.6l-5.2-6.4L4.4 21H1.4l8.1-9.3L1.5 3h6.7l4.7 5.9L18 3z"/></svg>',
+    twitter: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M18 3h3l-7.5 8.6L22 21h-6.6l-5.2-6.4L4.4 21H1.4l8.1-9.3L1.5 3h6.7l4.7 5.9L18 3z"/></svg>',
+    linkedin: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M4 4h4v4H4zM4 10h4v10H4zM10 10h4v1.6c.6-1 2-1.9 3.6-1.9C20.5 9.7 22 11.5 22 14.5V20h-4v-5c0-1.4-.6-2.4-1.9-2.4S14 13.7 14 15v5h-4V10z"/></svg>',
+    facebook: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M13 22v-8h3l.5-4H13V7.5c0-1.1.4-1.8 1.9-1.8H17V2.2C16.5 2.1 15.3 2 14 2c-2.9 0-4.7 1.7-4.7 4.9V10H6v4h3.3v8H13z"/></svg>',
+  };
+  return icons[p] || '';
+}
+
+function hoursList(hours) {
+  if (!hours || typeof hours !== 'object') return '';
+  const order = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
+  const labels = { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' };
+  return order
+    .filter((d) => hours[d])
+    .map((d) => `<dt>${labels[d]}</dt><dd>${esc(hours[d])}</dd>`)
+    .join('');
+}
+
+export function renderTemplate1(business, mockups = {}, options = {}) {
+  const b = business || {};
+  const hideAttribution = options.hideAttribution ?? b.tier === 'premium';
+  const primary = b.color_primary || '#5C1A1B';        // oxblood
+  const accent = b.color_accent || '#E8DCC4';          // cream
+  const services = Array.isArray(b.services) ? b.services.slice(0, 6) : [];
+  const gallery = Array.isArray(b.gallery_images) ? b.gallery_images.slice(0, 9) : [];
+  const socials = Array.isArray(b.socials) ? b.socials : [];
+  const lat = b.lat || 34.0522;
+  const lng = b.lng || -118.2437;
+  const heroBg = b.hero_image_url || 'https://images.unsplash.com/photo-1560066984-138dadb4c035?w=1600&q=70&auto=format';
+  const mapAddress = [b.address, b.city, b.state].filter(Boolean).join(', ');
+  const yearsLine = b.years_in_business ? `${esc(b.years_in_business)} years on the chair` : '';
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(b.name || 'Salon')} — ${esc(b.city || '')}</title>
+<meta name="description" content="${esc(b.about_text || b.name || '')}">
+<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=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
+<style>
+  :root {
+    --primary: ${primary};
+    --accent: ${accent};
+    --ink: #1a1a1a;
+    --paper: #faf6ef;
+    --rule: #d9cfbe;
+  }
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html { scroll-behavior: smooth; }
+  body { font-family: 'Inter', system-ui, sans-serif; background: var(--paper); color: var(--ink); font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
+  .serif { font-family: 'Fraunces', Georgia, serif; font-weight: 300; letter-spacing: -0.02em; }
+  .container { max-width: 1200px; margin: 0 auto; padding: 0 32px; }
+  a { color: var(--primary); text-decoration: none; border-bottom: 1px solid currentColor; }
+  a:hover { opacity: 0.7; }
+  button, .cta { font-family: inherit; cursor: pointer; }
+
+  /* nav */
+  .nav { padding: 28px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--rule); }
+  .nav-logo { font-family: 'Fraunces', serif; font-size: 22px; font-weight: 400; letter-spacing: 0.04em; text-transform: uppercase; border: none; color: var(--ink); }
+  .nav-links { display: flex; gap: 28px; font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
+  .nav-links a { border: none; color: var(--ink); }
+
+  /* hero */
+  .hero { padding: 96px 0 120px; display: grid; grid-template-columns: 1.1fr 1fr; gap: 64px; align-items: center; }
+  .hero-eyebrow { font-size: 12px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--primary); margin-bottom: 28px; }
+  .hero h1 { font-size: clamp(48px, 6.5vw, 88px); line-height: 0.98; margin-bottom: 32px; }
+  .hero h1 em { font-style: italic; color: var(--primary); font-weight: 300; }
+  .hero-tagline { font-family: 'Fraunces', serif; font-size: 20px; line-height: 1.4; max-width: 460px; color: #444; margin-bottom: 40px; font-weight: 300; }
+  .hero-cta { display: inline-block; background: var(--primary); color: var(--paper); padding: 18px 36px; border: none; font-size: 13px; letter-spacing: 0.18em; text-transform: uppercase; border-radius: 0; }
+  .hero-cta:hover { background: var(--ink); }
+  .hero-cta + .hero-cta { background: transparent; color: var(--primary); border: 1px solid var(--primary); margin-left: 12px; }
+  .hero-img { aspect-ratio: 4/5; background-size: cover; background-position: center; filter: sepia(0.18) saturate(0.85); border-radius: 0; }
+
+  /* about */
+  .section { padding: 96px 0; border-top: 1px solid var(--rule); }
+  .section-label { font-size: 12px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--primary); margin-bottom: 24px; }
+  .about { display: grid; grid-template-columns: 1fr 2fr; gap: 96px; }
+  .about h2 { font-size: 44px; margin-bottom: 32px; }
+  .about p { font-size: 18px; line-height: 1.7; margin-bottom: 16px; max-width: 600px; color: #333; }
+  .about .owner-line { font-family: 'Fraunces', serif; font-style: italic; color: var(--primary); margin-top: 32px; font-size: 16px; }
+
+  /* services */
+  .services { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; border-top: 1px solid var(--rule); }
+  .service { padding: 40px 0; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; gap: 32px; }
+  .service:nth-child(odd) { padding-right: 40px; border-right: 1px solid var(--rule); }
+  .service:nth-child(even) { padding-left: 40px; }
+  .service h3 { font-family: 'Fraunces', serif; font-weight: 400; font-size: 24px; margin-bottom: 8px; }
+  .service p { font-size: 14px; color: #555; max-width: 320px; }
+  .service-price { font-family: 'Fraunces', serif; font-size: 28px; color: var(--primary); white-space: nowrap; font-weight: 300; }
+
+  /* gallery */
+  .gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
+  .gallery-grid img { width: 100%; aspect-ratio: 1; object-fit: cover; filter: sepia(0.15) saturate(0.9); cursor: pointer; transition: filter 0.4s; }
+  .gallery-grid img:hover { filter: none; }
+
+  /* contact + map */
+  .contact-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 64px; }
+  .contact form { display: flex; flex-direction: column; gap: 16px; }
+  .contact label { font-size: 12px; letter-spacing: 0.15em; text-transform: uppercase; color: #666; }
+  .contact input, .contact textarea { font-family: inherit; font-size: 16px; padding: 14px 0; border: none; border-bottom: 1px solid var(--rule); background: transparent; color: var(--ink); border-radius: 0; }
+  .contact input:focus, .contact textarea:focus { outline: none; border-bottom-color: var(--primary); }
+  .contact textarea { resize: vertical; min-height: 100px; }
+  .contact button { background: var(--primary); color: var(--paper); padding: 18px; border: none; font-size: 13px; letter-spacing: 0.18em; text-transform: uppercase; margin-top: 12px; cursor: pointer; }
+  #map { width: 100%; height: 420px; border: 1px solid var(--rule); }
+
+  /* socials */
+  .socials { display: flex; gap: 20px; padding: 48px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); align-items: center; justify-content: center; }
+  .socials a { color: var(--primary); border: none; }
+
+  /* footer */
+  footer { padding: 64px 0 40px; }
+  .footer-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 48px; margin-bottom: 48px; }
+  .footer-grid h4 { font-family: 'Fraunces', serif; font-size: 18px; font-weight: 400; margin-bottom: 16px; color: var(--primary); }
+  .footer-grid dl { display: grid; grid-template-columns: 60px 1fr; gap: 6px 16px; font-size: 14px; }
+  .footer-grid dt { color: #888; text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; padding-top: 2px; }
+  .footer-attr { text-align: center; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #999; padding-top: 32px; border-top: 1px solid var(--rule); }
+  .footer-attr a { color: #999; border: none; }
+
+  @media (max-width: 720px) {
+    .container { padding: 0 20px; }
+    .nav-links { display: none; }
+    .hero, .about, .services, .contact-wrap, .footer-grid, .gallery-grid { grid-template-columns: 1fr !important; gap: 40px; }
+    .gallery-grid { grid-template-columns: repeat(2, 1fr) !important; }
+    .hero { padding: 48px 0 64px; }
+    .section { padding: 56px 0; }
+    .service:nth-child(odd) { border-right: none; padding-right: 0; }
+    .service:nth-child(even) { padding-left: 0; }
+    .hero-cta + .hero-cta { margin-left: 0; margin-top: 12px; display: block; text-align: center; }
+  }
+</style>
+</head>
+<body>
+<header class="container">
+  <nav class="nav">
+    <a class="nav-logo" href="#top">${esc(b.name || 'Salon')}</a>
+    <div class="nav-links">
+      <a href="#about">About</a>
+      <a href="#services">Services</a>
+      <a href="#gallery">Gallery</a>
+      <a href="#book">Book</a>
+    </div>
+  </nav>
+</header>
+
+<section class="container hero" id="top">
+  <div>
+    <div class="hero-eyebrow">${esc(b.category || 'Salon')} — ${esc(b.city || '')}</div>
+    <h1 class="serif">A quiet practice in <em>${esc(b.category?.toLowerCase() || 'craft')}</em>, made with care.</h1>
+    <p class="hero-tagline">${esc(b.about_text?.slice(0, 140) || `Welcome to ${b.name}. Personal, considered, never rushed.`)}</p>
+    <a class="hero-cta" href="#book">Book Now</a>
+    <a class="hero-cta" href="#map">Get Directions</a>
+  </div>
+  <div class="hero-img" style="background-image: url('${esc(heroBg)}')"></div>
+</section>
+
+<section class="section container" id="about">
+  <div class="about">
+    <div>
+      <div class="section-label">About</div>
+      <h2 class="serif">Behind the chair.</h2>
+    </div>
+    <div>
+      <p>${esc(b.about_text || `${b.name} is a small, intentional space for guests who want unhurried, expert care.`)}</p>
+      ${yearsLine ? `<p class="owner-line">— ${esc(b.owner_name || 'The owner')}, ${yearsLine}</p>` : (b.owner_name ? `<p class="owner-line">— ${esc(b.owner_name)}</p>` : '')}
+    </div>
+  </div>
+</section>
+
+<section class="section container" id="services">
+  <div class="section-label">Services</div>
+  <h2 class="serif" style="font-size: 44px; margin-bottom: 48px;">The menu.</h2>
+  <div class="services">
+    ${services.map((s) => `
+      <div class="service">
+        <div>
+          <h3>${esc(s.name)}</h3>
+          ${s.description ? `<p>${esc(s.description)}</p>` : ''}
+        </div>
+        <div class="service-price">${esc(s.price || '—')}</div>
+      </div>
+    `).join('')}
+  </div>
+</section>
+
+${gallery.length ? `
+<section class="section container" id="gallery">
+  <div class="section-label">Gallery</div>
+  <h2 class="serif" style="font-size: 44px; margin-bottom: 48px;">In the room.</h2>
+  <div class="gallery-grid">
+    ${gallery.map((g) => `<img loading="lazy" src="${esc(g.url)}" alt="${esc(g.alt || b.name)}" data-full="${esc(g.url)}">`).join('')}
+  </div>
+</section>` : ''}
+
+<section class="section container" id="book">
+  <div class="contact-wrap">
+    <div class="contact">
+      <div class="section-label">Inquiries</div>
+      <h2 class="serif" style="font-size: 36px; margin-bottom: 32px;">Drop a note.</h2>
+      <form id="lead-form">
+        <label>Name<input required name="name" type="text"></label>
+        <label>Email<input required name="email" type="email"></label>
+        <label>Phone<input name="phone" type="tel"></label>
+        <label>Message<textarea name="message" rows="4"></textarea></label>
+        <button type="submit">Send Inquiry</button>
+        <div id="lead-status" style="font-size: 13px; color: var(--primary); min-height: 18px;"></div>
+      </form>
+    </div>
+    <div>
+      <div class="section-label">Find us</div>
+      <h2 class="serif" style="font-size: 36px; margin-bottom: 32px;">${esc(mapAddress)}</h2>
+      <div id="map"></div>
+    </div>
+  </div>
+</section>
+
+${socials.length ? `
+<section class="container">
+  <div class="socials">
+    ${socials.map((s) => s.url ? `<a href="${esc(s.url)}" aria-label="${esc(s.platform)}" target="_blank" rel="noopener">${socialIcon(s.platform)}</a>` : '').join('')}
+  </div>
+</section>` : ''}
+
+<footer class="container">
+  <div class="footer-grid">
+    <div>
+      <h4>Hours</h4>
+      <dl>${hoursList(b.hours_json)}</dl>
+    </div>
+    <div>
+      <h4>Visit</h4>
+      <p style="font-size: 14px;">${esc(b.address || '')}<br>${esc(b.city || '')}${b.city && b.state ? ', ' : ''}${esc(b.state || '')}</p>
+    </div>
+    <div>
+      <h4>Reach</h4>
+      <p style="font-size: 14px;">${b.phone ? `<a href="tel:${esc(b.phone)}">${esc(b.phone)}</a><br>` : ''}${b.email ? `<a href="mailto:${esc(b.email)}">${esc(b.email)}</a>` : ''}</p>
+    </div>
+  </div>
+  ${hideAttribution ? '' : `<div class="footer-attr">Powered by <a href="#">SmallBizBuilder</a></div>`}
+</footer>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin defer></script>
+<script>
+  window.__BIZ__ = ${safeJSON({ lat, lng, name: b.name, slug: b.slug })};
+  document.addEventListener('DOMContentLoaded', () => {
+    if (window.L && document.getElementById('map')) {
+      const map = L.map('map', { scrollWheelZoom: false }).setView([__BIZ__.lat, __BIZ__.lng], 14);
+      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }).addTo(map);
+      L.marker([__BIZ__.lat, __BIZ__.lng]).addTo(map).bindPopup(__BIZ__.name).openPopup();
+    }
+    const form = document.getElementById('lead-form');
+    if (form) form.addEventListener('submit', async (e) => {
+      e.preventDefault();
+      const fd = new FormData(form);
+      const payload = Object.fromEntries(fd.entries());
+      payload.business_slug = __BIZ__.slug;
+      const status = document.getElementById('lead-status');
+      status.textContent = 'Sending…';
+      try {
+        const r = await fetch('/api/leads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
+        if (!r.ok) throw new Error();
+        status.textContent = 'Thank you. We\\'ll be in touch.';
+        form.reset();
+      } catch (err) { status.textContent = 'Could not send. Try phone or email above.'; }
+    });
+    document.querySelectorAll('.gallery-grid img').forEach(img => img.addEventListener('click', () => window.open(img.dataset.full, '_blank')));
+  });
+</script>
+</body>
+</html>`;
+}
+
+export default renderTemplate1;
+export const render = (b) => renderTemplate1(b);
+export const meta = { id: 1, name: 'Editorial Warm', blurb: 'Magazine layout · serif · cream/oxblood' };
diff --git a/src/render/templates/template-2.js b/src/render/templates/template-2.js
new file mode 100644
index 0000000..86a5ce7
--- /dev/null
+++ b/src/render/templates/template-2.js
@@ -0,0 +1,312 @@
+// Template 2 — "Bold & Loud"
+// Full-bleed hero photo · oversized sans · saturated brand color · aggressive CTAs
+// Palette: hot pink / electric blue / black · Typeface: Anton (display) + Inter (UI)
+// Inspired by Bumble & Bumble, Drybar
+
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
+
+function socialIcon(platform) {
+  const p = (platform || '').toLowerCase();
+  const icons = {
+    instagram: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 2.2c3.2 0 3.6 0 4.9.07 1.2.06 1.8.25 2.2.42.6.21 1 .47 1.4.88.4.4.67.81.88 1.4.17.46.36 1.05.42 2.2.06 1.3.07 1.7.07 4.9s0 3.6-.07 4.9c-.06 1.2-.25 1.8-.42 2.2-.21.6-.47 1-.88 1.4-.4.4-.81.67-1.4.88-.46.17-1.05.36-2.2.42-1.3.06-1.7.07-4.9.07s-3.6 0-4.9-.07c-1.2-.06-1.8-.25-2.2-.42-.6-.21-1-.47-1.4-.88-.4-.4-.67-.81-.88-1.4-.17-.46-.36-1.05-.42-2.2C2.2 15.6 2.2 15.2 2.2 12s0-3.6.07-4.9c.06-1.2.25-1.8.42-2.2.21-.6.47-1 .88-1.4.4-.4.81-.67 1.4-.88.46-.17 1.05-.36 2.2-.42C8.4 2.2 8.8 2.2 12 2.2zm0 6.3a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zm0 5.8a2.3 2.3 0 1 1 0-4.6 2.3 2.3 0 0 1 0 4.6zm4.5-6a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6z"/></svg>',
+    tiktok: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M16 3v3a4 4 0 0 0 4 4v3a7 7 0 0 1-4-1.3V16a5 5 0 1 1-5-5h1v3h-1a2 2 0 1 0 2 2V3h3z"/></svg>',
+    x: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M18 3h3l-7.5 8.6L22 21h-6.6l-5.2-6.4L4.4 21H1.4l8.1-9.3L1.5 3h6.7l4.7 5.9L18 3z"/></svg>',
+    twitter: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M18 3h3l-7.5 8.6L22 21h-6.6l-5.2-6.4L4.4 21H1.4l8.1-9.3L1.5 3h6.7l4.7 5.9L18 3z"/></svg>',
+    linkedin: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M4 4h4v4H4zM4 10h4v10H4zM10 10h4v1.6c.6-1 2-1.9 3.6-1.9C20.5 9.7 22 11.5 22 14.5V20h-4v-5c0-1.4-.6-2.4-1.9-2.4S14 13.7 14 15v5h-4V10z"/></svg>',
+    facebook: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M13 22v-8h3l.5-4H13V7.5c0-1.1.4-1.8 1.9-1.8H17V2.2C16.5 2.1 15.3 2 14 2c-2.9 0-4.7 1.7-4.7 4.9V10H6v4h3.3v8H13z"/></svg>',
+  };
+  return icons[p] || '';
+}
+
+function hoursList(hours) {
+  if (!hours || typeof hours !== 'object') return '';
+  const order = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
+  const labels = { mon: 'MON', tue: 'TUE', wed: 'WED', thu: 'THU', fri: 'FRI', sat: 'SAT', sun: 'SUN' };
+  return order
+    .filter((d) => hours[d])
+    .map((d) => `<li><strong>${labels[d]}</strong> ${esc(hours[d])}</li>`)
+    .join('');
+}
+
+export function renderTemplate2(business, mockups = {}, options = {}) {
+  const b = business || {};
+  const hideAttribution = options.hideAttribution ?? b.tier === 'premium';
+  const primary = b.color_primary || '#FF1F8F';        // hot pink
+  const accent = b.color_accent || '#0046FF';          // electric blue
+  const services = Array.isArray(b.services) ? b.services.slice(0, 6) : [];
+  const gallery = Array.isArray(b.gallery_images) ? b.gallery_images.slice(0, 8) : [];
+  const socials = Array.isArray(b.socials) ? b.socials : [];
+  const lat = b.lat || 34.0522;
+  const lng = b.lng || -118.2437;
+  const heroBg = b.hero_image_url || 'https://images.unsplash.com/photo-1521590832167-7bcbfaa6381f?w=1800&q=70&auto=format';
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(b.name || 'Salon')} — BOOK NOW</title>
+<meta name="description" content="${esc(b.about_text || b.name || '')}">
+<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=Anton&family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
+<style>
+  :root {
+    --primary: ${primary};
+    --accent: ${accent};
+    --ink: #0a0a0a;
+    --paper: #ffffff;
+  }
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html { scroll-behavior: smooth; }
+  body { font-family: 'Inter', system-ui, sans-serif; background: var(--paper); color: var(--ink); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
+  .display { font-family: 'Anton', 'Impact', sans-serif; text-transform: uppercase; letter-spacing: -0.01em; line-height: 0.92; font-weight: 400; }
+  .container { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
+  a { color: inherit; text-decoration: none; }
+  button { font-family: inherit; cursor: pointer; }
+
+  /* nav */
+  .nav { position: absolute; top: 0; left: 0; right: 0; z-index: 10; padding: 24px 32px; display: flex; justify-content: space-between; align-items: center; color: white; }
+  .nav-logo { font-family: 'Anton', sans-serif; font-size: 28px; text-transform: uppercase; letter-spacing: 0.02em; }
+  .nav-links { display: flex; gap: 24px; font-size: 14px; font-weight: 600; text-transform: uppercase; }
+  .nav-cta { background: var(--primary); color: white; padding: 12px 24px; font-weight: 800; text-transform: uppercase; font-size: 13px; letter-spacing: 0.05em; transform: skewX(-6deg); display: inline-block; }
+  .nav-cta span { display: inline-block; transform: skewX(6deg); }
+
+  /* hero */
+  .hero { position: relative; min-height: 100vh; background: var(--ink) url('${esc(heroBg)}') center / cover; display: flex; align-items: flex-end; color: white; padding: 96px 32px 64px; }
+  .hero::before { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.1) 30%, rgba(0,0,0,0.85) 100%); }
+  .hero-inner { position: relative; max-width: 1280px; margin: 0 auto; width: 100%; }
+  .hero h1 { font-size: clamp(72px, 14vw, 220px); margin-bottom: 24px; }
+  .hero h1 .tag { display: block; color: var(--primary); }
+  .hero-sub { font-size: clamp(18px, 2vw, 24px); font-weight: 600; margin-bottom: 32px; max-width: 640px; }
+  .cta-row { display: flex; gap: 16px; flex-wrap: wrap; }
+  .btn { display: inline-block; padding: 22px 40px; font-weight: 800; text-transform: uppercase; font-size: 16px; letter-spacing: 0.04em; border: none; cursor: pointer; transition: transform 0.15s; }
+  .btn:hover { transform: translateY(-2px); }
+  .btn-primary { background: var(--primary); color: white; }
+  .btn-secondary { background: white; color: var(--ink); }
+  .btn-ghost { background: transparent; color: white; border: 3px solid white; }
+
+  /* marquee */
+  .marquee { background: var(--primary); color: white; overflow: hidden; padding: 18px 0; white-space: nowrap; border-top: 4px solid var(--ink); border-bottom: 4px solid var(--ink); }
+  .marquee-track { display: inline-block; animation: scroll 30s linear infinite; font-family: 'Anton', sans-serif; font-size: 32px; text-transform: uppercase; letter-spacing: 0.05em; }
+  .marquee-track span { padding: 0 32px; }
+  .marquee-track .dot { color: var(--accent); }
+  @keyframes scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
+
+  /* sections */
+  .section { padding: 120px 0; }
+  .section h2 { font-family: 'Anton', sans-serif; font-size: clamp(56px, 8vw, 120px); text-transform: uppercase; line-height: 0.9; margin-bottom: 48px; }
+  .section h2 em { font-style: normal; color: var(--primary); }
+
+  /* about */
+  .about { background: var(--ink); color: white; }
+  .about-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 80px; align-items: center; }
+  .about p { font-size: 20px; line-height: 1.55; margin-bottom: 16px; }
+  .about-stat { font-family: 'Anton', sans-serif; font-size: 240px; line-height: 0.85; color: var(--primary); }
+  .about-stat-label { font-weight: 800; text-transform: uppercase; font-size: 18px; margin-top: 8px; }
+
+  /* services */
+  .services-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
+  .service-card { background: var(--ink); color: white; padding: 40px 32px; transition: background 0.2s; cursor: pointer; }
+  .service-card:hover { background: var(--primary); }
+  .service-card h3 { font-family: 'Anton', sans-serif; font-size: 36px; text-transform: uppercase; margin-bottom: 16px; line-height: 1; }
+  .service-card .price { font-family: 'Anton', sans-serif; font-size: 56px; color: var(--primary); display: block; margin-bottom: 16px; line-height: 1; }
+  .service-card:hover .price { color: white; }
+  .service-card p { font-size: 14px; opacity: 0.9; }
+
+  /* gallery */
+  .gallery { background: var(--accent); padding: 80px 0; }
+  .gallery h2 { color: white; }
+  .gallery-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
+  .gallery-grid img { width: 100%; aspect-ratio: 3/4; object-fit: cover; cursor: pointer; transition: transform 0.3s; }
+  .gallery-grid img:hover { transform: scale(1.04); }
+
+  /* contact */
+  .contact { background: var(--primary); color: white; }
+  .contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 64px; }
+  .contact form { display: grid; gap: 14px; }
+  .contact input, .contact textarea { font-family: inherit; font-size: 18px; padding: 18px; border: 3px solid white; background: transparent; color: white; font-weight: 600; border-radius: 0; }
+  .contact input::placeholder, .contact textarea::placeholder { color: rgba(255,255,255,0.7); font-weight: 600; }
+  .contact textarea { min-height: 120px; resize: vertical; }
+  .contact button { background: var(--ink); color: white; padding: 22px; border: none; font-weight: 800; text-transform: uppercase; font-size: 18px; letter-spacing: 0.05em; cursor: pointer; }
+  #map { width: 100%; height: 480px; border: 4px solid var(--ink); }
+
+  /* socials */
+  .social-bar { background: var(--ink); padding: 40px 0; display: flex; justify-content: center; gap: 32px; color: white; }
+  .social-bar a { transition: color 0.2s; }
+  .social-bar a:hover { color: var(--primary); }
+
+  /* footer */
+  footer { background: var(--ink); color: white; padding: 64px 0 32px; }
+  .footer-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 48px; margin-bottom: 48px; }
+  .footer-grid h4 { font-family: 'Anton', sans-serif; font-size: 24px; text-transform: uppercase; margin-bottom: 16px; color: var(--primary); }
+  .footer-grid ul { list-style: none; font-size: 14px; line-height: 1.9; }
+  .footer-grid ul strong { display: inline-block; width: 50px; color: var(--primary); }
+  .footer-attr { text-align: center; font-size: 12px; padding-top: 32px; border-top: 1px solid #333; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.1em; }
+
+  @media (max-width: 720px) {
+    .nav-links { display: none; }
+    .hero { padding: 72px 20px 48px; min-height: 90vh; }
+    .container { padding: 0 20px; }
+    .about-grid, .contact-grid, .footer-grid { grid-template-columns: 1fr !important; gap: 48px; }
+    .services-grid { grid-template-columns: 1fr; }
+    .gallery-grid { grid-template-columns: repeat(2, 1fr); }
+    .about-stat { font-size: 140px; }
+    .section { padding: 64px 0; }
+  }
+</style>
+</head>
+<body>
+
+<header>
+  <nav class="nav">
+    <a class="nav-logo" href="#top">${esc(b.name || 'SALON')}</a>
+    <a class="nav-cta" href="#book"><span>Book Now →</span></a>
+  </nav>
+</header>
+
+<section class="hero" id="top">
+  <div class="hero-inner">
+    <h1 class="display">${esc(b.name || 'BIG').split(' ').slice(0, 2).join(' ')}<br><span class="tag">${esc((b.category || 'BOLD').toUpperCase())}.</span></h1>
+    <p class="hero-sub">${esc(b.about_text?.slice(0, 120) || `${b.city || 'LA'}'s loudest, brightest, never-quiet salon.`)}</p>
+    <div class="cta-row">
+      <a class="btn btn-primary" href="#book">Book Now →</a>
+      <a class="btn btn-ghost" href="#map">Get Directions</a>
+    </div>
+  </div>
+</section>
+
+<div class="marquee">
+  <div class="marquee-track">
+    <span>WALK-INS WELCOME</span><span class="dot">●</span>
+    <span>${esc((b.city || 'LA').toUpperCase())}</span><span class="dot">●</span>
+    <span>BOOK ONLINE 24/7</span><span class="dot">●</span>
+    <span>${esc((b.category || 'SALON').toUpperCase())}</span><span class="dot">●</span>
+    <span>WALK-INS WELCOME</span><span class="dot">●</span>
+    <span>${esc((b.city || 'LA').toUpperCase())}</span><span class="dot">●</span>
+    <span>BOOK ONLINE 24/7</span><span class="dot">●</span>
+    <span>${esc((b.category || 'SALON').toUpperCase())}</span><span class="dot">●</span>
+  </div>
+</div>
+
+<section class="section about" id="about">
+  <div class="container about-grid">
+    <div>
+      <h2>Meet the<br><em>boss.</em></h2>
+      <p>${esc(b.about_text || `Hi, I'm ${b.owner_name || 'the owner'}. This is my chair, my craft, my love letter to ${b.city || 'this city'}.`)}</p>
+      <p style="opacity: 0.7; font-size: 16px; margin-top: 24px;">— ${esc(b.owner_name || 'Owner')}</p>
+    </div>
+    ${b.years_in_business ? `
+    <div style="text-align: center;">
+      <div class="about-stat">${esc(b.years_in_business)}</div>
+      <div class="about-stat-label">YEARS / DEEP IN IT</div>
+    </div>` : ''}
+  </div>
+</section>
+
+<section class="section" id="services">
+  <div class="container">
+    <h2>The <em>menu.</em></h2>
+    <div class="services-grid">
+      ${services.map((s) => `
+        <div class="service-card">
+          <span class="price">${esc(s.price || '—')}</span>
+          <h3>${esc(s.name)}</h3>
+          ${s.description ? `<p>${esc(s.description)}</p>` : ''}
+        </div>
+      `).join('')}
+    </div>
+  </div>
+</section>
+
+${gallery.length ? `
+<section class="gallery" id="gallery">
+  <div class="container">
+    <h2>The <em>work.</em></h2>
+    <div class="gallery-grid">
+      ${gallery.map((g) => `<img loading="lazy" src="${esc(g.url)}" alt="${esc(g.alt || b.name)}" data-full="${esc(g.url)}">`).join('')}
+    </div>
+  </div>
+</section>` : ''}
+
+<section class="section contact" id="book">
+  <div class="container contact-grid">
+    <div>
+      <h2>Hit us<br><em>up.</em></h2>
+      <form id="lead-form">
+        <input required name="name" type="text" placeholder="YOUR NAME">
+        <input required name="email" type="email" placeholder="YOUR EMAIL">
+        <input name="phone" type="tel" placeholder="YOUR PHONE">
+        <textarea name="message" placeholder="WHAT DO YOU WANT?"></textarea>
+        <button type="submit">SEND IT →</button>
+        <div id="lead-status" style="font-size: 14px; min-height: 18px; font-weight: 600;"></div>
+      </form>
+    </div>
+    <div>
+      <div id="map"></div>
+      <p style="margin-top: 16px; font-weight: 800; text-transform: uppercase; font-size: 14px;">${esc(b.address || '')}<br>${esc(b.city || '')}${b.city && b.state ? ', ' : ''}${esc(b.state || '')}</p>
+    </div>
+  </div>
+</section>
+
+${socials.length ? `
+<div class="social-bar">
+  ${socials.map((s) => s.url ? `<a href="${esc(s.url)}" aria-label="${esc(s.platform)}" target="_blank" rel="noopener">${socialIcon(s.platform)}</a>` : '').join('')}
+</div>` : ''}
+
+<footer>
+  <div class="container">
+    <div class="footer-grid">
+      <div>
+        <h4>HOURS</h4>
+        <ul>${hoursList(b.hours_json)}</ul>
+      </div>
+      <div>
+        <h4>FIND US</h4>
+        <p style="font-size: 14px; line-height: 1.7;">${esc(b.address || '')}<br>${esc(b.city || '')}${b.city && b.state ? ', ' : ''}${esc(b.state || '')}</p>
+      </div>
+      <div>
+        <h4>CALL / WRITE</h4>
+        <p style="font-size: 14px; line-height: 1.7;">${b.phone ? `<a href="tel:${esc(b.phone)}" style="color: var(--primary); font-weight: 800;">${esc(b.phone)}</a><br>` : ''}${b.email ? `<a href="mailto:${esc(b.email)}">${esc(b.email)}</a>` : ''}</p>
+      </div>
+    </div>
+    ${hideAttribution ? '' : `<div class="footer-attr">Powered by <a href="#" style="color: var(--primary);">SmallBizBuilder</a></div>`}
+  </div>
+</footer>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin defer></script>
+<script>
+  window.__BIZ__ = ${safeJSON({ lat, lng, name: b.name, slug: b.slug })};
+  document.addEventListener('DOMContentLoaded', () => {
+    if (window.L && document.getElementById('map')) {
+      const map = L.map('map', { scrollWheelZoom: false }).setView([__BIZ__.lat, __BIZ__.lng], 14);
+      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OSM', maxZoom: 19 }).addTo(map);
+      L.marker([__BIZ__.lat, __BIZ__.lng]).addTo(map).bindPopup(__BIZ__.name).openPopup();
+    }
+    const form = document.getElementById('lead-form');
+    if (form) form.addEventListener('submit', async (e) => {
+      e.preventDefault();
+      const fd = new FormData(form);
+      const payload = Object.fromEntries(fd.entries());
+      payload.business_slug = __BIZ__.slug;
+      const status = document.getElementById('lead-status');
+      status.textContent = 'SENDING...';
+      try {
+        const r = await fetch('/api/leads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
+        if (!r.ok) throw new Error();
+        status.textContent = "GOT IT. WE'LL HIT YOU BACK.";
+        form.reset();
+      } catch (err) { status.textContent = 'SOMETHING BROKE — TRY THE PHONE NUMBER.'; }
+    });
+    document.querySelectorAll('.gallery-grid img').forEach(img => img.addEventListener('click', () => window.open(img.dataset.full, '_blank')));
+  });
+</script>
+</body>
+</html>`;
+}
+
+export default renderTemplate2;
+export const render = (b) => renderTemplate2(b);
+export const meta = { id: 2, name: 'Bold & Loud', blurb: 'Full-bleed hero · oversized sans · saturated' };
diff --git a/src/render/templates/template-3.js b/src/render/templates/template-3.js
new file mode 100644
index 0000000..5e9e055
--- /dev/null
+++ b/src/render/templates/template-3.js
@@ -0,0 +1,47 @@
+// Template 3 — Bold Color Block
+// Solid colored top half (primary), white bottom half. Geometric, modern.
+import { esc } from '../../lib/escape.js';
+import { pickColors, placeholderHero, shell, brandStrip } from './_helpers.js';
+
+export function render(b) {
+  const c = pickColors(b);
+  const heroSrc = b.hero_image_url || placeholderHero(b, b.name);
+  const body = `
+${brandStrip(b)}
+<section style="background:${c.primary};color:#fff;padding:80px 0 120px;position:relative;overflow:hidden">
+  <div class="wrap" style="position:relative;z-index:2">
+    <p style="margin:0 0 14px;text-transform:uppercase;letter-spacing:2px;font-size:13px;opacity:.8">${esc(b.category || 'Local')} · ${esc(b.city || '')}</p>
+    <h1 style="font-size:72px;font-weight:900;margin:0 0 18px;letter-spacing:-1px">${esc(b.name)}</h1>
+    <p style="font-size:20px;max-width:600px;opacity:.95;margin:0 0 28px">${esc(b.about_text || `Bold cuts, color, and care. ${b.years_in_business ? `${b.years_in_business}+ years strong.` : ''}`)}</p>
+    <a class="btn" href="#book" style="background:#fff;color:${c.primary}">Book your seat →</a>
+  </div>
+  <div style="position:absolute;right:-100px;top:-50px;width:500px;height:500px;background:${c.accent};border-radius:50%;opacity:.45"></div>
+</section>
+<section style="margin-top:-80px;position:relative;z-index:3">
+  <div class="wrap">
+    <img src="${esc(heroSrc)}" alt="${esc(b.name)}" style="width:100%;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.25);display:block;max-height:520px;object-fit:cover">
+  </div>
+</section>
+<section style="padding:80px 0">
+  <div class="wrap">
+    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:32px;text-align:center">
+      ${[
+        { n: b.years_in_business || '—', l: 'years in business' },
+        { n: '4.9★', l: 'avg rating' },
+        { n: b.owner_name ? esc(b.owner_name) : 'Independent', l: 'owner' },
+      ].map(s => `<div><div style="font-size:42px;font-weight:900;color:${c.primary}">${s.n}</div><div style="color:#666;text-transform:uppercase;letter-spacing:1px;font-size:12px;margin-top:6px">${s.l}</div></div>`).join('')}
+    </div>
+  </div>
+</section>
+<section id="book" style="padding:60px 0;background:#0b0b0b;color:#fff;text-align:center">
+  <div class="wrap">
+    <h2 style="margin:0 0 12px;font-size:34px">Let's do this.</h2>
+    <p style="margin:0 0 22px;opacity:.85">${b.phone ? esc(b.phone) : 'DM us on Instagram'}</p>
+    <a class="btn" href="${b.phone ? `tel:${esc(b.phone)}` : '#'}" style="background:${c.accent}">Book Now</a>
+  </div>
+</section>
+<footer style="padding:24px 0;text-align:center;color:#888;font-size:13px">${esc(b.name)}</footer>`;
+  return shell(b.name, body, b);
+}
+
+export const meta = { id: 3, name: 'Bold Color Block', blurb: 'Vivid color slab · floating hero · stat strip' };
diff --git a/src/render/templates/template-4.js b/src/render/templates/template-4.js
new file mode 100644
index 0000000..1f9fde3
--- /dev/null
+++ b/src/render/templates/template-4.js
@@ -0,0 +1,31 @@
+// Template 4 — Minimalist Single-Column
+// Tight one-column layout, lots of whitespace, type-driven.
+import { esc } from '../../lib/escape.js';
+import { pickColors, placeholderHero, shell, brandStrip } from './_helpers.js';
+
+export function render(b) {
+  const c = pickColors(b);
+  const heroSrc = b.hero_image_url || placeholderHero(b, b.name);
+  const body = `
+${brandStrip(b)}
+<main style="max-width:680px;margin:0 auto;padding:80px 28px 120px">
+  <p style="margin:0 0 14px;color:${c.accent};letter-spacing:3px;text-transform:uppercase;font-size:11px">${esc(b.category || 'Salon')}</p>
+  <h1 style="font-family:Georgia,serif;font-size:56px;line-height:1.05;margin:0 0 24px;color:${c.primary}">${esc(b.name)}</h1>
+  <p style="font-size:18px;line-height:1.7;color:#333;margin:0 0 40px">${esc(b.about_text || `A small ${b.category || 'salon'} in ${b.city || 'town'}. By appointment.`)}</p>
+  <img src="${esc(heroSrc)}" alt="" style="width:100%;display:block;border-radius:4px;margin-bottom:40px">
+  <hr style="border:0;border-top:1px solid #ddd;margin:32px 0">
+  <dl style="display:grid;grid-template-columns:120px 1fr;row-gap:14px;column-gap:24px;font-size:15px;color:#444">
+    ${b.address ? `<dt style="color:#888">Address</dt><dd style="margin:0">${esc(b.address)}${b.city ? ', ' + esc(b.city) : ''}</dd>` : ''}
+    ${b.phone   ? `<dt style="color:#888">Phone</dt><dd style="margin:0"><a href="tel:${esc(b.phone)}" style="color:${c.primary}">${esc(b.phone)}</a></dd>` : ''}
+    ${b.email   ? `<dt style="color:#888">Email</dt><dd style="margin:0"><a href="mailto:${esc(b.email)}" style="color:${c.primary}">${esc(b.email)}</a></dd>` : ''}
+    ${b.years_in_business ? `<dt style="color:#888">Est.</dt><dd style="margin:0">${esc(String(new Date().getFullYear() - b.years_in_business))}</dd>` : ''}
+    ${b.owner_name ? `<dt style="color:#888">Owner</dt><dd style="margin:0">${esc(b.owner_name)}</dd>` : ''}
+  </dl>
+  <div style="margin-top:48px">
+    <a class="btn" href="${b.phone ? `tel:${esc(b.phone)}` : '#'}" style="background:${c.primary}">Book an appointment</a>
+  </div>
+</main>`;
+  return shell(b.name, body, b);
+}
+
+export const meta = { id: 4, name: 'Minimalist', blurb: 'One column · whitespace · type-driven' };
diff --git a/src/render/templates/template-5.js b/src/render/templates/template-5.js
new file mode 100644
index 0000000..592aefc
--- /dev/null
+++ b/src/render/templates/template-5.js
@@ -0,0 +1,54 @@
+// Template 5 — Magazine Grid
+// Mosaic of photo + name card + services + reviews tiles. Feels like a city-mag profile.
+import { esc } from '../../lib/escape.js';
+import { pickColors, placeholderHero, shell, brandStrip } from './_helpers.js';
+
+export function render(b) {
+  const c = pickColors(b);
+  const heroSrc = b.hero_image_url || placeholderHero(b, b.name);
+  const body = `
+${brandStrip(b)}
+<section style="padding:40px 0">
+  <div class="wrap">
+    <div style="display:grid;grid-template-columns:2fr 1fr;grid-template-rows:auto auto;gap:18px">
+      <div style="grid-row:1/3;background:#000;border-radius:14px;overflow:hidden">
+        <img src="${esc(heroSrc)}" alt="${esc(b.name)}" style="width:100%;height:100%;object-fit:cover;display:block;min-height:480px">
+      </div>
+      <div style="background:${c.primary};color:#fff;border-radius:14px;padding:32px">
+        <p style="margin:0 0 8px;opacity:.7;text-transform:uppercase;font-size:11px;letter-spacing:2px">${esc(b.category || 'Local')}</p>
+        <h1 style="margin:0 0 12px;font-family:Georgia,serif;font-size:36px;line-height:1.05">${esc(b.name)}</h1>
+        <p style="margin:0;font-size:14px;opacity:.92;line-height:1.5">${esc(b.city ? `${b.city}${b.state ? ', ' + b.state : ''}` : '')} ${b.years_in_business ? `· est. ${new Date().getFullYear() - b.years_in_business}` : ''}</p>
+      </div>
+      <div style="background:${c.accent};color:#fff;border-radius:14px;padding:24px;display:flex;flex-direction:column;justify-content:center">
+        <p style="margin:0 0 8px;font-size:13px;opacity:.85">Ready to book?</p>
+        <p style="margin:0 0 12px;font-size:22px;font-weight:700">${b.phone ? esc(b.phone) : 'DM us'}</p>
+        <a class="btn ghost" href="${b.phone ? `tel:${esc(b.phone)}` : '#'}" style="border-color:#fff;color:#fff;align-self:flex-start">Book →</a>
+      </div>
+    </div>
+  </div>
+</section>
+<section style="padding:40px 0">
+  <div class="wrap">
+    <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
+      ${['Cuts','Color','Treatments','Styling'].map(s => `
+        <div style="background:#f6f4f0;border-radius:10px;padding:24px">
+          <div style="width:32px;height:32px;border-radius:50%;background:${c.primary};margin-bottom:10px"></div>
+          <strong style="display:block;font-size:15px">${s}</strong>
+          <small style="color:#666">By appointment</small>
+        </div>`).join('')}
+    </div>
+  </div>
+</section>
+<section style="padding:60px 0;background:#0b0b0b;color:#fff">
+  <div class="wrap">
+    <h2 style="font-family:Georgia,serif;margin:0 0 24px">What people say</h2>
+    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:20px">
+      ${[1,2,3].map(_ => `<blockquote style="margin:0;padding:20px;background:#1a1a1a;border-radius:10px;font-size:14px;line-height:1.5;color:#ddd">"Real people, real care — easily our favorite spot in the neighborhood."<br><small style="opacity:.6;display:block;margin-top:10px">— ${esc(b.city || 'Local')} client</small></blockquote>`).join('')}
+    </div>
+  </div>
+</section>
+<footer style="padding:30px 0;text-align:center;color:#888;font-size:13px">${esc(b.name)} ${b.address ? '· ' + esc(b.address) : ''}</footer>`;
+  return shell(b.name, body, b);
+}
+
+export const meta = { id: 5, name: 'Magazine Grid', blurb: 'Mosaic tiles · review wall · city-mag feel' };
diff --git a/src/scrape/instagram_public.js b/src/scrape/instagram_public.js
new file mode 100644
index 0000000..b490f7f
--- /dev/null
+++ b/src/scrape/instagram_public.js
@@ -0,0 +1,110 @@
+// Public Instagram profile scrape — no API key.
+// Tries the JSON `?__a=1&__d=dis` endpoint first (often blocked anonymously),
+// then falls back to parsing the embedded `<meta>` tags + JSON-LD.
+//
+// Returns: { handle, url, full_name, biography, follower_count, post_count, profile_pic_url, last_post_at, raw }
+//
+// NOTE: Instagram aggressively rate-limits anonymous requests; treat null fields as
+// "blocked, retry later" rather than "doesn't exist". Use Browserbase if this becomes
+// a hot-path requirement (per Steve's feedback_browserbase_first.md).
+
+import { request } from 'undici';
+import * as cheerio from 'cheerio';
+
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 ' +
+           '(KHTML, like Gecko) Chrome/124.0 Safari/537.36';
+
+function cleanHandle(h) {
+  return String(h || '').trim().replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/^@/, '').replace(/\/.*$/, '');
+}
+
+export async function scrapeInstagramPublic(handleOrUrl) {
+  const handle = cleanHandle(handleOrUrl);
+  if (!handle) throw new Error('Empty handle');
+  const url = `https://www.instagram.com/${encodeURIComponent(handle)}/`;
+
+  const ac = new AbortController();
+  const t = setTimeout(() => ac.abort(), 15000);
+  let html = '';
+  let status = 0;
+  try {
+    const res = await request(url, {
+      method: 'GET',
+      headers: {
+        'user-agent': UA,
+        'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+        'accept-language': 'en-US,en;q=0.9',
+      },
+      maxRedirections: 5,
+      signal: ac.signal,
+    });
+    status = res.statusCode;
+    html = await res.body.text();
+  } catch (err) {
+    return { handle, url, error: String(err.message || err), blocked: true };
+  } finally {
+    clearTimeout(t);
+  }
+
+  if (status >= 400) {
+    return { handle, url, error: `HTTP ${status}`, blocked: status === 401 || status === 403 || status === 429 };
+  }
+
+  const $ = cheerio.load(html);
+  const ogTitle = $('meta[property="og:title"]').attr('content') || '';
+  const ogDescription = $('meta[property="og:description"]').attr('content') || '';
+  const ogImage = $('meta[property="og:image"]').attr('content') || null;
+  const ogUrl = $('meta[property="og:url"]').attr('content') || url;
+
+  // og:description on a profile typically reads:
+  //   "1,234 Followers, 567 Following, 89 Posts - See Instagram photos and videos from Full Name (@handle)"
+  let follower_count = null;
+  let following_count = null;
+  let post_count = null;
+  const m = ogDescription.match(/([\d,.]+(?:[KMB])?)\s+Followers?,\s+([\d,.]+(?:[KMB])?)\s+Following,\s+([\d,.]+(?:[KMB])?)\s+Posts?/i);
+  if (m) {
+    follower_count = humanToInt(m[1]);
+    following_count = humanToInt(m[2]);
+    post_count = humanToInt(m[3]);
+  }
+
+  // og:title is typically "Full Name (@handle) • Instagram photos and videos"
+  let full_name = null;
+  const t2 = ogTitle.match(/^(.+?)\s*\(@/);
+  if (t2) full_name = t2[1].trim();
+
+  // Bio shows up in JSON-LD on some pages
+  let biography = null;
+  $('script[type="application/ld+json"]').each((_, el) => {
+    try {
+      const j = JSON.parse($(el).text());
+      if (j && j.description && !biography) biography = j.description;
+    } catch { /* ignore */ }
+  });
+
+  const blocked = !follower_count && !full_name && !biography;
+
+  return {
+    handle,
+    url: ogUrl || url,
+    full_name,
+    biography,
+    follower_count,
+    following_count,
+    post_count,
+    profile_pic_url: ogImage,
+    last_post_at: null, // not parseable from public HTML reliably
+    blocked,
+    raw_status: status,
+  };
+}
+
+function humanToInt(s) {
+  if (s == null) return null;
+  s = String(s).replace(/,/g, '').trim();
+  const m = s.match(/^([\d.]+)([KMB])?$/i);
+  if (!m) return null;
+  const n = parseFloat(m[1]);
+  const mult = ({ K: 1e3, M: 1e6, B: 1e9 }[(m[2] || '').toUpperCase()]) || 1;
+  return Math.round(n * mult);
+}
diff --git a/src/scrape/website_meta.js b/src/scrape/website_meta.js
new file mode 100644
index 0000000..5cfae43
--- /dev/null
+++ b/src/scrape/website_meta.js
@@ -0,0 +1,123 @@
+// Fetch a small-business website and pull:
+//   - <title>, meta description, OG/Twitter meta (title/description/image/site_name)
+//   - hero image (og:image first, else first <img> with width/height)
+//   - color palette (best-effort — extracts inline hex colors + image-derived if palette skill present;
+//     here we use a light heuristic: scrape inline `color:` / `background-color:` hex values from the HTML+CSS)
+//   - phone (tel: links + first 10-digit pattern)
+//   - email (mailto: links)
+//
+// Local-only fetch w/ undici. No headless browser.
+
+import { request } from 'undici';
+import * as cheerio from 'cheerio';
+
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 ' +
+           '(KHTML, like Gecko) Chrome/124.0 Safari/537.36 small-business-builder/0.1';
+
+export async function fetchHtml(url, timeoutMs = 15000) {
+  const ac = new AbortController();
+  const t = setTimeout(() => ac.abort(), timeoutMs);
+  try {
+    const res = await request(url, {
+      method: 'GET',
+      headers: { 'user-agent': UA, 'accept': 'text/html,*/*' },
+      maxRedirections: 5,
+      signal: ac.signal,
+    });
+    if (res.statusCode >= 400) {
+      throw new Error(`HTTP ${res.statusCode}`);
+    }
+    const body = await res.body.text();
+    return { html: body, url, status: res.statusCode };
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+function abs(base, href) {
+  try { return new URL(href, base).toString(); } catch { return null; }
+}
+
+function extractHexColors(text, limit = 32) {
+  // Match #abc, #abcdef, rgb(...) — but normalize to hex-ish key
+  const seen = new Map();
+  const re = /#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g;
+  let m;
+  while ((m = re.exec(text)) && seen.size < limit) {
+    let h = m[1].toLowerCase();
+    if (h.length === 3) h = h.split('').map(c => c + c).join('');
+    const key = '#' + h;
+    seen.set(key, (seen.get(key) || 0) + 1);
+  }
+  // Sort by frequency descending
+  return [...seen.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c);
+}
+
+function extractPhone(text) {
+  // tel: first, then 10-digit pattern
+  const tel = text.match(/tel:([+\d\-\s().]{7,})/i);
+  if (tel) return tel[1].trim();
+  const m = text.match(/(\+?1[\s.\-]?)?\(?(\d{3})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})/);
+  if (m) return `(${m[2]}) ${m[3]}-${m[4]}`;
+  return null;
+}
+
+function extractEmail(text) {
+  const m = text.match(/mailto:([^"'>\s]+)/i);
+  if (m) return m[1].trim();
+  const e = text.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/);
+  return e ? e[0] : null;
+}
+
+export async function scrapeWebsiteMeta(url) {
+  if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
+  const { html } = await fetchHtml(url);
+  const $ = cheerio.load(html);
+
+  const meta = (name) =>
+    $(`meta[property="${name}"]`).attr('content') ||
+    $(`meta[name="${name}"]`).attr('content') ||
+    null;
+
+  const title = meta('og:title') || $('title').first().text().trim() || null;
+  const description = meta('og:description') || meta('description') || meta('twitter:description') || null;
+  const siteName = meta('og:site_name') || null;
+
+  let heroImage = meta('og:image') || meta('twitter:image') || null;
+  if (heroImage) heroImage = abs(url, heroImage);
+  if (!heroImage) {
+    // first reasonably-large <img>
+    $('img').each((_, el) => {
+      const src = $(el).attr('src');
+      if (!src) return;
+      const w = parseInt($(el).attr('width') || '0', 10);
+      const h = parseInt($(el).attr('height') || '0', 10);
+      if (heroImage) return;
+      if (!w || w >= 400 || !h || h >= 300) {
+        heroImage = abs(url, src);
+      }
+    });
+  }
+
+  // Inline-CSS palette (don't fetch external CSS for now — keep it light + safe)
+  const inlineCss = $('style').map((_, el) => $(el).text()).get().join('\n');
+  const palette = extractHexColors(inlineCss + '\n' + html);
+
+  // Phone + email — search visible text + tel/mailto links
+  const visibleText = $('body').text().replace(/\s+/g, ' ');
+  const phone = extractPhone(html) || extractPhone(visibleText);
+  const email = extractEmail(html) || extractEmail(visibleText);
+
+  return {
+    url,
+    title,
+    description,
+    site_name: siteName,
+    hero_image: heroImage,
+    palette,
+    color_primary: palette[0] || null,
+    color_accent: palette[1] || null,
+    phone,
+    email,
+  };
+}
diff --git a/src/server/index.js b/src/server/index.js
new file mode 100644
index 0000000..5fa12fe
--- /dev/null
+++ b/src/server/index.js
@@ -0,0 +1,287 @@
+// small-business-builder — Express server on PORT 9760
+//
+// Routes:
+//   GET  /                                  — grid of 3-12 mockups
+//   GET  /healthz
+//   GET  /biz/:slug                          — public profile (template 1 by default)
+//   GET  /biz/:slug/edit                     — left-panel edit + 5-template preview
+//   GET  /biz/:slug/template/:id             — full HTML render of one template
+//   GET  /biz/:slug/template/:id/preview     — same, sandboxed for iframe use
+//   POST /api/scrape-website                 — scrape OG/meta + create business
+//   POST /api/scrape-instagram               — scrape public IG profile + attach
+//   POST /api/biz/update                     — patch arbitrary business fields
+//   POST /api/biz/socials                    — upsert socials by platform
+//   GET  /admin                              — minimal admin (list + delete)
+//   POST /api/admin/delete                   — admin-token gated
+//
+// All app HTML is rendered by `src/render/grid.js` and `src/render/edit.js`.
+// All template HTML is rendered by `src/render/templates/index.js`.
+
+import 'dotenv/config';
+import express from 'express';
+import compression from 'compression';
+import cookieParser from 'cookie-parser';
+
+import { many, one, query } from '../lib/db.js';
+import { slugify } from '../lib/slug.js';
+import { log } from '../lib/log.js';
+import { renderGrid } from '../render/grid.js';
+import { renderEdit } from '../render/edit.js';
+import { renderTemplate } from '../render/templates/index.js';
+import { scrapeWebsiteMeta } from '../scrape/website_meta.js';
+import { scrapeInstagramPublic } from '../scrape/instagram_public.js';
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9760', 10);
+const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
+
+app.use(compression());
+app.use(express.json({ limit: '512kb' }));
+app.use(express.urlencoded({ extended: true, limit: '512kb' }));
+app.use(cookieParser());
+
+// --- helpers ----------------------------------------------------------------
+
+const ALLOWED_FIELDS = new Set([
+  'name','category','address','city','state','zip','latitude','longitude',
+  'phone','email','website','years_in_business','owner_name',
+  'hero_image_url','color_primary','color_accent','about_text','hours_json',
+  'tier','ranking_score',
+]);
+
+async function uniqueSlug(base) {
+  let s = slugify(base) || 'biz';
+  let i = 0;
+  while (await one('SELECT 1 FROM businesses WHERE slug=$1', [s])) {
+    i += 1;
+    s = `${slugify(base) || 'biz'}-${i}`;
+  }
+  return s;
+}
+
+async function loadBySlug(slug) {
+  return one('SELECT * FROM businesses WHERE slug=$1', [slug]);
+}
+
+// --- routes -----------------------------------------------------------------
+
+app.get('/healthz', async (_req, res) => {
+  try {
+    await query('SELECT 1');
+    res.json({ ok: true, port: PORT, ts: new Date().toISOString() });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: String(e.message || e) });
+  }
+});
+
+app.get('/', async (_req, res) => {
+  const businesses = await many(
+    `SELECT slug,name,category,city,state,tier,hero_image_url,color_primary,color_accent
+     FROM businesses ORDER BY updated_at DESC LIMIT 12`
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderGrid({ businesses }));
+});
+
+app.get('/biz/:slug', async (req, res) => {
+  const b = await loadBySlug(req.params.slug);
+  if (!b) return res.status(404).send('Not found');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderTemplate(1, b));
+});
+
+app.get('/biz/:slug/edit', async (req, res) => {
+  const b = await loadBySlug(req.params.slug);
+  if (!b) return res.status(404).send('Not found');
+  const socials = await many('SELECT * FROM business_socials WHERE business_id=$1', [b.id]);
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderEdit({ business: b, socials }));
+});
+
+app.get('/biz/:slug/template/:id', async (req, res) => {
+  const b = await loadBySlug(req.params.slug);
+  if (!b) return res.status(404).send('Not found');
+  const id = parseInt(req.params.id, 10);
+  if (!(id >= 1 && id <= 5)) return res.status(400).send('Bad template id');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderTemplate(id, b));
+});
+
+// Same content but with iframe-friendly headers
+app.get('/biz/:slug/template/:id/preview', async (req, res) => {
+  const b = await loadBySlug(req.params.slug);
+  if (!b) return res.status(404).send('Not found');
+  const id = parseInt(req.params.id, 10);
+  if (!(id >= 1 && id <= 5)) return res.status(400).send('Bad template id');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.set('x-frame-options', 'SAMEORIGIN');
+  res.send(renderTemplate(id, b));
+});
+
+// --- scrape APIs ------------------------------------------------------------
+
+app.post('/api/scrape-website', async (req, res) => {
+  try {
+    const { url, category, name } = req.body || {};
+    if (!url) return res.status(400).json({ error: 'url required' });
+    const meta = await scrapeWebsiteMeta(url);
+    const finalName = name || meta.title || meta.site_name || (new URL(meta.url)).hostname;
+    const slug = await uniqueSlug(finalName);
+
+    const result = await one(
+      `INSERT INTO businesses (
+         slug, name, category, website, hero_image_url,
+         color_primary, color_accent, about_text, phone, email, source_data_json
+       ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
+       RETURNING slug,id,name`,
+      [
+        slug,
+        finalName,
+        category || 'generic',
+        meta.url,
+        meta.hero_image,
+        meta.color_primary,
+        meta.color_accent,
+        meta.description,
+        meta.phone,
+        meta.email,
+        JSON.stringify({ scrape: meta }),
+      ]
+    );
+    log.info('scrape-website', { slug: result.slug, name: result.name });
+    res.json({ ok: true, slug: result.slug, id: result.id, scraped: meta });
+  } catch (e) {
+    log.err('scrape-website failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+app.post('/api/scrape-instagram', async (req, res) => {
+  try {
+    const { slug, handle } = req.body || {};
+    if (!slug || !handle) return res.status(400).json({ error: 'slug + handle required' });
+    const b = await loadBySlug(slug);
+    if (!b) return res.status(404).json({ error: 'business not found' });
+    const ig = await scrapeInstagramPublic(handle);
+    await query(
+      `INSERT INTO business_socials (business_id, platform, handle, url, follower_count, raw_json)
+       VALUES ($1,'instagram',$2,$3,$4,$5)
+       ON CONFLICT (business_id, platform)
+       DO UPDATE SET handle=EXCLUDED.handle, url=EXCLUDED.url,
+                     follower_count=EXCLUDED.follower_count,
+                     raw_json=EXCLUDED.raw_json,
+                     scraped_at=NOW()`,
+      [b.id, ig.handle, ig.url, ig.follower_count, JSON.stringify(ig)]
+    );
+    // If the IG bio gives us a name/about and the business doesn't have it, fill it in
+    if (ig.biography && !b.about_text) {
+      await query('UPDATE businesses SET about_text=$1 WHERE id=$2', [ig.biography, b.id]);
+    }
+    res.json({ ok: true, slug, instagram: ig });
+  } catch (e) {
+    log.err('scrape-instagram failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+// --- field updates ----------------------------------------------------------
+
+app.post('/api/biz/update', async (req, res) => {
+  try {
+    const { slug, ...fields } = req.body || {};
+    if (!slug) return res.status(400).json({ error: 'slug required' });
+    const b = await loadBySlug(slug);
+    if (!b) return res.status(404).json({ error: 'not found' });
+
+    const cols = [];
+    const vals = [];
+    let i = 1;
+    for (const [k, v] of Object.entries(fields)) {
+      if (!ALLOWED_FIELDS.has(k)) continue;
+      if (v === '' || v === undefined || v === null) continue;
+      cols.push(`${k} = $${i}`);
+      vals.push(v);
+      i += 1;
+    }
+    if (!cols.length) return res.json({ ok: true, slug, updated: 0 });
+    vals.push(b.id);
+    await query(`UPDATE businesses SET ${cols.join(', ')} WHERE id = $${i}`, vals);
+    res.json({ ok: true, slug, updated: cols.length });
+  } catch (e) {
+    log.err('biz/update failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+app.post('/api/biz/socials', async (req, res) => {
+  try {
+    const { slug, ...rest } = req.body || {};
+    if (!slug) return res.status(400).json({ error: 'slug required' });
+    const b = await loadBySlug(slug);
+    if (!b) return res.status(404).json({ error: 'not found' });
+
+    const platMap = {
+      instagram: 'https://instagram.com/',
+      tiktok: 'https://tiktok.com/@',
+      twitter: 'https://x.com/',
+      linkedin: 'https://linkedin.com/in/',
+      facebook: 'https://facebook.com/',
+      youtube: 'https://youtube.com/@',
+    };
+    let n = 0;
+    for (const [platform, baseUrl] of Object.entries(platMap)) {
+      const handle = rest[platform];
+      if (!handle) continue;
+      const cleaned = String(handle).trim().replace(/^@/, '').replace(/^https?:\/\/[^/]+\//, '');
+      await query(
+        `INSERT INTO business_socials (business_id, platform, handle, url)
+         VALUES ($1,$2,$3,$4)
+         ON CONFLICT (business_id, platform)
+         DO UPDATE SET handle=EXCLUDED.handle, url=EXCLUDED.url, scraped_at=NOW()`,
+        [b.id, platform, cleaned, baseUrl + cleaned]
+      );
+      n += 1;
+    }
+    res.json({ ok: true, slug, updated: n });
+  } catch (e) {
+    log.err('biz/socials failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+// --- admin ------------------------------------------------------------------
+
+app.get('/admin', async (req, res) => {
+  const tok = req.query.t || req.cookies?.smb_admin || '';
+  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
+    res.set('content-type', 'text/html; charset=utf-8');
+    return res.send(`<form><input name="t" placeholder="admin token"><button>Enter</button></form>`);
+  }
+  res.cookie('smb_admin', tok, { httpOnly: true, sameSite: 'lax' });
+  const rows = await many('SELECT id,slug,name,city,state,tier,created_at FROM businesses ORDER BY created_at DESC');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><meta charset="utf-8"><title>SMB Builder Admin</title>
+    <style>body{font-family:system-ui;padding:24px;max-width:980px;margin:0 auto}
+    table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid #eee;padding:8px;text-align:left}
+    a{color:#d97706}.del{color:#c00;cursor:pointer;border:0;background:none}</style>
+    <h1>Admin · ${rows.length} businesses</h1>
+    <table><tr><th>id</th><th>slug</th><th>name</th><th>city</th><th>tier</th><th></th></tr>
+    ${rows.map(r => `<tr><td>${r.id}</td><td><a href="/biz/${r.slug}">${r.slug}</a></td><td>${r.name}</td><td>${r.city || ''} ${r.state || ''}</td><td>${r.tier}</td>
+      <td><form method="POST" action="/api/admin/delete" style="display:inline" onsubmit="return confirm('Delete ${r.slug}?')"><input type="hidden" name="slug" value="${r.slug}"><button class="del">delete</button></form></td></tr>`).join('')}
+    </table>`);
+});
+
+app.post('/api/admin/delete', async (req, res) => {
+  const tok = req.cookies?.smb_admin || req.body?.t || '';
+  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) return res.status(401).json({ error: 'unauthorized' });
+  const slug = req.body?.slug;
+  if (!slug) return res.status(400).json({ error: 'slug required' });
+  await query('DELETE FROM businesses WHERE slug=$1', [slug]);
+  res.redirect('/admin');
+});
+
+// --- boot -------------------------------------------------------------------
+
+app.listen(PORT, () => {
+  log.info('smb-builder listening', { port: PORT });
+});

(oldest)  ·  back to Small Business Builder  ·  yolo: iter 2 trivial patches from claude-codex debate 980062b →