← back to Small Business Builder
yolo: baseline before overnight
ff56b97a2f7a86ac675f22dd76eca4f700438ed1 · 2026-05-03 23:46:26 -0700 · local
Files touched
A .env.exampleA .gitignoreA README.mdA ecosystem.config.cjsA migrations/001_init.sqlA package-lock.jsonA package.jsonA seed-data/README.mdA seed-data/la-salons.jsonA src/lib/db.jsA src/lib/escape.jsA src/lib/log.jsA src/lib/slug.jsA src/render/edit.jsA src/render/grid.jsA src/render/templates/_helpers.jsA src/render/templates/index.jsA src/render/templates/template-1.jsA src/render/templates/template-2.jsA src/render/templates/template-3.jsA src/render/templates/template-4.jsA src/render/templates/template-5.jsA src/scrape/instagram_public.jsA src/scrape/website_meta.jsA src/server/index.js
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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →