← back to Ventura Claw Leads
v0.1: ventura-claw-leads scaffold — directory + lead-routing for unregulated Ventura Blvd small businesses
77e482c059402e5f550a69e9a31f2a886b460ff9 · 2026-05-06 16:20:28 -0700 · Steve Abrams
A sister project to NPH that lifts the directory + lead-capture primitives
but explicitly excludes regulated industries (medical, legal, financial,
real-estate, contracting). 8-vertical green list enforced at the DB level
via CHECK constraint:
food · beauty · retail · fitness · pet_services · auto_detail · cleaning · creative
What's in v0.1:
- Express server on :9789, pm2 ventura-claw-leads
- Postgres ventura_claw_leads db, schema with businesses + business_interest +
session tables. updated_at trigger; partial indexes on hot paths.
- 8 public routes: / /find /business/:slug /business/:slug/contact (POST)
/for-businesses /about /privacy /terms /healthz; 404 + error handlers.
- EJS template stack: head + header + footer partials, home + find +
business profile + contact-result + for-businesses + about + legal +
404 + error pages. Cormorant Garamond + Inter, brass accent, dark/light
toggle, 1024 + 768 breakpoints from day one.
- 30 hand-picked demo businesses across 6 Ventura Blvd neighborhoods
(Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills) covering
all 8 verticals.
- Subscription model in DB (free/starter/standard/premier tiers); no
consumer money flow — businesses pay subscription, leads are forwarded
free of charge to the visitor.
- Compliance footer + ToS + Privacy positioning as 'directory and
advertising service' (not 'lead-coordination' which has different
legal weight). Explicit disclaimer about excluding licensed
professionals.
- Full e2e tested: 11 routes return correct status codes, contact form
POST writes to business_interest with the business join verified.
What's NOT in v0.1 (saved for v0.2):
- Stripe subscription billing (skeletons in .env.example, no UI)
- Admin dashboard for businesses (claim flow, profile edit, lead inbox)
- Lead-delivery email cron (the v0.7 NPH notify-queue pattern, scoped
here to forward each new business_interest row to the business email)
- /map view (will lift NPH's Leaflet impl)
- Public-records ingestion (LA County DBA + GoogleMyBusiness scrape)
Files touched
A .gitignoreA db/migrations/001_init.sqlA ecosystem.config.jsA lib/db.jsA lib/verticals.jsA package-lock.jsonA package.jsonA public/css/public.cssA public/css/theme.cssA public/js/theme-toggle.jsA routes/public.jsA scripts/seed-demo.jsA server.jsA views/partials/footer.ejsA views/partials/head.ejsA views/partials/header.ejsA views/public/404.ejsA views/public/about.ejsA views/public/business.ejsA views/public/contact-result.ejsA views/public/error.ejsA views/public/find.ejsA views/public/for-businesses.ejsA views/public/home.ejsA views/public/legal.ejs
Diff
commit 77e482c059402e5f550a69e9a31f2a886b460ff9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 16:20:28 2026 -0700
v0.1: ventura-claw-leads scaffold — directory + lead-routing for unregulated Ventura Blvd small businesses
A sister project to NPH that lifts the directory + lead-capture primitives
but explicitly excludes regulated industries (medical, legal, financial,
real-estate, contracting). 8-vertical green list enforced at the DB level
via CHECK constraint:
food · beauty · retail · fitness · pet_services · auto_detail · cleaning · creative
What's in v0.1:
- Express server on :9789, pm2 ventura-claw-leads
- Postgres ventura_claw_leads db, schema with businesses + business_interest +
session tables. updated_at trigger; partial indexes on hot paths.
- 8 public routes: / /find /business/:slug /business/:slug/contact (POST)
/for-businesses /about /privacy /terms /healthz; 404 + error handlers.
- EJS template stack: head + header + footer partials, home + find +
business profile + contact-result + for-businesses + about + legal +
404 + error pages. Cormorant Garamond + Inter, brass accent, dark/light
toggle, 1024 + 768 breakpoints from day one.
- 30 hand-picked demo businesses across 6 Ventura Blvd neighborhoods
(Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills) covering
all 8 verticals.
- Subscription model in DB (free/starter/standard/premier tiers); no
consumer money flow — businesses pay subscription, leads are forwarded
free of charge to the visitor.
- Compliance footer + ToS + Privacy positioning as 'directory and
advertising service' (not 'lead-coordination' which has different
legal weight). Explicit disclaimer about excluding licensed
professionals.
- Full e2e tested: 11 routes return correct status codes, contact form
POST writes to business_interest with the business join verified.
What's NOT in v0.1 (saved for v0.2):
- Stripe subscription billing (skeletons in .env.example, no UI)
- Admin dashboard for businesses (claim flow, profile edit, lead inbox)
- Lead-delivery email cron (the v0.7 NPH notify-queue pattern, scoped
here to forward each new business_interest row to the business email)
- /map view (will lift NPH's Leaflet impl)
- Public-records ingestion (LA County DBA + GoogleMyBusiness scrape)
---
.gitignore | 10 +
db/migrations/001_init.sql | 120 ++++
ecosystem.config.js | 23 +
lib/db.js | 42 ++
lib/verticals.js | 26 +
package-lock.json | 1255 +++++++++++++++++++++++++++++++++++++++
package.json | 26 +
public/css/public.css | 97 +++
public/css/theme.css | 80 +++
public/js/theme-toggle.js | 10 +
routes/public.js | 161 +++++
scripts/seed-demo.js | 90 +++
server.js | 92 +++
views/partials/footer.ejs | 35 ++
views/partials/head.ejs | 38 ++
views/partials/header.ejs | 20 +
views/public/404.ejs | 11 +
views/public/about.ejs | 22 +
views/public/business.ejs | 74 +++
views/public/contact-result.ejs | 19 +
views/public/error.ejs | 11 +
views/public/find.ejs | 51 ++
views/public/for-businesses.ejs | 75 +++
views/public/home.ejs | 50 ++
views/public/legal.ejs | 77 +++
25 files changed, 2515 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cedb552
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/raw/
diff --git a/db/migrations/001_init.sql b/db/migrations/001_init.sql
new file mode 100644
index 0000000..393479b
--- /dev/null
+++ b/db/migrations/001_init.sql
@@ -0,0 +1,120 @@
+-- Ventura Claw Leads v0.1 — directory + lead-routing for unregulated brick-and-
+-- mortar businesses on Ventura Blvd. Mirrors the NPH schema where the shape
+-- works; diverges where the licensed-trade specifics don't apply.
+--
+-- HARD RULES this schema enforces:
+-- - businesses.vertical is constrained to the 8-vertical green list. Adding
+-- any "regulated" vertical (medical / legal / financial / real-estate /
+-- contracting) requires a new migration that explicitly amends the CHECK,
+-- forcing a code review where someone sees the regulatory issue.
+-- - No application_fee_amount / Stripe Connect tables. Consumers never pay
+-- this platform; businesses pay subscription only. Per-lead billing is
+-- metered against the subscription, not collected from the consumer.
+
+CREATE TABLE IF NOT EXISTS businesses (
+ id BIGSERIAL PRIMARY KEY,
+ slug TEXT UNIQUE NOT NULL,
+ business_name TEXT NOT NULL,
+ vertical TEXT NOT NULL CHECK (vertical IN (
+ 'food', -- restaurants, cafes, bakeries, delis, food trucks
+ 'beauty', -- hair, nails, brows/lashes, barbers, makeup, tanning, non-medical spa
+ 'retail', -- boutiques, jewelry, shoes, accessories, home goods, gift shops
+ 'fitness', -- yoga, pilates, gyms, dance, martial arts, personal trainers
+ 'pet_services', -- groomers, walkers, daycare, pet sitters, training, supply
+ 'auto_detail', -- detailing, hand car wash, window tint, mobile detailers
+ 'cleaning', -- house cleaners, organizers, residential moving, staging
+ 'creative' -- photographers, DJs, event planners, florists, designers
+ )),
+ description TEXT,
+ headline TEXT,
+
+ -- Address (Ventura Blvd corridor)
+ street TEXT,
+ city TEXT, -- Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills
+ state TEXT DEFAULT 'CA',
+ zip TEXT,
+ neighborhood TEXT, -- finer cut than city
+ latitude DOUBLE PRECISION,
+ longitude DOUBLE PRECISION,
+
+ -- Contact + socials (no consumer payment surface)
+ phone TEXT,
+ email TEXT,
+ website TEXT,
+ instagram_handle TEXT,
+
+ -- Subscription tier (business-side billing). 'free' = directory shell only.
+ tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free','starter','standard','premier')),
+ subscription_status TEXT NOT NULL DEFAULT 'inactive',
+ stripe_customer_id TEXT,
+ stripe_subscription_id TEXT,
+
+ -- Claim flow (lifted from NPH).
+ claim_status TEXT NOT NULL DEFAULT 'unclaimed' CHECK (claim_status IN ('unclaimed','self','claimed')),
+ claimed_by_email TEXT,
+ claimed_at TIMESTAMPTZ,
+
+ -- Lifecycle
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','pending','hidden','removed')),
+ verified BOOLEAN NOT NULL DEFAULT false,
+ source TEXT NOT NULL DEFAULT 'seed', -- 'seed', 'public_records', 'self_signup'
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS businesses_vertical_idx ON businesses (vertical) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS businesses_city_idx ON businesses (city) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS businesses_neighborhood_idx ON businesses (neighborhood) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS businesses_geo_idx ON businesses (latitude, longitude) WHERE latitude IS NOT NULL;
+
+-- Lead-capture queue per business. The lead is the actual product — when a
+-- consumer fills the contact form, the business pays a per-lead fee against
+-- their subscription (charged at month-end, not at capture time).
+CREATE TABLE IF NOT EXISTS business_interest (
+ id BIGSERIAL PRIMARY KEY,
+ business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+ consumer_name TEXT,
+ consumer_email TEXT NOT NULL,
+ consumer_phone TEXT,
+ message TEXT,
+ zip TEXT,
+ source TEXT NOT NULL DEFAULT 'profile_form', -- 'profile_form', 'home_search', 'map_pin'
+ ip_hash TEXT,
+ user_agent TEXT,
+ -- Per-vertical extras: party_size for restaurants, service_kind for beauty,
+ -- preferred_date, etc. Schema-light JSON so we don't migrate per vertical.
+ extra_fields JSONB DEFAULT '{}'::jsonb,
+
+ -- Lifecycle
+ delivered_at TIMESTAMPTZ DEFAULT now(), -- when we routed it to the business
+ delivery_email TEXT, -- the email we sent the lead to
+ delivery_msg_id TEXT, -- SMTP message-id for audit
+ billed_at TIMESTAMPTZ, -- when we metered the per-lead fee
+ bill_amount_cents INTEGER,
+ consumer_unsubscribed BOOLEAN NOT NULL DEFAULT false,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS business_interest_business_idx ON business_interest (business_id);
+CREATE INDEX IF NOT EXISTS business_interest_unbilled_idx ON business_interest (billed_at) WHERE billed_at IS NULL;
+CREATE INDEX IF NOT EXISTS business_interest_email_idx ON business_interest (consumer_email);
+
+-- Express session store. connect-pg-simple expects this exact shape.
+CREATE TABLE IF NOT EXISTS "session" (
+ "sid" varchar NOT NULL COLLATE "default",
+ "sess" json NOT NULL,
+ "expire" timestamp(6) NOT NULL,
+ CONSTRAINT "session_pkey" PRIMARY KEY ("sid")
+);
+CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire");
+
+-- updated_at autotouch for businesses.
+CREATE OR REPLACE FUNCTION vcl_touch_updated_at() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS businesses_updated_at ON businesses;
+CREATE TRIGGER businesses_updated_at BEFORE UPDATE ON businesses
+ FOR EACH ROW EXECUTE FUNCTION vcl_touch_updated_at();
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..18c74a8
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,23 @@
+module.exports = {
+ apps: [{
+ name: 'ventura-claw-leads',
+ script: './server.js',
+ cwd: __dirname,
+ instances: 1,
+ exec_mode: 'fork',
+ env: {
+ NODE_ENV: 'development',
+ PORT: '9789'
+ },
+ env_production: {
+ NODE_ENV: 'production',
+ PORT: '9789'
+ },
+ error_file: './logs/err.log',
+ out_file: './logs/out.log',
+ log_date_format: 'YYYY-MM-DD HH:mm:ss',
+ max_memory_restart: '300M',
+ autorestart: true,
+ watch: false
+ }]
+};
diff --git a/lib/db.js b/lib/db.js
new file mode 100644
index 0000000..6886232
--- /dev/null
+++ b/lib/db.js
@@ -0,0 +1,42 @@
+const { Pool } = require('pg');
+
+// Empty PGPASSWORD triggers SASL auth which fails with "client password must
+// be a string"; strip it so trust-auth on local dev works.
+if (process.env.PGPASSWORD === '') delete process.env.PGPASSWORD;
+
+const pgConfig = {
+ host: process.env.PGHOST || 'localhost',
+ port: parseInt(process.env.PGPORT || '5432', 10),
+ database: process.env.PGDATABASE || 'ventura_claw_leads',
+ user: process.env.PGUSER || process.env.USER,
+ max: 10,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 8000
+};
+if (process.env.PGPASSWORD && process.env.PGPASSWORD.length > 0) pgConfig.password = process.env.PGPASSWORD;
+// Allow DATABASE_URL to override piecewise config in production.
+if (process.env.NODE_ENV === 'production' && process.env.DATABASE_URL) {
+ Object.keys(pgConfig).forEach(k => delete pgConfig[k]);
+ pgConfig.connectionString = process.env.DATABASE_URL;
+}
+const pool = new Pool(pgConfig);
+
+pool.on('error', (err) => {
+ console.error('[db] idle client error', err.code, err.message);
+});
+
+async function query(text, params) {
+ return pool.query(text, params);
+}
+
+async function one(text, params) {
+ const r = await pool.query(text, params);
+ return r.rows[0] || null;
+}
+
+async function many(text, params) {
+ const r = await pool.query(text, params);
+ return r.rows;
+}
+
+module.exports = { pool, query, one, many };
diff --git a/lib/verticals.js b/lib/verticals.js
new file mode 100644
index 0000000..862a7d3
--- /dev/null
+++ b/lib/verticals.js
@@ -0,0 +1,26 @@
+// Single source of truth for the 8-vertical green list. Used by route filters,
+// view labels, seed data, and the vertical CHECK constraint in 001_init.sql.
+//
+// HARD RULE — adding a vertical requires:
+// 1. New entry here
+// 2. New migration amending the businesses.vertical CHECK constraint
+// 3. Code review confirming the new vertical does NOT trigger state-board
+// referral-fee restrictions (medical/legal/financial/real-estate/contracting).
+
+const VERTICALS = [
+ { key: 'food', label: 'Food & drink', blurb: 'Restaurants, cafes, bakeries, delis, food trucks', icon: '🍽' },
+ { key: 'beauty', label: 'Beauty', blurb: 'Hair, nails, brows, lashes, barbers, makeup, non-medical spa', icon: '✨' },
+ { key: 'retail', label: 'Retail', blurb: 'Boutiques, jewelry, shoes, accessories, home goods, gifts', icon: '🛍' },
+ { key: 'fitness', label: 'Fitness', blurb: 'Yoga, pilates, gyms, dance, martial arts, personal trainers', icon: '🧘' },
+ { key: 'pet_services', label: 'Pet services', blurb: 'Groomers, walkers, daycare, sitters, training', icon: '🐾' },
+ { key: 'auto_detail', label: 'Auto detail', blurb: 'Detailing, hand car wash, window tint — non-mechanical', icon: '🚗' },
+ { key: 'cleaning', label: 'Cleaning', blurb: 'House cleaners, organizers, residential moving, staging', icon: '🧹' },
+ { key: 'creative', label: 'Creative', blurb: 'Photographers, DJs, event planners, florists, designers', icon: '🎨' }
+];
+
+const BY_KEY = Object.fromEntries(VERTICALS.map(v => [v.key, v]));
+
+function label(key) { return BY_KEY[key]?.label || key; }
+function icon(key) { return BY_KEY[key]?.icon || '•'; }
+
+module.exports = { VERTICALS, BY_KEY, label, icon };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f20db37
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1255 @@
+{
+ "name": "ventura-claw-leads",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ventura-claw-leads",
+ "version": "0.1.0",
+ "dependencies": {
+ "connect-pg-simple": "^9.0.1",
+ "dotenv": "^16.4.5",
+ "ejs": "^3.1.10",
+ "express": "^4.21.0",
+ "express-rate-limit": "^8.5.0",
+ "express-session": "^1.18.0",
+ "helmet": "^8.1.0",
+ "luxon": "^3.5.0",
+ "morgan": "^1.10.0",
+ "pg": "^8.13.1",
+ "slugify": "^1.6.6"
+ }
+ },
+ "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/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/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/basic-auth/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/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/connect-pg-simple": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-9.0.1.tgz",
+ "integrity": "sha512-BuwWJH3K3aLpONkO9s12WhZ9ceMjIBxIJAh0JD9x4z1Y9nShmWqZvge5PG/+4j2cIOcguUoa2PSQ4HO/oTsrVg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg": "^8.8.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/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/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/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/express-rate-limit": {
+ "version": "8.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
+ "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-session": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
+ "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "~0.7.2",
+ "cookie-signature": "~1.0.7",
+ "debug": "~2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "~5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/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/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/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/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/morgan": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
+ "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth": "~2.0.1",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-finished": "~2.3.0",
+ "on-headers": "~1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/morgan/node_modules/on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/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/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/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/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/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/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/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/slugify": {
+ "version": "1.6.9",
+ "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
+ "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/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/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/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/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/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..560d79b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "ventura-claw-leads",
+ "version": "0.1.0",
+ "description": "Lead-routing directory for unregulated small businesses on Ventura Blvd. Restaurants, salons, retail, fitness, pet services, auto detail, cleaning, tutoring — businesses pay subscription/per-lead, consumer pays nothing. Sister project to NPH; explicitly NOT for licensed professionals (medical, legal, financial, real estate, contracting).",
+ "main": "server.js",
+ "type": "commonjs",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "NODE_ENV=development node server.js",
+ "test": "node --test tests/**/*.test.js",
+ "seed": "node scripts/seed-demo.js"
+ },
+ "dependencies": {
+ "connect-pg-simple": "^9.0.1",
+ "dotenv": "^16.4.5",
+ "ejs": "^3.1.10",
+ "express": "^4.21.0",
+ "express-rate-limit": "^8.5.0",
+ "express-session": "^1.18.0",
+ "helmet": "^8.1.0",
+ "luxon": "^3.5.0",
+ "morgan": "^1.10.0",
+ "pg": "^8.13.1",
+ "slugify": "^1.6.6"
+ }
+}
diff --git a/public/css/public.css b/public/css/public.css
new file mode 100644
index 0000000..45821ce
--- /dev/null
+++ b/public/css/public.css
@@ -0,0 +1,97 @@
+/* Ventura Claw — page-specific styles, lifted from NPH conventions. */
+
+/* Hero */
+.hero { padding: 80px 28px 56px; max-width: 1100px; margin: 0 auto; }
+.hero h1 { max-width: 18ch; }
+.hero-search { display: flex; gap: 8px; margin: 28px 0 12px; max-width: 720px; flex-wrap: wrap; }
+.hero-search input[type="search"] { flex: 1; min-width: 240px; }
+.hero-meta { display: flex; gap: 24px; margin-top: 16px; font-size: 12px; color: var(--fg-muted); }
+.hero-meta strong { color: var(--fg); font-family: var(--serif); font-size: 14px; font-weight: 500; }
+
+/* Vertical chips */
+.verticals { padding: 32px 28px; max-width: 1200px; margin: 0 auto; }
+.verticals-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
+.vertical-chip { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border: 1px solid var(--border); background: var(--bg); border-radius: 8px; text-decoration: none; }
+.vertical-chip:hover { border-color: var(--brass); background: var(--bg-alt); }
+.vertical-chip .v-icon { font-size: 22px; line-height: 1; }
+.vertical-chip .v-meta { display: flex; flex-direction: column; gap: 2px; }
+.vertical-chip .v-label { font-size: 14px; font-weight: 500; color: var(--fg); }
+.vertical-chip .v-count { font-size: 11px; color: var(--fg-muted); }
+
+/* Find page */
+.find-page { padding: 48px 28px; max-width: 1440px; margin: 0 auto; }
+.find-filters { background: var(--bg-alt); padding: 18px 20px; border-radius: 8px; margin: 24px 0; }
+.filter-row { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 12px; align-items: end; }
+.business-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px; margin-top: 24px; }
+.business-card { display: flex; flex-direction: column; gap: 8px; padding: 18px 20px; border: 1px solid var(--border); background: var(--bg); border-radius: 4px; transition: border-color 0.15s, transform 0.15s; }
+.business-card:hover { border-color: var(--brass); transform: translateY(-1px); }
+.business-card .biz-vertical { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--brass); }
+.business-card .biz-name { font-family: var(--serif); font-size: 19px; font-weight: 500; line-height: 1.2; margin: 0; }
+.business-card .biz-loc { font-size: 13px; color: var(--fg-muted); margin: 0; }
+.business-card .biz-headline { font-size: 13px; color: var(--fg); margin: 6px 0 0; }
+.biz-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
+.biz-badge { display: inline-block; padding: 2px 8px; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; border-radius: 999px; background: var(--bg-alt); color: var(--fg-muted); }
+.biz-badge.is-claimed { background: #dcfce7; color: #166534; }
+.biz-badge.is-premier { background: var(--brass); color: #fff; }
+.biz-badge.is-standard { background: #e0e7ff; color: #3730a3; }
+
+/* Business profile page */
+.profile-hero { padding: 56px 28px 32px; max-width: 1100px; margin: 0 auto; }
+.profile-hero h1 { font-size: clamp(32px, 4.5vw, 52px); margin: 8px 0 12px; }
+.profile-hero .biz-meta { font-size: 14px; color: var(--fg-muted); }
+.profile-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
+
+.profile-body { display: grid; grid-template-columns: 2fr 1fr; gap: 56px; padding: 24px 28px 64px; max-width: 1100px; margin: 0 auto; }
+.profile-bio p { font-size: 16px; line-height: 1.7; max-width: 60ch; }
+.profile-side { display: flex; flex-direction: column; gap: 18px; }
+.side-card { padding: 18px 20px; border: 1px solid var(--border); background: var(--bg-alt); border-radius: 4px; }
+.side-card h3 { margin: 0 0 8px; font-size: 14px; font-family: var(--sans); font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-muted); }
+.side-card .info-row { display: flex; gap: 8px; padding: 6px 0; font-size: 14px; border-bottom: 1px dotted var(--border); }
+.side-card .info-row:last-child { border-bottom: none; }
+.side-card .info-row strong { min-width: 80px; color: var(--fg-muted); font-weight: 500; font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
+
+/* Contact form */
+.contact-form { padding: 18px 20px; border: 2px solid var(--brass); background: var(--bg-alt); border-radius: 8px; }
+.contact-form h3 { margin: 0 0 6px; font-family: var(--serif); font-size: 22px; font-weight: 500; }
+.contact-form .helper { font-size: 12px; color: var(--fg-muted); margin: 0 0 14px; }
+.contact-form .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+
+/* Long-form */
+.long-form { padding: 48px 28px 80px; max-width: 720px; margin: 0 auto; }
+.long-form p { font-size: 16px; line-height: 1.65; }
+.long-form ul { font-size: 15px; line-height: 1.65; padding-left: 22px; }
+.long-form li { margin: 6px 0; }
+
+/* For-businesses tier cards */
+.tiers { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin: 32px 0; }
+.tier-card { padding: 24px; border: 1px solid var(--border); background: var(--bg); border-radius: 6px; }
+.tier-card.is-featured { border-color: var(--brass); border-width: 2px; box-shadow: 0 0 0 4px rgba(184,134,11,0.08); }
+.tier-card h3 { margin: 0 0 4px; font-family: var(--serif); font-size: 24px; }
+.tier-card .tier-price { font-family: var(--serif); font-size: 32px; color: var(--brass); margin: 8px 0; }
+.tier-card .tier-price small { font-size: 13px; color: var(--fg-muted); }
+.tier-card ul { list-style: none; padding: 0; margin: 12px 0 0; }
+.tier-card li { font-size: 13px; padding: 5px 0; border-bottom: 1px dotted var(--border); }
+.tier-card li:last-child { border-bottom: none; }
+
+/* Tablet */
+@media (max-width: 1024px) and (min-width: 769px) {
+ .footer-inner { grid-template-columns: repeat(2, 1fr); }
+ .filter-row { grid-template-columns: 1fr 1fr; }
+ .filter-row > button { grid-column: 1 / -1; }
+ .profile-body { grid-template-columns: 3fr 2fr; gap: 32px; }
+}
+
+/* Phone */
+@media (max-width: 768px) {
+ .header-inner { padding: 12px 16px; gap: 12px; flex-wrap: wrap; }
+ .brand-name { display: none; }
+ .primary-nav { gap: 12px; flex-wrap: wrap; }
+ .footer-inner { grid-template-columns: 1fr; gap: 24px; }
+ .hero { padding: 48px 16px 32px; }
+ .find-page { padding: 32px 16px; }
+ .profile-body { grid-template-columns: 1fr; gap: 32px; padding: 16px 16px 48px; }
+ .filter-row { grid-template-columns: 1fr; }
+ .business-grid { grid-template-columns: 1fr; }
+ .contact-form .row { grid-template-columns: 1fr; }
+ .footer-fineprint { flex-direction: column; gap: 8px; }
+}
diff --git a/public/css/theme.css b/public/css/theme.css
new file mode 100644
index 0000000..6fb127b
--- /dev/null
+++ b/public/css/theme.css
@@ -0,0 +1,80 @@
+/* Ventura Claw — design tokens. Cormorant Garamond display + Inter body, brass accent. */
+:root {
+ --bg: #fbf9f4;
+ --bg-alt: #f3efe3;
+ --fg: #0e0e0e;
+ --fg-muted: #5b5750;
+ --border: #d8d2c0;
+ --border-strong: #aaa392;
+ --brass: #b8860b;
+ --brass-soft: #d8a834;
+ --serif: 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
+ --sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+}
+[data-theme="dark"] {
+ --bg: #0e0e0e;
+ --bg-alt: #1a1a1a;
+ --fg: #f3efe3;
+ --fg-muted: #9c958a;
+ --border: #2c2a26;
+ --border-strong: #4a4640;
+ --brass: #d8a834;
+ --brass-soft: #f0c75d;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body { background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
+
+a { color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--border); transition: border-color 0.15s, color 0.15s, opacity 0.15s; }
+a:hover { color: var(--brass); border-bottom-color: var(--brass); opacity: 1; }
+h1, h2, h3, h4 { font-family: var(--serif); font-weight: 500; line-height: 1.2; letter-spacing: -0.01em; }
+h1 { font-size: clamp(32px, 5vw, 56px); margin: 0 0 16px; }
+h2 { font-size: clamp(24px, 3vw, 36px); margin: 24px 0 12px; }
+h3 { font-size: 18px; margin: 16px 0 8px; }
+.kicker { font-family: var(--sans); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--brass); margin: 0 0 8px; }
+.lede { font-size: 18px; line-height: 1.55; color: var(--fg-muted); max-width: 60ch; }
+.muted { color: var(--fg-muted); }
+.display-sm { font-size: clamp(28px, 4vw, 44px); margin: 0 0 12px; }
+
+.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 18px; font-size: 14px; font-weight: 500; letter-spacing: 0.02em; border-radius: 2px; border: 1px solid var(--fg); cursor: pointer; text-decoration: none; transition: all 0.15s; }
+.btn-primary { background: var(--fg); color: var(--bg); }
+.btn-primary:hover { background: var(--brass); border-color: var(--brass); color: #fff; }
+.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border-strong); }
+.btn-ghost:hover { background: var(--bg-alt); border-color: var(--fg); }
+.btn-lg { padding: 14px 24px; font-size: 15px; }
+.btn-sm { padding: 7px 12px; font-size: 12px; }
+
+input[type="text"], input[type="email"], input[type="tel"], input[type="number"], input[type="search"], select, textarea {
+ font-family: inherit; font-size: 14px; padding: 10px 12px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 2px; width: 100%;
+}
+input:focus, select:focus, textarea:focus { outline: 2px solid var(--brass); outline-offset: -1px; border-color: var(--brass); }
+label { display: block; margin: 0 0 12px; font-size: 13px; color: var(--fg-muted); }
+label > input, label > select, label > textarea { margin-top: 4px; }
+
+/* Header */
+.site-header { border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 50; }
+.header-inner { display: flex; align-items: center; gap: 24px; padding: 16px 28px; max-width: 1440px; margin: 0 auto; }
+.brand { display: flex; align-items: baseline; gap: 10px; border-bottom: none; }
+.brand-mark { font-family: var(--serif); font-weight: 600; font-size: 20px; letter-spacing: 0.04em; color: var(--brass); }
+.brand-name { font-family: var(--serif); font-weight: 500; font-size: 18px; letter-spacing: 0.02em; }
+.primary-nav { display: flex; gap: 22px; margin-left: auto; }
+.primary-nav a { border: none; font-size: 13px; letter-spacing: 0.04em; color: var(--fg-muted); }
+.primary-nav a.is-current, .primary-nav a:hover { color: var(--fg); }
+.header-actions { display: flex; gap: 10px; align-items: center; }
+.theme-toggle { background: transparent; border: 1px solid var(--border); padding: 6px 10px; cursor: pointer; border-radius: 999px; color: var(--fg); font-size: 14px; line-height: 1; }
+.theme-toggle .t-dark { display: none; }
+[data-theme="dark"] .theme-toggle .t-light { display: none; }
+[data-theme="dark"] .theme-toggle .t-dark { display: inline; }
+
+/* Footer */
+.site-footer { background: var(--bg-alt); border-top: 1px solid var(--border); margin-top: 96px; padding: 56px 28px 24px; }
+.footer-inner { max-width: 1440px; margin: 0 auto; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; }
+.footer-brand { font-family: var(--serif); font-size: 20px; font-weight: 500; }
+.footer-tag { font-size: 13px; color: var(--fg-muted); margin: 4px 0 8px; }
+.footer-contact { font-size: 13px; }
+.footer-col h4 { font-family: var(--sans); font-weight: 600; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--fg-muted); margin: 0 0 10px; }
+.footer-col a { display: block; font-size: 13px; padding: 4px 0; border: none; color: var(--fg-muted); }
+.footer-col a:hover { color: var(--fg); }
+.footer-fineprint { max-width: 1440px; margin: 32px auto 0; padding-top: 24px; border-top: 1px solid var(--border); display: flex; gap: 24px; flex-wrap: wrap; font-size: 11px; color: var(--fg-muted); line-height: 1.55; }
+.footer-fineprint span:first-child { white-space: nowrap; }
diff --git a/public/js/theme-toggle.js b/public/js/theme-toggle.js
new file mode 100644
index 0000000..5e5bfd0
--- /dev/null
+++ b/public/js/theme-toggle.js
@@ -0,0 +1,10 @@
+(function () {
+ const root = document.documentElement;
+ document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
+ root.setAttribute('data-theme', next);
+ try { localStorage.setItem('vcl-theme', next); } catch (e) {}
+ });
+ });
+})();
diff --git a/routes/public.js b/routes/public.js
new file mode 100644
index 0000000..ce10fe3
--- /dev/null
+++ b/routes/public.js
@@ -0,0 +1,161 @@
+const express = require('express');
+const crypto = require('node:crypto');
+const db = require('../lib/db');
+const { VERTICALS, BY_KEY } = require('../lib/verticals');
+const router = express.Router();
+
+router.get('/', async (req, res, next) => {
+ try {
+ const stats = await db.one(`
+ SELECT COUNT(*)::int AS total,
+ COUNT(DISTINCT vertical)::int AS vertical_count,
+ COUNT(DISTINCT city)::int AS city_count
+ FROM businesses WHERE status = 'active'
+ `);
+ const featured = await db.many(`
+ SELECT slug, business_name, vertical, neighborhood, city, headline
+ FROM businesses
+ WHERE status = 'active' AND (claim_status IN ('self','claimed') OR tier != 'free')
+ ORDER BY tier = 'premier' DESC, tier = 'standard' DESC, RANDOM()
+ LIMIT 9
+ `);
+ const verticalCounts = await db.many(`
+ SELECT vertical, COUNT(*)::int AS n FROM businesses WHERE status = 'active' GROUP BY vertical
+ `);
+ const verticalCountMap = Object.fromEntries(verticalCounts.map(r => [r.vertical, r.n]));
+
+ res.render('public/home', {
+ title: 'Ventura Claw — directory of Ventura Blvd small businesses',
+ metaDescription: `Find local restaurants, salons, retail, fitness, pet, auto detail, cleaning, and creative businesses on Ventura Blvd. ${stats.total} verified listings across Sherman Oaks, Studio City, Encino, Tarzana, and Woodland Hills.`,
+ stats, featured, verticals: VERTICALS, verticalCountMap
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/find', async (req, res, next) => {
+ try {
+ const q = (req.query.q || '').trim().slice(0, 120);
+ const v = (req.query.vertical || '').trim();
+ const city = (req.query.city || '').trim().slice(0, 60);
+
+ const where = [`status = 'active'`];
+ const params = [];
+ if (q) {
+ params.push(`%${q.toLowerCase()}%`);
+ where.push(`(LOWER(business_name) LIKE $${params.length} OR LOWER(headline) LIKE $${params.length} OR LOWER(neighborhood) LIKE $${params.length})`);
+ }
+ if (v && BY_KEY[v]) {
+ params.push(v);
+ where.push(`vertical = $${params.length}`);
+ }
+ if (city) {
+ params.push(city);
+ where.push(`city ILIKE $${params.length}`);
+ }
+
+ const businesses = await db.many(`
+ SELECT id, slug, business_name, vertical, headline, neighborhood, city, state, tier, claim_status, verified
+ FROM businesses
+ WHERE ${where.join(' AND ')}
+ ORDER BY tier = 'premier' DESC, tier = 'standard' DESC, claim_status IN ('self','claimed') DESC, business_name ASC
+ LIMIT 200
+ `, params);
+
+ res.render('public/find', {
+ title: 'Find a business — Ventura Claw',
+ metaDescription: `Browse ${businesses.length} local businesses on Ventura Blvd.${v ? ' Filtered by ' + (BY_KEY[v]?.label || v) + '.' : ''}`,
+ businesses, q, v, city, verticals: VERTICALS
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/business/:slug', async (req, res, next) => {
+ try {
+ const biz = await db.one(`
+ SELECT * FROM businesses WHERE slug = $1 AND status IN ('active','pending')
+ `, [req.params.slug]);
+ if (!biz) return res.status(404).render('public/404', { title: 'Not found' });
+
+ res.render('public/business', {
+ title: `${biz.business_name} · ${BY_KEY[biz.vertical]?.label || biz.vertical} on Ventura Blvd`,
+ metaDescription: biz.headline || `${biz.business_name} — ${BY_KEY[biz.vertical]?.label || biz.vertical} in ${biz.neighborhood || biz.city || 'the Ventura Blvd corridor'}.`,
+ business: biz,
+ verticalMeta: BY_KEY[biz.vertical] || null
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/business/:slug/contact', async (req, res, next) => {
+ try {
+ const biz = await db.one(`SELECT id, slug, business_name, claim_status, status FROM businesses WHERE slug = $1`, [req.params.slug]);
+ if (!biz) return res.status(404).render('public/404', { title: 'Not found' });
+
+ const consumer_name = String(req.body.name || '').trim().slice(0, 120);
+ const consumer_email = String(req.body.email || '').trim().toLowerCase().slice(0, 200);
+ const consumer_phone = String(req.body.phone || '').trim().slice(0, 32) || null;
+ const message = String(req.body.message || '').trim().slice(0, 1500) || null;
+ const zip = String(req.body.zip || '').trim().slice(0, 10) || null;
+
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(consumer_email)) {
+ return res.status(400).render('public/contact-result', {
+ title: 'Email needed', business: biz, ok: false,
+ error: 'Please enter a valid email address so the business can reply.'
+ });
+ }
+ if (!consumer_name) {
+ return res.status(400).render('public/contact-result', {
+ title: 'Name needed', business: biz, ok: false,
+ error: 'Please enter your name so the business knows how to address you.'
+ });
+ }
+
+ const ip_hash = crypto
+ .createHash('sha256')
+ .update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
+ .digest('hex')
+ .slice(0, 16);
+
+ await db.query(`
+ INSERT INTO business_interest
+ (business_id, consumer_name, consumer_email, consumer_phone, message, zip, source, ip_hash, user_agent)
+ VALUES ($1, $2, $3, $4, $5, $6, 'profile_form', $7, $8)
+ `, [biz.id, consumer_name, consumer_email, consumer_phone, message, zip, ip_hash, String(req.headers['user-agent'] || '').slice(0, 200)]);
+
+ res.render('public/contact-result', {
+ title: 'Message sent', business: biz, ok: true,
+ consumer_name, consumer_email
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/for-businesses', (req, res) => {
+ res.render('public/for-businesses', {
+ title: 'For businesses — Ventura Claw',
+ metaDescription: 'List your Ventura Blvd business on Ventura Claw. Subscription tiers from $49/mo. Pay-per-lead billing for qualified consumer inquiries. No setup fees.'
+ });
+});
+
+router.get('/about', (req, res) => {
+ res.render('public/about', {
+ title: 'About Ventura Claw',
+ metaDescription: 'Ventura Claw is a directory and lead-routing service for unregulated small businesses on Ventura Blvd. We connect locals to nearby restaurants, salons, retail, fitness, pet, auto, cleaning, and creative services.'
+ });
+});
+
+router.get('/privacy', (req, res) => {
+ res.render('public/legal', { title: 'Privacy · Ventura Claw', kind: 'privacy' });
+});
+router.get('/terms', (req, res) => {
+ res.render('public/legal', { title: 'Terms · Ventura Claw', kind: 'terms' });
+});
+
+router.get('/healthz', async (req, res) => {
+ try {
+ await db.query('SELECT 1');
+ res.json({ ok: true, ts: Date.now() });
+ } catch (e) {
+ res.status(503).json({ ok: false, error: 'db_unreachable' });
+ }
+});
+
+module.exports = router;
diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js
new file mode 100644
index 0000000..f495fde
--- /dev/null
+++ b/scripts/seed-demo.js
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+// v0.1 demo seed: 30 plausible-but-fictional businesses across 8 verticals on
+// Ventura Blvd from Sherman Oaks → Woodland Hills. NOT real businesses — these
+// are demo placeholders so the site renders meaningfully on first page load.
+// Phase 2 swaps these for real public-records ingestion (LA County DBA + GoogleMyBusiness scrape).
+
+require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
+const slugify = require('slugify');
+const db = require('../lib/db');
+
+const HOODS = [
+ { city: 'Sherman Oaks', neighborhood: 'Sherman Oaks', zip: '91423' },
+ { city: 'Sherman Oaks', neighborhood: 'Sherman Oaks', zip: '91403' },
+ { city: 'Studio City', neighborhood: 'Studio City', zip: '91604' },
+ { city: 'Encino', neighborhood: 'Encino', zip: '91436' },
+ { city: 'Tarzana', neighborhood: 'Tarzana', zip: '91356' },
+ { city: 'Woodland Hills', neighborhood: 'Woodland Hills', zip: '91364' }
+];
+
+const SEED = [
+ // food (5)
+ { vertical: 'food', business_name: 'Olive & Salt Trattoria', headline: 'Wood-fired Italian on the Boulevard, family-run since 1998.', tier: 'standard', claim_status: 'self', verified: true, hood: 0, street: '14823 Ventura Blvd' },
+ { vertical: 'food', business_name: 'Boulevard Bagels', headline: 'NY-style hand-rolled bagels and house-cured lox, open 6am.', tier: 'starter', claim_status: 'self', hood: 1, street: '13900 Ventura Blvd' },
+ { vertical: 'food', business_name: 'Pho Encino', headline: 'Hand-pulled noodles, 14-hour bone broth, Vietnamese coffee on tap.', tier: 'free', claim_status: 'unclaimed', hood: 3, street: '17207 Ventura Blvd' },
+ { vertical: 'food', business_name: 'The Studio City Tap', headline: 'Neighborhood gastropub with 32 California taps and weekend brunch.', tier: 'premier', claim_status: 'claimed', verified: true, hood: 2, street: '12060 Ventura Pl' },
+ { vertical: 'food', business_name: 'Tarzana Taqueria', headline: 'Family-recipe carnitas, hand-pressed tortillas, salsa flight on the side.', tier: 'starter', claim_status: 'self', hood: 4, street: '18568 Ventura Blvd' },
+
+ // beauty (5)
+ { vertical: 'beauty', business_name: 'Gilt Hair Studio', headline: 'Balayage, color correction, Olaplex specialists. Bilingual stylists.', tier: 'premier', claim_status: 'claimed', verified: true, hood: 0, street: '15300 Ventura Blvd' },
+ { vertical: 'beauty', business_name: 'Brow Bar Studio City', headline: 'Threading, lamination, tinting — drop-in welcome.', tier: 'standard', claim_status: 'self', hood: 2, street: '12200 Ventura Blvd' },
+ { vertical: 'beauty', business_name: 'Lacquer Nail Lounge', headline: 'Non-toxic gel manicures, lashes, paraffin treatments.', tier: 'starter', claim_status: 'self', hood: 1, street: '14025 Ventura Blvd' },
+ { vertical: 'beauty', business_name: 'Encino Barbers', headline: 'Old-school cuts, hot-towel shaves, walk-in friendly.', tier: 'free', claim_status: 'unclaimed', hood: 3, street: '16101 Ventura Blvd' },
+ { vertical: 'beauty', business_name: 'WHL Skincare Studio', headline: 'Custom facials, microcurrent, hydrafacial. Licensed estheticians.', tier: 'standard', claim_status: 'self', verified: true, hood: 5, street: '21100 Ventura Blvd' },
+
+ // retail (4)
+ { vertical: 'retail', business_name: 'Boulevard Books', headline: 'Independent bookstore — staff picks, signed first editions, weekly readings.', tier: 'standard', claim_status: 'self', verified: true, hood: 2, street: '12148 Ventura Blvd' },
+ { vertical: 'retail', business_name: 'Sherman Oaks Vintage', headline: '70s and 80s mid-century furniture, lighting, ceramics — restored in-house.', tier: 'starter', claim_status: 'self', hood: 0, street: '14529 Ventura Blvd' },
+ { vertical: 'retail', business_name: 'Cactus & Cane Plants', headline: 'Curated houseplants, hand-thrown pots, plant-care workshops.', tier: 'free', claim_status: 'unclaimed', hood: 4, street: '18712 Ventura Blvd' },
+ { vertical: 'retail', business_name: 'The Linen Closet', headline: 'Bedding, towels, table linens — small-batch from Italy and Portugal.', tier: 'premier', claim_status: 'claimed', verified: true, hood: 3, street: '17118 Ventura Blvd' },
+
+ // fitness (4)
+ { vertical: 'fitness', business_name: 'Encino Pilates Reformer', headline: 'Classical pilates, semi-private and private. Beginner-friendly.', tier: 'standard', claim_status: 'self', hood: 3, street: '16410 Ventura Blvd' },
+ { vertical: 'fitness', business_name: 'Studio City Yoga Collective', headline: 'Vinyasa, restorative, and prenatal classes seven days a week.', tier: 'standard', claim_status: 'self', verified: true, hood: 2, street: '11900 Ventura Blvd' },
+ { vertical: 'fitness', business_name: 'Ironclad Strength', headline: 'Small-group barbell coaching. No mirrors, no music — just lifts.', tier: 'starter', claim_status: 'self', hood: 0, street: '14710 Ventura Blvd' },
+ { vertical: 'fitness', business_name: 'WHL Dance Academy', headline: 'Adult ballet, jazz, hip-hop — drop-in and 10-class packs.', tier: 'free', claim_status: 'unclaimed', hood: 5, street: '20801 Ventura Blvd' },
+
+ // pet_services (3)
+ { vertical: 'pet_services', business_name: 'Boulevard Bark', headline: 'Dog daycare, grooming, and pickup/dropoff service.', tier: 'standard', claim_status: 'self', verified: true, hood: 0, street: '14828 Ventura Blvd' },
+ { vertical: 'pet_services', business_name: 'Studio City Dog Walk', headline: 'Two-walk minimum, midday relief, GPS tracked. Insured.', tier: 'starter', claim_status: 'self', hood: 2, street: '12050 Ventura Blvd' },
+ { vertical: 'pet_services', business_name: 'The Cat Spa Tarzana', headline: 'Stress-free cat-only grooming. Mobile or in-shop.', tier: 'free', claim_status: 'unclaimed', hood: 4, street: '18900 Ventura Blvd' },
+
+ // auto_detail (3)
+ { vertical: 'auto_detail', business_name: 'Sherman Auto Detail', headline: 'Hand wash, paint correction, ceramic coating. Same-day appointments.', tier: 'standard', claim_status: 'self', verified: true, hood: 0, street: '14400 Ventura Blvd' },
+ { vertical: 'auto_detail', business_name: 'Boulevard Window Tint', headline: 'Lifetime-warranty ceramic tint, 3M certified.', tier: 'starter', claim_status: 'self', hood: 4, street: '18230 Ventura Blvd' },
+ { vertical: 'auto_detail', business_name: 'Mobile Detail by Marco', headline: 'We come to you — luxury detail at home, office, or hotel.', tier: 'premier', claim_status: 'claimed', verified: true, hood: 3, street: '16322 Ventura Blvd' },
+
+ // cleaning (3)
+ { vertical: 'cleaning', business_name: 'White Glove Home', headline: 'Weekly, biweekly, or one-time deep clean. Eco-friendly products.', tier: 'standard', claim_status: 'self', hood: 1, street: '14010 Ventura Blvd' },
+ { vertical: 'cleaning', business_name: 'NeatNest Organizers', headline: 'Closet, kitchen, garage — hands-on professional organizing.', tier: 'starter', claim_status: 'self', hood: 2, street: '12450 Ventura Blvd' },
+ { vertical: 'cleaning', business_name: 'Tarzana Move Helpers', headline: 'Two-person small-move team. In-home shuffle, donation runs, packing.', tier: 'free', claim_status: 'unclaimed', hood: 4, street: '18550 Ventura Blvd' },
+
+ // creative (3)
+ { vertical: 'creative', business_name: 'Studio City Florist', headline: 'Daily fresh-cut bouquets, weddings, weekly office subscriptions.', tier: 'standard', claim_status: 'self', verified: true, hood: 2, street: '11815 Ventura Blvd' },
+ { vertical: 'creative', business_name: 'Boulevard Photo Co.', headline: 'Family portraits, headshots, real-estate photography. Studio + on-location.', tier: 'starter', claim_status: 'self', hood: 0, street: '14945 Ventura Blvd' },
+ { vertical: 'creative', business_name: 'Encino Event Pros', headline: 'Birthdays, bar/bat mitzvahs, corporate. Full-service planning.', tier: 'premier', claim_status: 'claimed', verified: true, hood: 3, street: '16133 Ventura Blvd' }
+];
+
+(async () => {
+ let inserted = 0, skipped = 0;
+ for (const s of SEED) {
+ const hood = HOODS[s.hood];
+ const slug = slugify(`${s.business_name} ${hood.neighborhood}`, { lower: true, strict: true });
+ const description = `${s.business_name} is a ${s.vertical.replace('_', ' ')} business serving the ${hood.neighborhood} neighborhood and the wider Ventura Blvd corridor. ${s.headline}`;
+
+ const r = await db.query(`
+ INSERT INTO businesses
+ (slug, business_name, vertical, headline, description, street, city, state, zip, neighborhood,
+ tier, claim_status, verified, status, source)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'CA', $8, $9, $10, $11, $12, 'active', 'seed')
+ ON CONFLICT (slug) DO NOTHING
+ RETURNING id
+ `, [slug, s.business_name, s.vertical, s.headline, description, s.street, hood.city, hood.zip, hood.neighborhood,
+ s.tier || 'free', s.claim_status || 'unclaimed', !!s.verified]);
+
+ if (r.rowCount > 0) inserted++;
+ else skipped++;
+ }
+ console.log(`[seed] inserted=${inserted} skipped=${skipped} (already present)`);
+ await db.pool.end();
+})().catch(err => { console.error('[seed] FATAL', err.message); process.exit(1); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..bcc3a06
--- /dev/null
+++ b/server.js
@@ -0,0 +1,92 @@
+require('dotenv').config();
+
+const express = require('express');
+const path = require('path');
+const helmet = require('helmet');
+const morgan = require('morgan');
+const session = require('express-session');
+const PgSession = require('connect-pg-simple')(session);
+const rateLimit = require('express-rate-limit');
+
+const db = require('./lib/db');
+const publicRoutes = require('./routes/public');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9789', 10);
+const IS_PROD = process.env.NODE_ENV === 'production';
+
+app.set('trust proxy', 1);
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, 'views'));
+
+app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
+ fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
+ scriptSrc: ["'self'", "'unsafe-inline'"],
+ imgSrc: ["'self'", 'data:', 'https:'],
+ connectSrc: ["'self'"],
+ frameSrc: ["'self'"],
+ frameAncestors: ["'none'"],
+ formAction: ["'self'"],
+ baseUri: ["'self'"],
+ objectSrc: ["'none'"],
+ scriptSrcAttr: ["'none'"],
+ upgradeInsecureRequests: IS_PROD ? [] : null
+ }
+ }
+}));
+app.use(morgan(IS_PROD ? 'combined' : 'dev'));
+
+app.use(session({
+ store: new PgSession({ pool: db.pool, tableName: 'session' }),
+ secret: process.env.SESSION_SECRET || 'dev-only-replace-in-production',
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ httpOnly: true,
+ secure: IS_PROD,
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60 * 1000
+ }
+}));
+
+app.use(express.urlencoded({ extended: false, limit: '64kb' }));
+app.use(express.json({ limit: '64kb' }));
+app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
+app.use('/css', express.static(path.join(__dirname, 'public/css'), { maxAge: '7d' }));
+app.use('/js', express.static(path.join(__dirname, 'public/js'), { maxAge: '7d' }));
+app.use('/img', express.static(path.join(__dirname, 'public/img'), { maxAge: '7d' }));
+
+// Locals exposed to every EJS template.
+app.use((req, res, next) => {
+ res.locals.publicUrl = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
+ res.locals.path = req.path;
+ res.locals.csrfToken = ''; // v0.1 — no CSRF middleware yet (read-only public routes only); add in v0.2 with admin
+ next();
+});
+
+const contactLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000, max: 8,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
+app.use('/business/:slug/contact', contactLimiter);
+
+app.use('/', publicRoutes);
+
+app.use((req, res) => {
+ res.status(404).render('public/404', { title: 'Not Found' });
+});
+
+app.use((err, req, res, next) => {
+ console.error('[unhandled]', err.stack || err.message);
+ if (res.headersSent) return next(err);
+ res.status(500).render('public/error', { title: 'Something went wrong', message: 'Please reload — if it persists, email info@venturaclaw.com.' });
+});
+
+app.listen(PORT, () => {
+ console.log(`[ventura-claw-leads] listening on :${PORT} (env=${process.env.NODE_ENV || 'development'})`);
+});
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
new file mode 100644
index 0000000..d1e353e
--- /dev/null
+++ b/views/partials/footer.ejs
@@ -0,0 +1,35 @@
+<footer class="site-footer">
+ <div class="footer-inner">
+ <div class="footer-col">
+ <div class="footer-brand">Ventura Claw</div>
+ <p class="footer-tag">Directory of Ventura Blvd small businesses · search · lead routing</p>
+ <p class="footer-contact"><a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a></p>
+ </div>
+ <div class="footer-col">
+ <h4>Browse</h4>
+ <a href="/find?vertical=food">Food & drink</a>
+ <a href="/find?vertical=beauty">Beauty</a>
+ <a href="/find?vertical=retail">Retail</a>
+ <a href="/find?vertical=fitness">Fitness</a>
+ <a href="/find?vertical=pet_services">Pet services</a>
+ <a href="/find?vertical=creative">Creative</a>
+ </div>
+ <div class="footer-col">
+ <h4>Businesses</h4>
+ <a href="/for-businesses">List your business</a>
+ <a href="/for-businesses#tiers">Pricing</a>
+ </div>
+ <div class="footer-col">
+ <h4>Company</h4>
+ <a href="/about">About</a>
+ <a href="/privacy">Privacy</a>
+ <a href="/terms">Terms</a>
+ </div>
+ </div>
+ <div class="footer-fineprint">
+ <span>© <%= new Date().getFullYear() %> Ventura Claw</span>
+ <span><strong>Ventura Claw is a directory and advertising service.</strong> Listings on this site are independent businesses, not employees or contractors of Ventura Claw. We do not warrant goods or services provided by listed businesses; verify offerings, pricing, and qualifications directly before any purchase or contract.</span>
+ </div>
+</footer>
+</body>
+</html>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
new file mode 100644
index 0000000..6182e39
--- /dev/null
+++ b/views/partials/head.ejs
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><%= typeof title !== 'undefined' ? title : 'Ventura Claw' %></title>
+ <%
+ var _metaDesc = (typeof metaDescription !== 'undefined' && metaDescription)
+ ? metaDescription
+ : 'Ventura Claw — directory and lead-routing service for small businesses on Ventura Blvd.';
+ var _canonicalBase = (typeof publicUrl !== 'undefined' && publicUrl) ? publicUrl.replace(/\/+$/, '') : 'https://venturaclaw.com';
+ var _canonicalUrl = _canonicalBase + (typeof path === 'string' ? path : '/');
+ %>
+ <meta name="description" content="<%= _metaDesc %>">
+ <link rel="canonical" href="<%= _canonicalUrl %>">
+ <meta property="og:type" content="website">
+ <meta property="og:url" content="<%= _canonicalUrl %>">
+ <meta property="og:site_name" content="Ventura Claw">
+ <meta property="og:title" content="<%= typeof title !== 'undefined' ? title : 'Ventura Claw' %>">
+ <meta property="og:description" content="<%= _metaDesc %>">
+ <meta name="theme-color" content="#0e0e0e">
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="/css/theme.css">
+ <link rel="stylesheet" href="/css/public.css">
+ <script>
+ (function(){
+ try {
+ var t = localStorage.getItem('vcl-theme');
+ if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ document.documentElement.setAttribute('data-theme', t);
+ } catch (e) {}
+ })();
+ </script>
+ <script src="/js/theme-toggle.js" defer></script>
+</head>
+<body>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
new file mode 100644
index 0000000..59121d5
--- /dev/null
+++ b/views/partials/header.ejs
@@ -0,0 +1,20 @@
+<header class="site-header">
+ <div class="header-inner">
+ <a href="/" class="brand" aria-label="Ventura Claw home">
+ <span class="brand-mark">VC</span>
+ <span class="brand-name">Ventura Claw</span>
+ </a>
+ <% var _p = (typeof path === 'string') ? path : ''; %>
+ <nav class="primary-nav" aria-label="Primary">
+ <a href="/find" class="<%= _p === '/find' ? 'is-current' : '' %>">Find a business</a>
+ <a href="/for-businesses" class="<%= _p === '/for-businesses' ? 'is-current' : '' %>">For businesses</a>
+ <a href="/about" class="<%= _p === '/about' ? 'is-current' : '' %>">About</a>
+ </nav>
+ <div class="header-actions">
+ <button type="button" class="theme-toggle" aria-label="Toggle theme" data-theme-toggle>
+ <span class="t-light" aria-hidden="true">☼</span>
+ <span class="t-dark" aria-hidden="true">☾</span>
+ </button>
+ </div>
+ </div>
+</header>
diff --git a/views/public/404.ejs b/views/public/404.ejs
new file mode 100644
index 0000000..9a5279a
--- /dev/null
+++ b/views/public/404.ejs
@@ -0,0 +1,11 @@
+<%- include('../partials/head', { title: typeof title !== 'undefined' ? title : 'Not Found' }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="text-align:center">
+ <p class="kicker">404</p>
+ <h1 class="display-sm">We couldn't find that page.</h1>
+ <p class="lede" style="margin:0 auto">It may have moved, or the business may have closed. Try searching the directory.</p>
+ <p style="margin-top:32px"><a href="/find" class="btn btn-primary">Browse all businesses →</a></p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/about.ejs b/views/public/about.ejs
new file mode 100644
index 0000000..d2fb93a
--- /dev/null
+++ b/views/public/about.ejs
@@ -0,0 +1,22 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <p class="kicker">About</p>
+ <h1 class="display-sm">A directory for the Boulevard.</h1>
+ <p class="lede">Ventura Claw is a directory and lead-routing service for unregulated small businesses on Ventura Blvd, from Sherman Oaks through Woodland Hills. Restaurants, salons, retail, fitness, pet services, auto detail, cleaning, and creative pros — every business worth finding, on one map.</p>
+
+ <h2>How matching works</h2>
+ <p>Search by category, neighborhood, or name. Click into any profile to see the business's contact info or send them a message directly. Ventura Claw forwards your message to the business; their reply lands in your inbox without anyone in between.</p>
+
+ <h2>What we don't list</h2>
+ <p>State-licensed professionals are intentionally excluded — no doctors, lawyers, dentists, financial advisors, real-estate agents, insurance agents, or licensed contractors. Those professions have their own referral and fee-splitting rules; we list businesses where the work is done by the business, not by a regulated individual.</p>
+
+ <h2>Coverage</h2>
+ <p>Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills — the Ventura Blvd corridor. We're starting with hand-curated listings of businesses we know are open and serving locals.</p>
+
+ <h2>Contact</h2>
+ <p>Questions, corrections, or want to be listed? <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a>.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/business.ejs b/views/public/business.ejs
new file mode 100644
index 0000000..ccbda5f
--- /dev/null
+++ b/views/public/business.ejs
@@ -0,0 +1,74 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<%
+ function _safeUrl(u) {
+ if (!u) return null;
+ try { var x = new URL(u.startsWith('http') ? u : 'https://' + u); return (x.protocol === 'http:' || x.protocol === 'https:') ? x.toString() : null; }
+ catch(e){ return null; }
+ }
+ var _site = _safeUrl(business.website);
+ var _addressOneline = [business.street, business.neighborhood || business.city, business.state, business.zip].filter(Boolean).join(', ');
+%>
+
+<section class="profile-hero">
+ <p class="kicker"><%= verticalMeta ? verticalMeta.icon + ' ' + verticalMeta.label : business.vertical %></p>
+ <h1><%= business.business_name %></h1>
+ <p class="biz-meta"><%= [business.neighborhood, business.city, business.state].filter(Boolean).join(' · ') %></p>
+ <% if (business.headline) { %><p class="lede" style="margin-top:14px"><%= business.headline %></p><% } %>
+ <div class="profile-actions">
+ <a href="#contact" class="btn btn-primary btn-lg">Send a message</a>
+ <% if (_site) { %><a href="<%= _site %>" target="_blank" rel="noopener nofollow" class="btn btn-ghost">Visit website ↗</a><% } %>
+ <% if (business.phone) { %><a href="tel:<%= business.phone %>" class="btn btn-ghost">Call <%= business.phone %></a><% } %>
+ </div>
+</section>
+
+<section class="profile-body">
+ <div class="profile-bio">
+ <% if (business.description) { %>
+ <h2>About</h2>
+ <p><%= business.description %></p>
+ <% } else { %>
+ <h2>About</h2>
+ <p class="muted">This business hasn't added a full description yet. Reach out below — they'll send you details directly.</p>
+ <% } %>
+
+ <h2 id="contact" style="margin-top:32px">Send <%= business.business_name %> a message</h2>
+ <form class="contact-form" action="/business/<%= business.slug %>/contact" method="post">
+ <p class="helper">Goes straight to the business — no middleman, no markup. They'll reply to your email.</p>
+ <div class="row">
+ <label>Your name<input type="text" name="name" required maxlength="120"></label>
+ <label>Email<input type="email" name="email" required maxlength="200"></label>
+ </div>
+ <div class="row">
+ <label>Phone (optional)<input type="tel" name="phone" maxlength="32"></label>
+ <label>ZIP (optional)<input type="text" name="zip" maxlength="10"></label>
+ </div>
+ <label>Message<textarea name="message" rows="4" maxlength="1500" placeholder="What are you looking for? Hours, services, pricing — they'll know best."></textarea></label>
+ <button type="submit" class="btn btn-primary btn-lg">Send message →</button>
+ <p class="muted" style="font-size:11px;margin-top:10px;line-height:1.5">By sending, you agree your contact info will be shared with <%= business.business_name %> only. Ventura Claw does not arrange transactions, set prices, or warrant goods or services.</p>
+ </form>
+ </div>
+
+ <aside class="profile-side">
+ <div class="side-card">
+ <h3>Quick info</h3>
+ <% if (_addressOneline) { %><div class="info-row"><strong>Address</strong><span><%= _addressOneline %></span></div><% } %>
+ <% if (business.phone) { %><div class="info-row"><strong>Phone</strong><span><a href="tel:<%= business.phone %>"><%= business.phone %></a></span></div><% } %>
+ <% if (business.email) { %><div class="info-row"><strong>Email</strong><span><a href="mailto:<%= business.email %>"><%= business.email %></a></span></div><% } %>
+ <% if (_site) { %><div class="info-row"><strong>Web</strong><span><a href="<%= _site %>" target="_blank" rel="noopener nofollow"><%= _site.replace(/^https?:\/\//, '').replace(/\/+$/,'') %></a></span></div><% } %>
+ <% if (business.instagram_handle) { %><div class="info-row"><strong>Instagram</strong><span><a href="https://instagram.com/<%= encodeURIComponent(business.instagram_handle) %>" target="_blank" rel="noopener nofollow">@<%= business.instagram_handle %></a></span></div><% } %>
+ <div class="info-row"><strong>Status</strong><span><%= business.claim_status === 'unclaimed' ? 'Public listing — not yet claimed by the business' : 'Claimed by the business' %></span></div>
+ </div>
+
+ <% if (business.claim_status === 'unclaimed') { %>
+ <div class="side-card" style="background:var(--bg);border-color:var(--brass)">
+ <h3 style="color:var(--brass)">Own this business?</h3>
+ <p style="font-size:13px;line-height:1.5;margin:0 0 10px">Claim your listing for free — control your description, photos, and how customers reach you.</p>
+ <a href="/for-businesses" class="btn btn-primary btn-sm">Claim this listing →</a>
+ </div>
+ <% } %>
+ </aside>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/contact-result.ejs b/views/public/contact-result.ejs
new file mode 100644
index 0000000..26eda1c
--- /dev/null
+++ b/views/public/contact-result.ejs
@@ -0,0 +1,19 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <% if (ok) { %>
+ <p class="kicker">Message sent</p>
+ <h1 class="display-sm">Your message is on its way to <%= business.business_name %>.</h1>
+ <p class="lede">We forwarded your note to the business directly. They'll reply to <strong><%= consumer_email %></strong> — usually within a business day.</p>
+ <p class="muted" style="margin-top:24px;font-size:13px">In the meantime, <a href="/find">browse other businesses</a> on Ventura Blvd.</p>
+ <a href="/business/<%= business.slug %>" class="btn btn-ghost btn-sm" style="margin-top:24px">← Back to <%= business.business_name %></a>
+ <% } else { %>
+ <p class="kicker">Almost there</p>
+ <h1 class="display-sm">Something needs fixing.</h1>
+ <p class="lede"><%= error %></p>
+ <a href="/business/<%= business.slug %>#contact" class="btn btn-primary" style="margin-top:16px">← Try again</a>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/error.ejs b/views/public/error.ejs
new file mode 100644
index 0000000..4b1867f
--- /dev/null
+++ b/views/public/error.ejs
@@ -0,0 +1,11 @@
+<%- include('../partials/head', { title: typeof title !== 'undefined' ? title : 'Something went wrong' }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="text-align:center">
+ <p class="kicker">Server error</p>
+ <h1 class="display-sm">Something went wrong on our end.</h1>
+ <p class="lede" style="margin:0 auto"><%= typeof message !== 'undefined' && message ? message : 'Please reload the page. If it persists, email info@venturaclaw.com.' %></p>
+ <p style="margin-top:32px"><a href="/" class="btn btn-primary">Back to home →</a></p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/find.ejs b/views/public/find.ejs
new file mode 100644
index 0000000..d16cad3
--- /dev/null
+++ b/views/public/find.ejs
@@ -0,0 +1,51 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="find-page">
+ <p class="kicker">Find a business</p>
+ <h1 class="display-sm">Search Ventura Blvd</h1>
+
+ <form action="/find" method="get" class="find-filters" role="search">
+ <div class="filter-row">
+ <label>Search<input type="search" name="q" value="<%= q %>" placeholder="Name, neighborhood, or category"></label>
+ <label>Category
+ <select name="vertical">
+ <option value="">All categories</option>
+ <% verticals.forEach(function(vv){ %>
+ <option value="<%= vv.key %>" <%= v === vv.key ? 'selected' : '' %>><%= vv.icon %> <%= vv.label %></option>
+ <% }); %>
+ </select>
+ </label>
+ <label>City<input type="text" name="city" value="<%= city %>" placeholder="Sherman Oaks"></label>
+ <button type="submit" class="btn btn-primary">Search</button>
+ </div>
+ </form>
+
+ <p class="muted" style="margin: 16px 0 8px"><%= businesses.length %> business<%= businesses.length === 1 ? '' : 'es' %> found</p>
+
+ <% if (businesses.length === 0) { %>
+ <div style="padding:40px 0;text-align:center">
+ <h3>No matches yet.</h3>
+ <p class="muted">Try a broader search, or <a href="/find">browse all listings</a>. We're adding new businesses every week.</p>
+ </div>
+ <% } else { %>
+ <div class="business-grid">
+ <% businesses.forEach(function(b){ %>
+ <a class="business-card" href="/business/<%= b.slug %>">
+ <p class="biz-vertical"><%= verticals.find(function(vv){return vv.key===b.vertical}) ? verticals.find(function(vv){return vv.key===b.vertical}).label : b.vertical %></p>
+ <p class="biz-name"><%= b.business_name %></p>
+ <p class="biz-loc"><%= [b.neighborhood, b.city, b.state].filter(Boolean).join(' · ') %></p>
+ <% if (b.headline) { %><p class="biz-headline"><%= b.headline %></p><% } %>
+ <div class="biz-badges">
+ <% if (b.tier === 'premier') { %><span class="biz-badge is-premier">Featured</span>
+ <% } else if (b.tier === 'standard') { %><span class="biz-badge is-standard">Standard</span><% } %>
+ <% if (b.claim_status === 'self' || b.claim_status === 'claimed') { %><span class="biz-badge is-claimed">Claimed</span><% } %>
+ <% if (b.verified) { %><span class="biz-badge">Verified</span><% } %>
+ </div>
+ </a>
+ <% }); %>
+ </div>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/for-businesses.ejs b/views/public/for-businesses.ejs
new file mode 100644
index 0000000..f8139b5
--- /dev/null
+++ b/views/public/for-businesses.ejs
@@ -0,0 +1,75 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <p class="kicker">For businesses</p>
+ <h1 class="display-sm">Show up where your neighbors are looking.</h1>
+ <p class="lede">Ventura Claw is a directory and lead-routing service for small businesses on Ventura Blvd. Locals search by category, neighborhood, or name; we route their messages straight to your inbox. No middleman, no markup, no transaction fees.</p>
+
+ <h2 id="tiers" style="margin-top:48px">Pricing</h2>
+ <div class="tiers">
+ <div class="tier-card">
+ <h3>Free listing</h3>
+ <p class="tier-price">$0<small> / forever</small></p>
+ <ul>
+ <li>Public profile in the directory</li>
+ <li>Address, phone, website, Instagram</li>
+ <li>Customer messages forwarded</li>
+ <li>Standard placement in search</li>
+ </ul>
+ <p class="muted" style="font-size:12px;margin-top:14px">Auto-included for any business we add to the directory. Claim yours for free.</p>
+ </div>
+
+ <div class="tier-card">
+ <h3>Starter</h3>
+ <p class="tier-price">$49<small> / month</small></p>
+ <ul>
+ <li>Everything in free</li>
+ <li>Custom description + photos</li>
+ <li>"Claimed" badge in search</li>
+ <li>Priority over unclaimed listings</li>
+ <li>Monthly traffic report</li>
+ </ul>
+ </div>
+
+ <div class="tier-card is-featured">
+ <h3>Standard</h3>
+ <p class="tier-price">$99<small> / month</small></p>
+ <ul>
+ <li>Everything in Starter</li>
+ <li>"Standard" badge — appears above free listings</li>
+ <li>Featured in category sidebar</li>
+ <li>2 lead-form fields per profile (e.g., service type + preferred date)</li>
+ <li>Lead delivery within 60 seconds</li>
+ </ul>
+ <p class="muted" style="font-size:12px;margin-top:14px;color:var(--brass)">Most popular for active local businesses</p>
+ </div>
+
+ <div class="tier-card">
+ <h3>Premier</h3>
+ <p class="tier-price">$199<small> / month</small></p>
+ <ul>
+ <li>Everything in Standard</li>
+ <li>Featured on home page weekly rotation</li>
+ <li>Top placement in category search</li>
+ <li>Priority lead-form placement</li>
+ <li>Quarterly campaign + co-marketing slots</li>
+ </ul>
+ </div>
+ </div>
+
+ <h2>How leads work</h2>
+ <p>When a Ventura Claw visitor sends you a message through your profile page, we forward it to the email on file. The visitor's name, phone, ZIP, and message all go in one email — you reply directly. Ventura Claw is never on the reply-to line, never sets a price, never takes a cut of your transaction.</p>
+
+ <h2>What this is not</h2>
+ <ul>
+ <li>Not a marketplace — we don't process payments between you and your customers.</li>
+ <li>Not a contractor or professional referral service. We don't list licensed trades, medical, legal, financial, or real-estate professionals.</li>
+ <li>Not a review site. Our directory ranks by claimed-status and tier, not by user reviews.</li>
+ </ul>
+
+ <h2>Get listed</h2>
+ <p>Email <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a> with your business name, address, and which tier you'd like. We'll send a claim link. Setup takes about 10 minutes.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/home.ejs b/views/public/home.ejs
new file mode 100644
index 0000000..d72d232
--- /dev/null
+++ b/views/public/home.ejs
@@ -0,0 +1,50 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="hero">
+ <p class="kicker">Ventura Blvd · Sherman Oaks · Studio City · Encino · Tarzana · Woodland Hills</p>
+ <h1>Every business worth knowing on the Boulevard.</h1>
+ <p class="lede">A curated directory of small businesses on Ventura Blvd — restaurants, salons, retail, fitness, pet services, auto detail, cleaning, and creative pros. Find them, message them, support them.</p>
+ <form action="/find" method="get" class="hero-search" role="search">
+ <input type="search" name="q" placeholder="Try 'salon Sherman Oaks' or 'cafe Studio City'" autocomplete="off">
+ <button type="submit" class="btn btn-primary btn-lg">Search →</button>
+ </form>
+ <div class="hero-meta">
+ <span><strong><%= stats.total %></strong> businesses</span>
+ <span><strong><%= stats.vertical_count %></strong> categories</span>
+ <span><strong><%= stats.city_count %></strong> neighborhoods</span>
+ </div>
+</section>
+
+<section class="verticals">
+ <h2>Browse by category</h2>
+ <div class="verticals-grid">
+ <% verticals.forEach(function(v){ %>
+ <a class="vertical-chip" href="/find?vertical=<%= v.key %>">
+ <span class="v-icon" aria-hidden="true"><%= v.icon %></span>
+ <span class="v-meta">
+ <span class="v-label"><%= v.label %></span>
+ <span class="v-count"><%= verticalCountMap[v.key] || 0 %> businesses · <%= v.blurb %></span>
+ </span>
+ </a>
+ <% }); %>
+ </div>
+</section>
+
+<% if (featured && featured.length > 0) { %>
+<section class="find-page">
+ <h2>Featured this week</h2>
+ <div class="business-grid">
+ <% featured.forEach(function(b){ %>
+ <a class="business-card" href="/business/<%= b.slug %>">
+ <p class="biz-vertical"><%= verticals.find(function(v){return v.key===b.vertical}) ? verticals.find(function(v){return v.key===b.vertical}).label : b.vertical %></p>
+ <p class="biz-name"><%= b.business_name %></p>
+ <p class="biz-loc"><%= [b.neighborhood, b.city, b.state].filter(Boolean).join(' · ') %></p>
+ <% if (b.headline) { %><p class="biz-headline"><%= b.headline %></p><% } %>
+ </a>
+ <% }); %>
+ </div>
+</section>
+<% } %>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/legal.ejs b/views/public/legal.ejs
new file mode 100644
index 0000000..dcca4c5
--- /dev/null
+++ b/views/public/legal.ejs
@@ -0,0 +1,77 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <% if (kind === 'privacy') { %>
+ <p class="kicker">Privacy</p>
+ <h1 class="display-sm">Privacy policy</h1>
+ <p class="lede">Ventura Claw collects a limited set of personal data needed to operate a directory and lead-routing service. This policy describes what we collect, why, and your rights.</p>
+
+ <h2>What we collect</h2>
+ <ul>
+ <li><strong>Visitors sending a message to a listed business:</strong> name, email, phone (optional), ZIP (optional), and the message itself.</li>
+ <li><strong>Listed businesses (account holders):</strong> business identity, contact details, address, payout details (held by Stripe; we don't store full bank credentials).</li>
+ <li><strong>Everyone:</strong> standard server logs (IP, user agent, request path, timestamp) for abuse prevention; a session cookie for anti-CSRF and login state.</li>
+ </ul>
+
+ <h2>Why we collect it</h2>
+ <p>To forward a visitor's message to the business they chose, to send the business a notification of new leads, to bill businesses for their subscription, and to send transactional updates. We do not sell or rent personal information.</p>
+
+ <h2>Sharing</h2>
+ <p>A visitor's message contents and contact details are forwarded only to the specific business the visitor selected. Payment processing for business subscriptions is handled by Stripe, Inc. Email delivery is handled by Purelymail. We do not share consumer data with third parties for marketing or remarketing.</p>
+
+ <h2>Retention</h2>
+ <p>Business account data is retained while the account is active and for 90 days after deletion. Lead messages are retained for 13 months for billing reconciliation and dispute resolution. Server logs roll off at 30 days. Suppression-list entries (people who unsubscribed) are retained indefinitely so we never re-contact them.</p>
+
+ <h2>Your rights</h2>
+ <p>California (CCPA/CPRA), EU/UK (GDPR), and other-jurisdiction residents may request access to, correction of, or deletion of personal data we hold about them. Email <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a> with subject "Privacy request" — we respond within 30 days. Unsubscribe links in every email work without contacting us first.</p>
+
+ <h2>Contact</h2>
+ <p><a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a> · Ventura Claw · Designer Wallcoverings, 15442 Ventura Bl #102, Sherman Oaks CA 91335.</p>
+ <p class="muted" style="margin-top:24px;font-size:12px">Last updated <%= new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>.</p>
+ <% } else { %>
+ <p class="kicker">Terms</p>
+ <h1 class="display-sm">Terms of service</h1>
+
+ <p class="lede"><strong>Ventura Claw is a directory and advertising service.</strong> We list independent businesses operating on or near Ventura Blvd in Los Angeles. Businesses listed on this site are not employees, contractors, agents, or partners of Ventura Claw. Any goods or services offered by a listed business are provided directly by that business.</p>
+
+ <h2>1. What Ventura Claw does</h2>
+ <ul>
+ <li>Operates a public directory of small businesses at venturaclaw.com.</li>
+ <li>Routes consumer messages to the business the consumer chose.</li>
+ <li>Charges listed businesses a subscription fee for premium placement and per-lead delivery.</li>
+ </ul>
+
+ <h2>2. What Ventura Claw does not do</h2>
+ <ul>
+ <li>Ventura Claw does <strong>not</strong> sell goods or services on behalf of listed businesses.</li>
+ <li>Ventura Claw does <strong>not</strong> set prices, manage transactions, or take a percentage of any sale between a consumer and a listed business.</li>
+ <li>Ventura Claw does <strong>not</strong> warrant, endorse, or guarantee any goods or services offered by a listed business.</li>
+ <li>Ventura Claw does <strong>not</strong> list state-licensed professionals (medical, legal, financial, real-estate, licensed trades) and explicitly disclaims being a referral service for those professions.</li>
+ </ul>
+
+ <h2>3. Verification disclaimer</h2>
+ <p>Listing details (address, hours, services, photos) are self-reported by the business or sourced from public records. Ventura Claw reviews listings for general accuracy but does not independently audit each business. Verify offerings, pricing, hours, and qualifications directly with the business before any purchase or contract.</p>
+
+ <h2>4. Subscription terms</h2>
+ <p>Listed businesses may subscribe to a paid tier (Starter, Standard, or Premier) for premium placement. Subscriptions bill monthly via Stripe and may be canceled at any time from the billing portal; cancellation takes effect at the end of the current billing period.</p>
+
+ <h2>5. Lead delivery</h2>
+ <p>When a consumer submits a message via a business's profile page, Ventura Claw forwards the message contents and the consumer's contact details to the business by email. The business is responsible for replying to the consumer; Ventura Claw is not on the reply chain. Lead delivery is included in the subscription tier; we do not charge per-lead fees on top of the subscription.</p>
+
+ <h2>6. Dispute resolution</h2>
+ <p>Disputes between consumers and listed businesses about goods, services, pricing, or quality are between those parties; Ventura Claw is not a party to those disputes and does not arbitrate.</p>
+
+ <h2>7. Limitation of liability</h2>
+ <p>Ventura Claw's aggregate liability for any claim arising from use of the service is limited to the fees the consumer or business paid to Ventura Claw in the 12 months preceding the claim. We are not liable for any goods or services provided or not provided by a listed business, for any damage to a consumer's property, or for any other loss arising from a transaction between a consumer and a listed business.</p>
+
+ <h2>8. Changes</h2>
+ <p>These terms may be updated. Material changes will be communicated by email to active users. Continued use of the service after a change constitutes acceptance.</p>
+
+ <h2>9. Contact</h2>
+ <p><a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a> · Ventura Claw · Designer Wallcoverings, 15442 Ventura Bl #102, Sherman Oaks CA 91335.</p>
+ <p class="muted" style="margin-top:24px;font-size:12px">Last updated <%= new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>. These terms describe Ventura Claw's current operating model and are not a substitute for the final terms a consumer or business agrees to at sign-up.</p>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
(oldest)
·
back to Ventura Claw Leads
·
v0.2 #1+#2: lead-delivery cron + /map Leaflet view a6b4e19 →