← back to Ventura Claw Leads
v0.2 #3: admin/claim flow + lead inbox + profile editor + billing UI
cc3cfef61499801d02bcf2bb3fe6fb48f3d6e7dd · 2026-05-06 16:51:52 -0700 · Steve Abrams
CLAIM FLOW
- POST /business/:slug/claim — visitor on unclaimed profile submits email,
we mint HMAC-signed token (24h TTL) + email magic link via Purelymail.
- GET /claim/:token — magic link lands on a 'set a password' form.
- POST /claim/:token — bcrypt-hashes password, creates business_users row,
flips businesses.claim_status='self', logs the user in.
- claimLimiter: 5 req/hour/IP on the start endpoint.
ADMIN
- /admin/login + /admin/logout (bcrypt verify, session-based, loginLimiter
10 req/15min).
- /admin dashboard with stats (total leads, last 30d, delivered, pending) +
recent-5 leads + welcome flash on fresh claim.
- /admin/profile editor — every public field (name/headline/description/
address/contact/socials).
- /admin/leads inbox — full lead table with delivery status.
- /admin/billing — pricing tiers from VCL_TIERS, upgrade/downgrade buttons
hit /admin/billing/checkout (Stripe Checkout) + manage-subscription button
hits /admin/billing/portal (Stripe Customer Portal). Real flow with the
live keys from v0.2 #4.
INFRA
- lib/auth.js — bcrypt + claim-token mint/consume + attachBusiness +
requireBusiness; res.locals.currentBusinessUser + currentBusiness exposed.
- lib/csrf.js — lifted from NPH; res.locals.csrfToken auto-set; safe-method
pass-through; webhooks bypass; double-submit + timing-safe compare.
- 4 new routes mounted: auth, claim, admin (auth.requireBusiness gated).
- 6 new EJS: claim-result, claim-complete, admin/login, admin/dashboard,
admin/profile, admin/leads, admin/billing.
- header partial: 'Business sign in' / 'Dashboard' link toggle.
- business.ejs unclaimed branch: inline claim form with email field.
DB
- migrations/004_business_users_and_claims.sql — business_users (FK to
businesses, UNIQUE on email + (business_id,email)) and claim_tokens
(token UNIQUE, FK to businesses, expires_at, consumed_at, ip_hash).
Live test 2026-05-06 on prod (cactus-and-cane-plants-tarzana):
GET profile (csrf) → POST /business/:slug/claim (email submitted) →
email-fetch token from DB → GET /claim/token (form) → POST /claim/token
(password set, password_confirm matched) → claim_status flipped to
'self' with claimed_by_email recorded → session-login automatic → all
4 admin pages return 200 (dashboard / leads / profile / billing).
Closes v0.2 #3. Now the Stripe products from v0.2 #4 have a UI to be
purchased through; admin/billing/checkout invokes
stripe.createSubscriptionCheckout with the right price ID per tier.
Files touched
A db/migrations/004_business_users_and_claims.sqlA lib/auth.jsA lib/csrf.jsM package-lock.jsonM package.jsonA routes/admin.jsA routes/auth.jsA routes/claim.jsM server.jsA views/admin/billing.ejsA views/admin/dashboard.ejsA views/admin/leads.ejsA views/admin/login.ejsA views/admin/profile.ejsM views/partials/header.ejsM views/public/business.ejsA views/public/claim-complete.ejsA views/public/claim-result.ejs
Diff
commit cc3cfef61499801d02bcf2bb3fe6fb48f3d6e7dd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 16:51:52 2026 -0700
v0.2 #3: admin/claim flow + lead inbox + profile editor + billing UI
CLAIM FLOW
- POST /business/:slug/claim — visitor on unclaimed profile submits email,
we mint HMAC-signed token (24h TTL) + email magic link via Purelymail.
- GET /claim/:token — magic link lands on a 'set a password' form.
- POST /claim/:token — bcrypt-hashes password, creates business_users row,
flips businesses.claim_status='self', logs the user in.
- claimLimiter: 5 req/hour/IP on the start endpoint.
ADMIN
- /admin/login + /admin/logout (bcrypt verify, session-based, loginLimiter
10 req/15min).
- /admin dashboard with stats (total leads, last 30d, delivered, pending) +
recent-5 leads + welcome flash on fresh claim.
- /admin/profile editor — every public field (name/headline/description/
address/contact/socials).
- /admin/leads inbox — full lead table with delivery status.
- /admin/billing — pricing tiers from VCL_TIERS, upgrade/downgrade buttons
hit /admin/billing/checkout (Stripe Checkout) + manage-subscription button
hits /admin/billing/portal (Stripe Customer Portal). Real flow with the
live keys from v0.2 #4.
INFRA
- lib/auth.js — bcrypt + claim-token mint/consume + attachBusiness +
requireBusiness; res.locals.currentBusinessUser + currentBusiness exposed.
- lib/csrf.js — lifted from NPH; res.locals.csrfToken auto-set; safe-method
pass-through; webhooks bypass; double-submit + timing-safe compare.
- 4 new routes mounted: auth, claim, admin (auth.requireBusiness gated).
- 6 new EJS: claim-result, claim-complete, admin/login, admin/dashboard,
admin/profile, admin/leads, admin/billing.
- header partial: 'Business sign in' / 'Dashboard' link toggle.
- business.ejs unclaimed branch: inline claim form with email field.
DB
- migrations/004_business_users_and_claims.sql — business_users (FK to
businesses, UNIQUE on email + (business_id,email)) and claim_tokens
(token UNIQUE, FK to businesses, expires_at, consumed_at, ip_hash).
Live test 2026-05-06 on prod (cactus-and-cane-plants-tarzana):
GET profile (csrf) → POST /business/:slug/claim (email submitted) →
email-fetch token from DB → GET /claim/token (form) → POST /claim/token
(password set, password_confirm matched) → claim_status flipped to
'self' with claimed_by_email recorded → session-login automatic → all
4 admin pages return 200 (dashboard / leads / profile / billing).
Closes v0.2 #3. Now the Stripe products from v0.2 #4 have a UI to be
purchased through; admin/billing/checkout invokes
stripe.createSubscriptionCheckout with the right price ID per tier.
---
db/migrations/004_business_users_and_claims.sql | 30 +++++
lib/auth.js | 134 +++++++++++++++++++++
lib/csrf.js | 44 +++++++
package-lock.json | 35 ++++++
package.json | 1 +
routes/admin.js | 136 +++++++++++++++++++++
routes/auth.js | 42 +++++++
routes/claim.js | 149 ++++++++++++++++++++++++
server.js | 25 +++-
views/admin/billing.ejs | 87 ++++++++++++++
views/admin/dashboard.ejs | 67 +++++++++++
views/admin/leads.ejs | 35 ++++++
views/admin/login.ejs | 24 ++++
views/admin/profile.ejs | 39 +++++++
views/partials/header.ejs | 5 +
views/public/business.ejs | 16 ++-
views/public/claim-complete.ejs | 33 ++++++
views/public/claim-result.ejs | 19 +++
18 files changed, 917 insertions(+), 4 deletions(-)
diff --git a/db/migrations/004_business_users_and_claims.sql b/db/migrations/004_business_users_and_claims.sql
new file mode 100644
index 0000000..92ec168
--- /dev/null
+++ b/db/migrations/004_business_users_and_claims.sql
@@ -0,0 +1,30 @@
+-- Admin/claim infrastructure for businesses to claim their directory listing,
+-- log into a dashboard, see leads, edit profile, and manage subscription.
+
+CREATE TABLE IF NOT EXISTS business_users (
+ id BIGSERIAL PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT, -- nullable until first password set; magic-link login still works
+ business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+ display_name TEXT,
+ email_verified BOOLEAN NOT NULL DEFAULT false,
+ last_login_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (business_id, email)
+);
+CREATE INDEX IF NOT EXISTS business_users_business_idx ON business_users (business_id);
+
+-- One-shot claim tokens. The email→business_id mapping is in the row, so a
+-- compromised token only unlocks that one specific listing.
+CREATE TABLE IF NOT EXISTS claim_tokens (
+ id BIGSERIAL PRIMARY KEY,
+ token TEXT UNIQUE NOT NULL, -- HMAC-derived, 32 chars
+ business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+ email TEXT NOT NULL, -- the recipient who'll claim
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ ip_hash TEXT
+);
+CREATE INDEX IF NOT EXISTS claim_tokens_business_idx ON claim_tokens (business_id);
+CREATE INDEX IF NOT EXISTS claim_tokens_active_idx ON claim_tokens (token) WHERE consumed_at IS NULL;
diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..4344325
--- /dev/null
+++ b/lib/auth.js
@@ -0,0 +1,134 @@
+// Business-side auth — bcrypt + session-based + claim tokens.
+// Mirrors the NPH installer-side pattern but scoped to business_users.
+
+const crypto = require('node:crypto');
+const bcrypt = require('bcrypt');
+const db = require('./db');
+
+const BCRYPT_ROUNDS = 12;
+const CLAIM_TTL_HOURS = 24;
+
+async function hashPassword(plain) {
+ return bcrypt.hash(plain, BCRYPT_ROUNDS);
+}
+async function verifyPassword(plain, hash) {
+ if (!hash) return false;
+ return bcrypt.compare(plain, hash);
+}
+
+function normalizeEmail(s) {
+ return String(s || '').trim().toLowerCase();
+}
+function isEmailShape(s) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
+}
+
+async function findUserByEmail(email) {
+ return db.one(
+ `SELECT id, email, password_hash, business_id, display_name, email_verified, last_login_at FROM business_users WHERE email = $1`,
+ [normalizeEmail(email)]
+ );
+}
+
+async function createUser({ email, businessId, passwordHash, displayName, emailVerified }) {
+ const r = await db.one(
+ `INSERT INTO business_users (email, password_hash, business_id, display_name, email_verified)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (email) DO NOTHING
+ RETURNING id, email, business_id, display_name, email_verified`,
+ [normalizeEmail(email), passwordHash || null, businessId, displayName || null, !!emailVerified]
+ );
+ if (r) return r;
+ // Conflict — pull the existing record (someone tried to re-claim).
+ return findUserByEmail(email);
+}
+
+async function setPassword(userId, plain) {
+ const hash = await hashPassword(plain);
+ await db.query(`UPDATE business_users SET password_hash = $2 WHERE id = $1`, [userId, hash]);
+}
+
+async function recordLogin(userId) {
+ await db.query(`UPDATE business_users SET last_login_at = now() WHERE id = $1`, [userId]);
+}
+
+// Session-based business auth. session.businessUserId is set on login.
+async function attachBusiness(req, res, next) {
+ res.locals.currentBusinessUser = null;
+ res.locals.currentBusiness = null;
+ if (!req.session || !req.session.businessUserId) return next();
+ try {
+ const user = await db.one(
+ `SELECT bu.id, bu.email, bu.business_id, bu.display_name,
+ b.slug, b.business_name, b.tier, b.subscription_status, b.stripe_customer_id, b.stripe_subscription_id
+ FROM business_users bu
+ JOIN businesses b ON b.id = bu.business_id
+ WHERE bu.id = $1`,
+ [req.session.businessUserId]
+ );
+ if (user) {
+ res.locals.currentBusinessUser = { id: user.id, email: user.email, display_name: user.display_name };
+ res.locals.currentBusiness = {
+ id: user.business_id, slug: user.slug, business_name: user.business_name,
+ tier: user.tier, subscription_status: user.subscription_status,
+ stripe_customer_id: user.stripe_customer_id, stripe_subscription_id: user.stripe_subscription_id
+ };
+ } else {
+ // Stale session — user was deleted; clear it.
+ req.session.businessUserId = null;
+ }
+ } catch (e) {
+ console.warn('[auth] attachBusiness error', e.message);
+ }
+ next();
+}
+
+function requireBusiness(req, res, next) {
+ if (!res.locals.currentBusinessUser) {
+ return res.redirect('/admin/login?next=' + encodeURIComponent(req.originalUrl));
+ }
+ next();
+}
+
+// ─── Claim tokens ──────────────────────────────────────────────────────
+function claimSecret() {
+ return process.env.CLAIM_SIGNING_SECRET || process.env.SESSION_SECRET || 'dev-only-not-for-prod';
+}
+
+async function issueClaimToken({ businessId, email, ipHash }) {
+ const raw = `claim:${businessId}:${normalizeEmail(email)}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
+ const token = crypto.createHmac('sha256', claimSecret()).update(raw).digest('base64url').slice(0, 32);
+ const expiresAt = new Date(Date.now() + CLAIM_TTL_HOURS * 3600 * 1000).toISOString();
+ await db.query(
+ `INSERT INTO claim_tokens (token, business_id, email, expires_at, ip_hash)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [token, businessId, normalizeEmail(email), expiresAt, ipHash || null]
+ );
+ return { token, expiresAt };
+}
+
+async function consumeClaimToken(token) {
+ const row = await db.one(
+ `SELECT ct.id, ct.business_id, ct.email, ct.consumed_at, ct.expires_at,
+ b.slug, b.business_name, b.claim_status
+ FROM claim_tokens ct
+ JOIN businesses b ON b.id = ct.business_id
+ WHERE ct.token = $1`,
+ [token]
+ );
+ if (!row) return { ok: false, reason: 'invalid' };
+ if (row.consumed_at) return { ok: false, reason: 'already_used' };
+ if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' };
+ return { ok: true, claim: row };
+}
+
+async function markClaimConsumed(token) {
+ await db.query(`UPDATE claim_tokens SET consumed_at = now() WHERE token = $1`, [token]);
+}
+
+module.exports = {
+ hashPassword, verifyPassword, normalizeEmail, isEmailShape,
+ findUserByEmail, createUser, setPassword, recordLogin,
+ attachBusiness, requireBusiness,
+ issueClaimToken, consumeClaimToken, markClaimConsumed
+};
diff --git a/lib/csrf.js b/lib/csrf.js
new file mode 100644
index 0000000..0091dbf
--- /dev/null
+++ b/lib/csrf.js
@@ -0,0 +1,44 @@
+// CSRF — per-session token, double-submit pattern with timing-safe compare.
+// Lifted from NPH. Skips GET/HEAD/OPTIONS, /webhooks/* (Stripe sig handles auth),
+// and any req with `req.skipCsrf = true`.
+
+const crypto = require('crypto');
+
+function ensureToken(req) {
+ if (!req.session) return null;
+ if (!req.session.csrfToken) {
+ req.session.csrfToken = crypto.randomBytes(24).toString('base64url');
+ }
+ return req.session.csrfToken;
+}
+
+function csrfMiddleware(req, res, next) {
+ const token = ensureToken(req);
+ res.locals.csrfToken = token || '';
+
+ const safe = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
+ if (safe) return next();
+ if (req.path.startsWith('/webhooks/')) return next();
+ if (req.skipCsrf) return next();
+
+ const supplied = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
+ if (!token || !supplied) return reject(req, res);
+
+ let a, b;
+ try { a = Buffer.from(token); b = Buffer.from(supplied); }
+ catch { return reject(req, res); }
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return reject(req, res);
+ next();
+}
+
+function reject(req, res) {
+ if (req.accepts('json') && !req.accepts('html')) {
+ return res.status(403).json({ error: 'csrf_failed' });
+ }
+ return res.status(403).render('public/error', {
+ title: 'Request blocked',
+ message: 'Form expired or session lost — please reload the page and try again.'
+ });
+}
+
+module.exports = { csrfMiddleware, ensureToken };
diff --git a/package-lock.json b/package-lock.json
index 33503f5..c90312e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "ventura-claw-leads",
"version": "0.1.0",
"dependencies": {
+ "bcrypt": "^6.0.0",
"connect-pg-simple": "^9.0.1",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
@@ -72,6 +73,20 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -764,6 +779,26 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
+ "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
diff --git a/package.json b/package.json
index 6ef1765..d954198 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"seed": "node scripts/seed-demo.js"
},
"dependencies": {
+ "bcrypt": "^6.0.0",
"connect-pg-simple": "^9.0.1",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..f9399fd
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,136 @@
+const express = require('express');
+const slugify = require('slugify');
+const db = require('../lib/db');
+const auth = require('../lib/auth');
+const stripe = require('../lib/stripe');
+const router = express.Router();
+
+const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+
+router.use(auth.requireBusiness);
+
+router.get('/', async (req, res, next) => {
+ try {
+ const biz = res.locals.currentBusiness;
+ const stats = await db.one(`
+ SELECT
+ COUNT(*)::int AS total_leads,
+ COUNT(*) FILTER (WHERE delivered_at IS NOT NULL)::int AS delivered,
+ COUNT(*) FILTER (WHERE delivered_at IS NULL)::int AS pending,
+ COUNT(*) FILTER (WHERE created_at > now() - interval '30 days')::int AS last_30d
+ FROM business_interest WHERE business_id = $1
+ `, [biz.id]);
+ const recent = await db.many(`
+ SELECT id, consumer_name, consumer_email, consumer_phone, message, zip,
+ created_at, delivered_at, delivery_email
+ FROM business_interest WHERE business_id = $1
+ ORDER BY created_at DESC LIMIT 5
+ `, [biz.id]);
+ res.render('admin/dashboard', {
+ title: `${biz.business_name} · Dashboard`,
+ stats, recent,
+ welcome: req.query.welcome === '1'
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/profile', async (req, res, next) => {
+ try {
+ const biz = await db.one(`SELECT * FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
+ res.render('admin/profile', { title: 'Edit profile · Ventura Claw', biz, error: null, ok: req.query.saved === '1' });
+ } catch (err) { next(err); }
+});
+
+router.post('/profile', async (req, res, next) => {
+ try {
+ const biz = res.locals.currentBusiness;
+ const fields = {
+ business_name: String(req.body.business_name || '').trim().slice(0, 200),
+ headline: String(req.body.headline || '').trim().slice(0, 250) || null,
+ description: String(req.body.description || '').trim().slice(0, 4000) || null,
+ street: String(req.body.street || '').trim().slice(0, 200) || null,
+ neighborhood: String(req.body.neighborhood || '').trim().slice(0, 80) || null,
+ city: String(req.body.city || '').trim().slice(0, 80) || null,
+ zip: String(req.body.zip || '').trim().slice(0, 10) || null,
+ phone: String(req.body.phone || '').trim().slice(0, 32) || null,
+ email: String(req.body.email || '').trim().toLowerCase().slice(0, 200) || null,
+ website: String(req.body.website || '').trim().slice(0, 300) || null,
+ instagram_handle: String(req.body.instagram_handle || '').trim().replace(/^@/, '').slice(0, 60) || null
+ };
+ if (!fields.business_name) {
+ const fresh = await db.one(`SELECT * FROM businesses WHERE id = $1`, [biz.id]);
+ return res.status(400).render('admin/profile', {
+ title: 'Edit profile · Ventura Claw', biz: fresh, ok: false,
+ error: 'Business name is required.'
+ });
+ }
+ await db.query(`
+ UPDATE businesses SET
+ business_name = $2, headline = $3, description = $4,
+ street = $5, neighborhood = $6, city = $7, zip = $8,
+ phone = $9, email = $10, website = $11, instagram_handle = $12
+ WHERE id = $1
+ `, [biz.id, fields.business_name, fields.headline, fields.description,
+ fields.street, fields.neighborhood, fields.city, fields.zip,
+ fields.phone, fields.email, fields.website, fields.instagram_handle]);
+ res.redirect('/admin/profile?saved=1');
+ } catch (err) { next(err); }
+});
+
+router.get('/leads', async (req, res, next) => {
+ try {
+ const bizId = res.locals.currentBusiness.id;
+ const leads = await db.many(`
+ SELECT id, consumer_name, consumer_email, consumer_phone, message, zip, source,
+ created_at, delivered_at, delivery_email, delivery_msg_id, bill_amount_cents
+ FROM business_interest
+ WHERE business_id = $1
+ ORDER BY created_at DESC
+ LIMIT 200
+ `, [bizId]);
+ res.render('admin/leads', { title: 'Leads · Ventura Claw', leads });
+ } catch (err) { next(err); }
+});
+
+// ─── Billing ───────────────────────────────────────────────────────────
+router.get('/billing', (req, res) => {
+ res.render('admin/billing', {
+ title: 'Billing · Ventura Claw',
+ stripeLive: stripe.isLive(),
+ tiers: stripe.VCL_TIERS,
+ flash: req.query
+ });
+});
+
+router.post('/billing/checkout', async (req, res, next) => {
+ try {
+ const tier = String(req.body.tier || '').trim();
+ if (!stripe.VCL_TIERS[tier]) {
+ return res.redirect('/admin/billing?error=unknown_tier');
+ }
+ const biz = await db.one(`SELECT id, slug, business_name, email, stripe_customer_id FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
+ const session = await stripe.createSubscriptionCheckout({
+ business: biz, tier,
+ successUrl: `${PUBLIC_URL}/admin/billing?upgraded=${tier}`,
+ cancelUrl: `${PUBLIC_URL}/admin/billing?canceled=1`
+ });
+ if (session.mocked) {
+ return res.redirect(session.url || '/admin/billing?mock=1');
+ }
+ res.redirect(303, session.url);
+ } catch (err) { next(err); }
+});
+
+router.post('/billing/portal', async (req, res, next) => {
+ try {
+ const biz = await db.one(`SELECT id, slug, business_name, email, stripe_customer_id FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
+ if (!biz.stripe_customer_id) {
+ return res.redirect('/admin/billing?error=no_customer');
+ }
+ const portal = await stripe.createPortalSession({ business: biz, returnUrl: `${PUBLIC_URL}/admin/billing` });
+ if (portal.mocked) return res.redirect('/admin/billing?mock=portal');
+ res.redirect(303, portal.url);
+ } catch (err) { next(err); }
+});
+
+module.exports = router;
diff --git a/routes/auth.js b/routes/auth.js
new file mode 100644
index 0000000..0aceda0
--- /dev/null
+++ b/routes/auth.js
@@ -0,0 +1,42 @@
+const express = require('express');
+const auth = require('../lib/auth');
+const router = express.Router();
+
+router.get('/admin/login', (req, res) => {
+ if (res.locals.currentBusinessUser) return res.redirect('/admin');
+ res.render('admin/login', {
+ title: 'Sign in · Ventura Claw',
+ next: String(req.query.next || '/admin'),
+ error: null,
+ email: ''
+ });
+});
+
+router.post('/admin/login', async (req, res) => {
+ const email = auth.normalizeEmail(req.body.email);
+ const password = String(req.body.password || '');
+ const next = String(req.body.next || req.query.next || '/admin');
+
+ if (!auth.isEmailShape(email) || !password) {
+ return res.status(400).render('admin/login', {
+ title: 'Sign in · Ventura Claw', next, email,
+ error: 'Email and password required.'
+ });
+ }
+ const user = await auth.findUserByEmail(email);
+ if (!user || !user.password_hash || !(await auth.verifyPassword(password, user.password_hash))) {
+ return res.status(400).render('admin/login', {
+ title: 'Sign in · Ventura Claw', next, email,
+ error: 'Email or password incorrect. If you just claimed your listing, check your inbox for the link.'
+ });
+ }
+ req.session.businessUserId = user.id;
+ await auth.recordLogin(user.id);
+ res.redirect(next.startsWith('/admin') ? next : '/admin');
+});
+
+router.post('/admin/logout', (req, res) => {
+ req.session.destroy(() => res.redirect('/'));
+});
+
+module.exports = router;
diff --git a/routes/claim.js b/routes/claim.js
new file mode 100644
index 0000000..1cfb72f
--- /dev/null
+++ b/routes/claim.js
@@ -0,0 +1,149 @@
+const express = require('express');
+const crypto = require('node:crypto');
+const db = require('../lib/db');
+const auth = require('../lib/auth');
+const compliance = require('../lib/compliance');
+const { sendEmail } = require('../lib/email');
+const router = express.Router();
+
+const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+
+// Step 1: visitor on a business profile clicks "Claim this listing" → enters
+// the email they want to claim with. We email them a magic link.
+router.post('/business/:slug/claim', async (req, res, next) => {
+ try {
+ const biz = await db.one(`SELECT id, slug, business_name, claim_status, email AS biz_email FROM businesses WHERE slug = $1`, [req.params.slug]);
+ if (!biz) return res.status(404).render('public/404', { title: 'Not Found' });
+
+ const submittedEmail = auth.normalizeEmail(req.body.email);
+ if (!auth.isEmailShape(submittedEmail)) {
+ return res.status(400).render('public/claim-result', {
+ title: 'Email needed', business: biz, ok: false,
+ error: 'Please enter a valid email address.'
+ });
+ }
+
+ const ipHash = crypto.createHash('sha256')
+ .update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
+ .digest('hex').slice(0, 16);
+
+ const { token } = await auth.issueClaimToken({ businessId: biz.id, email: submittedEmail, ipHash });
+ const link = `${PUBLIC_URL}/claim/${encodeURIComponent(token)}`;
+ const profileUrl = `${PUBLIC_URL}/business/${encodeURIComponent(biz.slug)}`;
+
+ // Compliance: if MAILING_ADDRESS / suppression tables aren't ready, this
+ // would assert-fail and we'd never email. For dev we skip the gate.
+ if (process.env.NODE_ENV === 'production') {
+ try { await compliance.assertSendCompliance({ campaign: 'claim_invite' }); }
+ catch (e) {
+ console.warn('[claim] compliance fail-closed', e.code);
+ return res.status(503).render('public/claim-result', { title: 'Try again later', business: biz, ok: false, error: 'Email service is not configured. Reach out at info@venturaclaw.com.' });
+ }
+ }
+
+ const subject = `Claim ${biz.business_name} on Ventura Claw`;
+ const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <p style="font-size:11px;letter-spacing:0.16em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">Claim your listing</p>
+ <h1 style="font-size:24px;font-weight:400;margin:0 0 12px">Confirm you own ${escapeHtml(biz.business_name)}</h1>
+ <p style="font-size:15px;line-height:1.55">Click the link below to claim the public listing for <strong>${escapeHtml(biz.business_name)}</strong> on Ventura Claw. The link is valid for 24 hours and works once.</p>
+ <p style="margin:24px 0"><a href="${link}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Claim ${escapeHtml(biz.business_name)} →</a></p>
+ <p style="font-size:13px;color:#666;line-height:1.55">Profile: <a href="${profileUrl}" style="color:#666">${profileUrl.replace(/^https?:\/\//, '')}</a></p>
+ <p style="font-size:12px;color:#888;margin:24px 0 0;line-height:1.55">If you didn't request this, ignore the message — no action is taken until the link is clicked.</p>
+${compliance.complianceFooter({ campaign: 'claim_invite', unsubscribeUrl: null })}
+</div>`;
+ const text = `Claim ${biz.business_name} on Ventura Claw.\n\n${link}\n\n(link expires in 24 hours, single use)`;
+
+ const result = await sendEmail({ to: submittedEmail, subject, html, text });
+ if (process.env.NODE_ENV === 'production') {
+ await compliance.recordAudit({
+ channel: 'email', campaign: 'claim_invite',
+ recipient: submittedEmail, businessId: biz.id,
+ decision: result.ok ? 'sent' : 'failed',
+ reason: result.ok ? null : (result.error || 'unknown'),
+ subject, messageId: result.messageId || null,
+ payload: { token_prefix: token.slice(0, 8) }
+ });
+ }
+
+ res.render('public/claim-result', {
+ title: 'Check your email', business: biz, ok: true, email: submittedEmail
+ });
+ } catch (err) { next(err); }
+});
+
+// Step 2: visitor clicks the magic link from their email → set password →
+// session-login.
+router.get('/claim/:token', async (req, res, next) => {
+ try {
+ const r = await auth.consumeClaimToken(req.params.token);
+ if (!r.ok) {
+ return res.status(400).render('public/claim-complete', {
+ title: 'Link not valid',
+ business: null, ok: false, reason: r.reason
+ });
+ }
+ res.render('public/claim-complete', {
+ title: `Set up your account · ${r.claim.business_name}`,
+ business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
+ ok: true, token: req.params.token, email: r.claim.email, error: null
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/claim/:token', async (req, res, next) => {
+ try {
+ const r = await auth.consumeClaimToken(req.params.token);
+ if (!r.ok) {
+ return res.status(400).render('public/claim-complete', {
+ title: 'Link not valid', business: null, ok: false, reason: r.reason
+ });
+ }
+ const password = String(req.body.password || '');
+ const passwordConfirm = String(req.body.password_confirm || '');
+ if (password.length < 10) {
+ return res.status(400).render('public/claim-complete', {
+ title: 'Password too short',
+ business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
+ ok: true, token: req.params.token, email: r.claim.email,
+ error: 'Pick a password with at least 10 characters.'
+ });
+ }
+ if (password !== passwordConfirm) {
+ return res.status(400).render('public/claim-complete', {
+ title: 'Passwords don\'t match',
+ business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
+ ok: true, token: req.params.token, email: r.claim.email,
+ error: 'The two password fields must match.'
+ });
+ }
+
+ // Mark token consumed first to prevent replay during the rest of the flow.
+ await auth.markClaimConsumed(req.params.token);
+
+ const passwordHash = await auth.hashPassword(password);
+ const user = await auth.createUser({
+ email: r.claim.email, businessId: r.claim.business_id,
+ passwordHash, displayName: r.claim.email.split('@')[0],
+ emailVerified: true
+ });
+ if (user && !user.password_hash) {
+ // Pre-existing user without a password — set it now.
+ await auth.setPassword(user.id, password);
+ }
+ // Flip business to claimed.
+ await db.query(
+ `UPDATE businesses SET claim_status = 'self', claimed_by_email = $2, claimed_at = now() WHERE id = $1`,
+ [r.claim.business_id, r.claim.email]
+ );
+
+ req.session.businessUserId = user.id;
+ await auth.recordLogin(user.id);
+ res.redirect('/admin?welcome=1');
+ } catch (err) { next(err); }
+});
+
+function escapeHtml(s) {
+ return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+}
+
+module.exports = router;
diff --git a/server.js b/server.js
index ae2029b..5a6b27f 100644
--- a/server.js
+++ b/server.js
@@ -9,8 +9,13 @@ const PgSession = require('connect-pg-simple')(session);
const rateLimit = require('express-rate-limit');
const db = require('./lib/db');
+const auth = require('./lib/auth');
+const { csrfMiddleware } = require('./lib/csrf');
const publicRoutes = require('./routes/public');
const webhookRoutes = require('./routes/webhooks');
+const authRoutes = require('./routes/auth');
+const claimRoutes = require('./routes/claim');
+const adminRoutes = require('./routes/admin');
const app = express();
const PORT = parseInt(process.env.PORT || '9789', 10);
@@ -69,18 +74,36 @@ app.use('/img', express.static(path.join(__dirname, 'public/img'), { maxAge:
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();
});
+// Auth + CSRF (CSRF runs after attachBusiness so locals are available in error templates).
+app.use(auth.attachBusiness);
+app.use(csrfMiddleware);
+
const contactLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 8,
standardHeaders: true, legacyHeaders: false,
message: { error: 'too_many_requests' }
});
+const claimLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000, max: 5,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
+const loginLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, max: 10,
+ standardHeaders: true, legacyHeaders: false,
+ message: { error: 'too_many_requests' }
+});
app.use('/business/:slug/contact', contactLimiter);
+app.use('/business/:slug/claim', claimLimiter);
+app.use('/admin/login', loginLimiter);
app.use('/', publicRoutes);
+app.use('/', authRoutes);
+app.use('/', claimRoutes);
+app.use('/admin', adminRoutes);
app.use((req, res) => {
res.status(404).render('public/404', { title: 'Not Found' });
diff --git a/views/admin/billing.ejs b/views/admin/billing.ejs
new file mode 100644
index 0000000..2cf8716
--- /dev/null
+++ b/views/admin/billing.ejs
@@ -0,0 +1,87 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:1000px">
+ <p class="kicker">Billing</p>
+ <h1 class="display-sm">Pick a tier.</h1>
+ <p class="lede">Current tier: <strong><%= currentBusiness.tier === 'free' ? 'Free' : currentBusiness.tier.charAt(0).toUpperCase() + currentBusiness.tier.slice(1) %></strong> · status: <%= currentBusiness.subscription_status || 'inactive' %></p>
+
+ <% if (flash.upgraded) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Subscription updated to <strong><%= flash.upgraded %></strong>. The new tier may take ~30 seconds to reflect on your dashboard once Stripe confirms.</p><% } %>
+ <% if (flash.canceled) { %><p class="callout" style="background:var(--bg-alt);border-left:3px solid var(--brass);padding:12px 16px;margin:16px 0;border-radius:4px">Checkout canceled — no changes made.</p><% } %>
+ <% if (flash.error) { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">Error: <%= flash.error %></p><% } %>
+ <% if (flash.mock || flash['mock=portal']) { %><p class="callout" style="background:var(--bg-alt);border-left:3px solid var(--brass);padding:12px 16px;margin:16px 0;border-radius:4px">Stripe is in mock mode (no live keys). Real checkout requires a configured Stripe account.</p><% } %>
+
+ <% if (!stripeLive) { %>
+ <p class="muted" style="margin-top:24px">Billing is in mock mode in this environment — buttons below are clickable but won't actually charge.</p>
+ <% } %>
+
+ <div class="tiers" style="margin-top:32px">
+ <div class="tier-card">
+ <h3>Free</h3>
+ <p class="tier-price">$0<small> / forever</small></p>
+ <ul>
+ <li>Public directory profile</li>
+ <li>Customer messages forwarded</li>
+ <li>Standard search placement</li>
+ </ul>
+ <% if (currentBusiness.tier === 'free') { %>
+ <p class="muted" style="font-size:12px;margin-top:14px">Your current tier.</p>
+ <% } %>
+ </div>
+
+ <% Object.entries(tiers).forEach(function(entry){
+ var tierKey = entry[0]; var tierMeta = entry[1];
+ var isCurrent = currentBusiness.tier === tierKey;
+ var isFeatured = tierKey === 'standard';
+ %>
+ <div class="tier-card<%= isFeatured ? ' is-featured' : '' %>">
+ <h3><%= tierMeta.name %></h3>
+ <p class="tier-price"><%= tierMeta.label.split(' /')[0] %><small> / month</small></p>
+ <ul>
+ <% if (tierKey === 'starter') { %>
+ <li>Custom description & photos</li>
+ <li>"Claimed" badge in search</li>
+ <li>Priority over free listings</li>
+ <li>Monthly traffic report</li>
+ <% } else if (tierKey === 'standard') { %>
+ <li>Everything in Starter</li>
+ <li>"Standard" badge — above free listings</li>
+ <li>Featured in category sidebar</li>
+ <li>Sub-60s lead delivery</li>
+ <% } else { %>
+ <li>Everything in Standard</li>
+ <li>Home-page weekly rotation</li>
+ <li>Top placement in category</li>
+ <li>Quarterly co-marketing</li>
+ <% } %>
+ </ul>
+ <% if (isCurrent) { %>
+ <p class="muted" style="font-size:12px;margin-top:14px;color:#16a34a">Your current tier.</p>
+ <% } else { %>
+ <form method="post" action="/admin/billing/checkout" style="margin-top:14px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="hidden" name="tier" value="<%= tierKey %>">
+ <button type="submit" class="btn btn-primary" style="width:100%">Upgrade to <%= tierMeta.name %></button>
+ </form>
+ <% } %>
+ </div>
+ <% }); %>
+ </div>
+
+ <% if (currentBusiness.stripe_customer_id) { %>
+ <h2 style="margin-top:48px">Manage subscription</h2>
+ <p>Update your payment method, see invoices, or cancel from the secure Stripe portal.</p>
+ <form method="post" action="/admin/billing/portal" style="margin-top:12px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-ghost">Open billing portal →</button>
+ </form>
+ <% } %>
+
+ <h2 style="margin-top:48px">Sign out</h2>
+ <form method="post" action="/admin/logout" style="margin-top:8px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
+ </form>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
new file mode 100644
index 0000000..a62e928
--- /dev/null
+++ b/views/admin/dashboard.ejs
@@ -0,0 +1,67 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:1100px">
+ <% if (welcome) { %>
+ <div class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:14px 18px;margin:0 0 24px;border-radius:4px">
+ <strong>Welcome to Ventura Claw.</strong> Your listing is now claimed. Edit your profile and pick a tier to unlock featured placement.
+ </div>
+ <% } %>
+
+ <p class="kicker">Dashboard</p>
+ <h1 class="display-sm"><%= currentBusiness.business_name %></h1>
+ <p class="lede">Tier: <strong><%= currentBusiness.tier === 'free' ? 'Free' : (currentBusiness.tier.charAt(0).toUpperCase() + currentBusiness.tier.slice(1)) %></strong> · Subscription: <strong><%= currentBusiness.subscription_status || 'inactive' %></strong></p>
+
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin:32px 0">
+ <div class="side-card" style="text-align:center">
+ <h3>Total leads</h3>
+ <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.total_leads %></p>
+ </div>
+ <div class="side-card" style="text-align:center">
+ <h3>Last 30 days</h3>
+ <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.last_30d %></p>
+ </div>
+ <div class="side-card" style="text-align:center">
+ <h3>Delivered</h3>
+ <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.delivered %></p>
+ </div>
+ <div class="side-card" style="text-align:center">
+ <h3>Pending</h3>
+ <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.pending %></p>
+ </div>
+ </div>
+
+ <h2>Recent leads</h2>
+ <% if (recent.length === 0) { %>
+ <p class="muted">No leads yet — your listing is live; consumers will start finding you. Make sure your <a href="/admin/profile">profile</a> is filled out.</p>
+ <% } else { %>
+ <table style="width:100%;border-collapse:collapse;margin:16px 0">
+ <thead>
+ <tr style="border-bottom:1px solid var(--border);text-align:left">
+ <th style="padding:8px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">When</th>
+ <th style="padding:8px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">From</th>
+ <th style="padding:8px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% recent.forEach(function(l){ %>
+ <tr style="border-bottom:1px solid var(--border)">
+ <td style="padding:10px 0;font-size:13px;color:var(--fg-muted)"><%= new Date(l.created_at).toLocaleString('en-US',{ dateStyle:'medium', timeStyle:'short' }) %></td>
+ <td style="padding:10px 0;font-size:13px"><strong><%= l.consumer_name %></strong> · <%= l.consumer_email %></td>
+ <td style="padding:10px 0;font-size:13px"><%= l.delivered_at ? '<span style="color:#16a34a">Delivered</span>' : '<span style="color:var(--fg-muted)">Pending</span>' %></td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <p style="margin-top:16px"><a href="/admin/leads" class="btn btn-ghost btn-sm">See all leads →</a></p>
+ <% } %>
+
+ <h2 style="margin-top:48px">Next steps</h2>
+ <ul>
+ <li><a href="/admin/profile">Edit your public profile</a> — make sure description, hours, contact, and Instagram are current.</li>
+ <li><a href="/admin/billing">Pick a tier</a> — Free is fine; paid tiers unlock featured placement and category-sidebar feature.</li>
+ <li><a href="/business/<%= currentBusiness.slug %>" target="_blank">View your live profile ↗</a></li>
+ </ul>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/leads.ejs b/views/admin/leads.ejs
new file mode 100644
index 0000000..bdcd2be
--- /dev/null
+++ b/views/admin/leads.ejs
@@ -0,0 +1,35 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:1100px">
+ <p class="kicker">Leads</p>
+ <h1 class="display-sm">Inbox</h1>
+ <p class="lede">Every customer message routed to <%= currentBusiness.business_name %>. Replies via your email client go straight to the customer.</p>
+
+ <% if (leads.length === 0) { %>
+ <p class="muted" style="margin-top:32px">No leads yet. Make sure your <a href="/admin/profile">profile</a> is filled out — the more complete the listing, the more inbound.</p>
+ <% } else { %>
+ <table style="width:100%;border-collapse:collapse;margin:24px 0;font-size:13px">
+ <thead>
+ <tr style="border-bottom:2px solid var(--fg);text-align:left">
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">When</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">From</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Message</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% leads.forEach(function(l){ %>
+ <tr style="border-bottom:1px solid var(--border);vertical-align:top">
+ <td style="padding:12px 8px;color:var(--fg-muted);white-space:nowrap"><%= new Date(l.created_at).toLocaleString('en-US',{ month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></td>
+ <td style="padding:12px 8px"><strong><%= l.consumer_name %></strong><br><a href="mailto:<%= l.consumer_email %>"><%= l.consumer_email %></a><% if (l.consumer_phone) { %><br><a href="tel:<%= l.consumer_phone %>"><%= l.consumer_phone %></a><% } %><% if (l.zip) { %><br><span style="color:var(--fg-muted)">ZIP <%= l.zip %></span><% } %></td>
+ <td style="padding:12px 8px;max-width:380px"><%= l.message || '—' %></td>
+ <td style="padding:12px 8px"><% if (l.delivered_at) { %><span style="color:#16a34a">Delivered</span><br><span style="color:var(--fg-muted);font-size:11px"><%= new Date(l.delivered_at).toLocaleString('en-US',{ month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></span><% } else { %><span style="color:var(--fg-muted)">Pending (≤5min)</span><% } %></td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/login.ejs b/views/admin/login.ejs
new file mode 100644
index 0000000..693c9e3
--- /dev/null
+++ b/views/admin/login.ejs
@@ -0,0 +1,24 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:420px">
+ <p class="kicker">Business sign in</p>
+ <h1 class="display-sm">Sign in to your dashboard.</h1>
+ <p class="lede">Manage your listing, see leads, and update your subscription.</p>
+
+ <% if (error) { %>
+ <p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p>
+ <% } %>
+
+ <form method="post" action="/admin/login" style="margin-top:24px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="hidden" name="next" value="<%= next %>">
+ <label>Email<input type="email" name="email" required value="<%= email %>" autocomplete="username"></label>
+ <label>Password<input type="password" name="password" required autocomplete="current-password"></label>
+ <button type="submit" class="btn btn-primary btn-lg" style="margin-top:8px">Sign in</button>
+ </form>
+
+ <p class="muted" style="margin-top:24px;font-size:13px">Haven't claimed your listing yet? Find your business in the <a href="/find">directory</a> and click "Claim this listing" on the profile page.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/profile.ejs b/views/admin/profile.ejs
new file mode 100644
index 0000000..7cb97d2
--- /dev/null
+++ b/views/admin/profile.ejs
@@ -0,0 +1,39 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:720px">
+ <p class="kicker">Profile</p>
+ <h1 class="display-sm">Edit your listing.</h1>
+ <p class="lede">Changes go live immediately on your public profile at <a href="/business/<%= biz.slug %>" target="_blank">/business/<%= biz.slug %></a>.</p>
+
+ <% if (ok) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Profile saved.</p><% } %>
+ <% if (error) { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p><% } %>
+
+ <form method="post" action="/admin/profile" style="margin-top:24px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+
+ <label>Business name<input type="text" name="business_name" required maxlength="200" value="<%= biz.business_name %>"></label>
+ <label>Headline (1 line)<input type="text" name="headline" maxlength="250" value="<%= biz.headline || '' %>" placeholder="e.g. Wood-fired Italian, family-run since 1998"></label>
+ <label>Description<textarea name="description" rows="5" maxlength="4000"><%= biz.description || '' %></textarea></label>
+
+ <h3 style="margin-top:24px">Address</h3>
+ <label>Street<input type="text" name="street" maxlength="200" value="<%= biz.street || '' %>"></label>
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+ <label>Neighborhood<input type="text" name="neighborhood" maxlength="80" value="<%= biz.neighborhood || '' %>"></label>
+ <label>City<input type="text" name="city" maxlength="80" value="<%= biz.city || '' %>"></label>
+ </div>
+ <label>ZIP<input type="text" name="zip" maxlength="10" value="<%= biz.zip || '' %>"></label>
+
+ <h3 style="margin-top:24px">Contact</h3>
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+ <label>Phone<input type="tel" name="phone" maxlength="32" value="<%= biz.phone || '' %>"></label>
+ <label>Email (where leads land)<input type="email" name="email" maxlength="200" value="<%= biz.email || '' %>"></label>
+ </div>
+ <label>Website<input type="text" name="website" maxlength="300" value="<%= biz.website || '' %>" placeholder="https://yoursite.com"></label>
+ <label>Instagram handle<input type="text" name="instagram_handle" maxlength="60" value="<%= biz.instagram_handle || '' %>" placeholder="yourhandle (no @)"></label>
+
+ <button type="submit" class="btn btn-primary btn-lg" style="margin-top:24px">Save profile</button>
+ </form>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 4c03d24..664659d 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -16,6 +16,11 @@
<span class="t-light" aria-hidden="true">☼</span>
<span class="t-dark" aria-hidden="true">☾</span>
</button>
+ <% if (currentBusinessUser) { %>
+ <a href="/admin" class="btn btn-ghost btn-sm">Dashboard</a>
+ <% } else { %>
+ <a href="/admin/login" class="btn btn-ghost btn-sm">Business sign in</a>
+ <% } %>
</div>
</div>
</header>
diff --git a/views/public/business.ejs b/views/public/business.ejs
index ccbda5f..95942cb 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -35,6 +35,7 @@
<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">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<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>
@@ -62,10 +63,19 @@
</div>
<% if (business.claim_status === 'unclaimed') { %>
- <div class="side-card" style="background:var(--bg);border-color:var(--brass)">
+ <div class="side-card" style="background:var(--bg);border-color:var(--brass)" id="claim">
<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>
+ <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. We'll email a confirmation link to verify ownership.</p>
+ <form action="/business/<%= business.slug %>/claim" method="post" style="display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 0">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <input type="email" name="email" required placeholder="you@<%= business.slug.split('-')[0] %>.com" style="flex:1;min-width:0">
+ <button type="submit" class="btn btn-primary btn-sm">Claim</button>
+ </form>
+ </div>
+ <% } else if (currentBusiness && currentBusiness.id === business.id) { %>
+ <div class="side-card" style="background:#dcfce7;border-color:#16a34a;color:#166534">
+ <h3 style="color:#166534">You own this listing</h3>
+ <p style="font-size:13px;line-height:1.5;margin:0 0 10px">Open your <a href="/admin">dashboard</a> to edit details or check leads.</p>
</div>
<% } %>
</aside>
diff --git a/views/public/claim-complete.ejs b/views/public/claim-complete.ejs
new file mode 100644
index 0000000..2c33e46
--- /dev/null
+++ b/views/public/claim-complete.ejs
@@ -0,0 +1,33 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <% if (!ok) { %>
+ <p class="kicker">Link not valid</p>
+ <h1 class="display-sm">This claim link can't be used.</h1>
+ <p class="lede"><%
+ if (reason === 'expired') { %>The 24-hour window expired.<%
+ } else if (reason === 'already_used') { %>This link was already used to claim a listing — sign in below.<%
+ } else { %>The link is malformed or doesn't match a current claim.<%
+ } %></p>
+ <p style="margin-top:24px"><a href="/admin/login" class="btn btn-primary">Sign in</a> <a href="/find" class="btn btn-ghost">Browse the directory</a></p>
+ <% } else { %>
+ <p class="kicker">Claim · <%= business.business_name %></p>
+ <h1 class="display-sm">Set a password to take ownership.</h1>
+ <p class="lede">Welcome — once you set a password, you'll be signed in and dropped on your dashboard. You'll receive new leads at <strong><%= email %></strong>.</p>
+
+ <% if (error) { %>
+ <p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p>
+ <% } %>
+
+ <form method="post" action="/claim/<%= encodeURIComponent(token) %>" style="margin-top:24px;max-width:420px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <label>Email<input type="email" value="<%= email %>" disabled style="background:var(--bg-alt);color:var(--fg-muted)"></label>
+ <label>Password (10+ characters)<input type="password" name="password" required minlength="10" autocomplete="new-password"></label>
+ <label>Confirm password<input type="password" name="password_confirm" required minlength="10" autocomplete="new-password"></label>
+ <button type="submit" class="btn btn-primary btn-lg" style="margin-top:8px">Claim <%= business.business_name %> →</button>
+ </form>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/claim-result.ejs b/views/public/claim-result.ejs
new file mode 100644
index 0000000..625b992
--- /dev/null
+++ b/views/public/claim-result.ejs
@@ -0,0 +1,19 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="text-align:center">
+ <% if (ok) { %>
+ <p class="kicker">Check your email</p>
+ <h1 class="display-sm">We sent a claim link to <strong><%= email %></strong>.</h1>
+ <p class="lede" style="margin:0 auto">Click the link in that email within 24 hours to set a password and take ownership of <strong><%= business.business_name %></strong>'s Ventura Claw listing.</p>
+ <p class="muted" style="margin-top:24px;font-size:13px">Didn't get it? Check spam, or email <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a>.</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">Email needed</h1>
+ <p class="lede" style="margin:0 auto"><%= error %></p>
+ <a href="/business/<%= business.slug %>#claim" class="btn btn-primary" style="margin-top:16px">← Try again</a>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
← 319e023 v0.2 #4: Stripe subscription billing — products, prices, web
·
back to Ventura Claw Leads
·
yolo tick 1: JSON-LD LocalBusiness + ItemList structured dat 520e445 →