← back to Lawyer Directory Builder
src/server/upgrade.ts
541 lines
/**
* EZ Upgrade — order capture + Stripe Checkout.
*
* GET /upgrade?prefill=<json> — order summary + Pay button
* POST /upgrade — creates order row + Stripe Checkout Session, redirects to Stripe
* GET /upgrade/thanks?session_id=… — verifies the session, marks order paid, shows confirmation
* GET /admin/orders — admin queue
* POST /admin/orders/:id/status — admin status updates
* POST /webhooks/stripe — webhook for redundancy (signed when STRIPE_WEBHOOK_SECRET is set)
*
* NOTE: Stripe keys are LIVE (sk_live_/pk_live_). Any session created here can
* charge a real card. We only create a session inside POST /upgrade after a
* lawyer has explicitly clicked Pay — never on smoke / GET / fixture.
*/
import express from 'express';
import Stripe from 'stripe';
import { query } from '../db/pool.ts';
import { requireAdmin } from './auth.ts';
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || '';
const stripe: Stripe | null = STRIPE_KEY.startsWith('sk_') ? new Stripe(STRIPE_KEY) : null;
const ORDER_STATUSES = ['pending_payment', 'paid', 'in_production', 'delivered', 'refunded', 'cancelled'] as const;
const router = express.Router();
router.use(express.urlencoded({ extended: false }));
function esc(s: any): string {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
}
function shellHead(title: string): string {
const monogram = `<svg viewBox="0 0 28 28" width="28" height="28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-up" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-up)" stroke-width="1"/><path fill="url(#cb-up)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg>`;
return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)} · Counsel & Bar</title>
<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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{--noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;--rule:#2a2724;--rule-faint:#1a1815;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;--good:#34d399;--warn:#d4a04a;--bad:#d87a7a;--serif:"Cormorant Garamond","Cormorant","Georgia",serif;--sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
*{box-sizing:border-box}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55}
::selection{background:var(--metal);color:var(--noir)}
a{color:var(--metal);text-decoration:none;transition:color 180ms ease}a:hover{color:var(--metal-glow)}
header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400}
header.brand h1 a{color:var(--ink)}
header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
.brand-mono{display:inline-flex;align-items:center;gap:14px;text-decoration:none}
.topnav{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);display:flex;gap:24px}
.topnav a{color:var(--ink-mute)}.topnav a:hover{color:var(--ink)}
main{max-width:1100px;margin:0 auto;padding:64px 48px}
.up-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
.up-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
.up-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
.up-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
h1.display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,72px);line-height:1.04;letter-spacing:-.02em;margin:0 0 18px;color:var(--ink);max-width:18ch}
h1.display em{font-style:italic;color:var(--metal);font-weight:400}
.lede{font-size:17px;color:var(--ink-soft);max-width:62ch;margin:0 0 56px;font-weight:300;line-height:1.6}
.flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
.flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
.flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
.muted{color:var(--ink-mute);font-size:13px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:14px 12px;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
.pill{display:inline-block;font-size:10px;padding:3px 9px;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
.pill.pending_payment{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
.pill.paid{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
.pill.in_production{background:#1a1a2a;color:#a5a0d4;border-color:#2a2a4a}
.pill.delivered{background:#0e2a26;color:#7fd1c4;border-color:#1a4a44}
.pill.refunded{background:#2a1410;color:var(--warn);border-color:#4a2520}
.pill.cancelled{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
.up-grid{display:grid;grid-template-columns:1.4fr 1fr;gap:64px;align-items:start}
@media (max-width:780px){.up-grid{grid-template-columns:1fr;gap:32px}}
.up-form{display:flex;flex-direction:column;gap:24px}
.up-fld{display:flex;flex-direction:column;gap:8px}
.up-fld-lbl{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.up-fld-in{padding:14px 0;background:transparent;color:var(--ink);border:none;border-bottom:1px solid var(--rule);font-family:var(--sans);font-size:16px;font-weight:300;letter-spacing:.005em;transition:border-color 200ms ease}
.up-fld-in:focus{outline:none;border-bottom-color:var(--metal)}
.up-fld-in::placeholder{color:var(--ink-mute);opacity:.5}
.up-cta{padding:22px 40px;background:transparent;color:var(--metal-glow);border:1px solid var(--metal);font-size:11px;letter-spacing:.22em;text-transform:uppercase;font-weight:500;cursor:pointer;align-self:flex-start;margin-top:18px;transition:background 280ms ease,color 280ms ease,letter-spacing 280ms ease,border-color 280ms ease}
.up-cta:hover{background:var(--metal);color:var(--noir);letter-spacing:.26em;border-color:var(--metal-glow)}
.up-cta:disabled{opacity:.45;cursor:not-allowed;letter-spacing:.22em;background:transparent;color:var(--ink-mute);border-color:var(--rule)}
.up-aside{background:linear-gradient(180deg,rgba(184,153,104,.025),transparent);border:1px solid var(--rule);padding:32px 36px;position:sticky;top:32px}
.up-aside-eyebrow{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 22px}
.up-line{display:flex;justify-content:space-between;padding:14px 0;border-bottom:1px solid var(--rule-faint);font-size:14px;align-items:baseline}
.up-line:last-of-type{border-bottom:none}
.up-line .lbl{color:var(--ink-soft);font-weight:300}
.up-line .val{font-family:var(--serif);font-style:italic;color:var(--metal);font-size:15px;letter-spacing:.02em}
.up-line.included .val{color:var(--ink-mute);font-style:normal;font-family:var(--sans);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:500}
.up-total{display:flex;justify-content:space-between;padding:24px 0 4px;border-top:1px solid var(--rule);margin-top:14px;align-items:baseline}
.up-total .lbl{font-size:11px;color:var(--ink-mute);letter-spacing:.22em;text-transform:uppercase;font-weight:500}
.up-total .val{font-family:var(--serif);font-weight:300;font-size:42px;color:var(--metal-glow);line-height:1;letter-spacing:-.01em}
.up-total .val sub{font-size:11px;color:var(--ink-mute);font-family:var(--sans);font-weight:500;margin-left:6px;letter-spacing:.18em;text-transform:uppercase;vertical-align:baseline}
.up-mini{font-size:11px;color:var(--ink-mute);letter-spacing:.06em;margin:24px 0 0;line-height:1.6}
.up-mini strong{color:var(--ink-soft);font-weight:500}
/* /upgrade/thanks editorial confirmation */
.up-receipt{margin:32px 0 12px;padding:24px 32px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.025),transparent);display:grid;grid-template-columns:repeat(3,1fr);gap:32px}
@media (max-width:720px){.up-receipt{grid-template-columns:1fr;gap:14px;padding:20px 24px}}
.up-r-row{display:flex;flex-direction:column;gap:4px}
.up-r-label{font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.up-r-val{font-family:var(--serif);font-style:italic;color:var(--metal);font-size:18px;font-variant-numeric:tabular-nums;letter-spacing:.02em}
.up-next{margin:64px 0 0;padding:48px 0 0;border-top:1px solid var(--rule)}
.up-next-h{font-family:var(--serif);font-weight:400;font-size:24px;color:var(--ink);margin:0 0 28px;letter-spacing:-.005em}
.up-next-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
@media (max-width:780px){.up-next-grid{grid-template-columns:1fr}}
.up-next-cell{padding:32px 28px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:12px}
.up-next-cell:last-child{border-right:none}
@media (max-width:780px){.up-next-cell{border-right:none;border-bottom:1px solid var(--rule);padding:28px 20px}.up-next-cell:last-child{border-bottom:none}}
.up-next-num{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:28px;line-height:1;letter-spacing:-.02em}
.up-next-cell h4{font-family:var(--serif);font-weight:400;font-size:18px;line-height:1.25;color:var(--ink);margin:0;letter-spacing:-.005em}
.up-next-cell p{font-size:13px;line-height:1.6;color:var(--ink-mute);margin:0;flex:1}
.up-manifest{margin:96px 0 0;padding:64px 0 0;border-top:1px solid var(--rule)}
.up-manifest-eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
.up-manifest-title{font-family:var(--serif);font-weight:300;font-size:clamp(28px,3.6vw,40px);line-height:1.1;letter-spacing:-.015em;margin:0 0 8px;color:var(--ink);max-width:22ch}
.up-manifest-title em{font-style:italic;color:var(--metal);font-weight:400}
.up-manifest-sub{font-family:var(--serif);font-style:italic;color:var(--ink-mute);font-size:16px;margin:0 0 56px}
.up-beats{display:grid;grid-template-columns:repeat(4,1fr);gap:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
.up-beat{padding:48px 28px 36px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:16px;position:relative}
.up-beat:last-child{border-right:0}
.up-beat-num{font-family:var(--serif);font-style:italic;font-weight:300;font-size:64px;line-height:.9;letter-spacing:-.02em;color:transparent;-webkit-text-stroke:1px var(--metal);margin:0;display:block}
.up-beat-eyebrow{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;margin:0}
.up-beat-h{font-family:var(--serif);font-weight:400;font-size:22px;line-height:1.2;color:var(--ink);margin:0;letter-spacing:-.005em}
.up-beat-p{font-size:14px;line-height:1.65;color:var(--ink-mute);margin:0;font-weight:300;max-width:32ch}
.up-beat-meta{font-family:var(--serif);font-style:italic;color:var(--metal);font-size:13px;margin-top:auto;padding-top:14px;border-top:1px solid var(--rule-faint);letter-spacing:.01em}
@media (max-width:880px){.up-beats{grid-template-columns:repeat(2,1fr)} .up-beat{border-right:0;border-bottom:1px solid var(--rule)} .up-beat:nth-child(odd){border-right:1px solid var(--rule)} .up-beat:nth-last-child(-n+2){border-bottom:0}}
@media (max-width:520px){.up-beats{grid-template-columns:1fr} .up-beat,.up-beat:nth-child(odd){border-right:0;border-bottom:1px solid var(--rule)} .up-beat:last-child{border-bottom:0}}
.up-footer-row{margin:48px 0 0;padding-top:32px;border-top:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px}
.up-cta-quiet{padding:16px 28px;background:transparent;color:var(--metal-glow);border:1px solid var(--metal);font-size:11px;letter-spacing:.22em;text-transform:uppercase;font-weight:500;text-decoration:none;display:inline-flex;align-items:center;transition:background 280ms ease,color 280ms ease,letter-spacing 280ms ease}
.up-cta-quiet:hover{background:var(--metal);color:var(--noir);letter-spacing:.26em}
.up-trust{padding:24px 28px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.018),transparent)}
.up-trust-eyebrow{font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 12px}
.up-trust p{font-size:13px;line-height:1.7;color:var(--ink-mute);margin:0}
@media (prefers-reduced-motion: no-preference){
.scroll-reveal{opacity:0;transform:translateY(22px);transition:opacity 900ms cubic-bezier(.16,.84,.3,1),transform 900ms cubic-bezier(.16,.84,.3,1)}
.scroll-reveal.in-view{opacity:1;transform:none}
.scroll-reveal.s1{transition-delay:80ms}.scroll-reveal.s2{transition-delay:160ms}.scroll-reveal.s3{transition-delay:240ms}
}
*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
*:focus:not(:focus-visible){outline:none}
</style></head><body>
<header class="brand">
<a class="brand-mono" href="/">${monogram}<h1 style="margin:0"><span style="color:var(--ink)">Counsel</span> <span class="amp">&</span> <span style="color:var(--ink)">Bar</span></h1></a>
<nav class="topnav">
<a href="/find-a-lawyer">Find counsel</a>
<a href="/directory">Directory</a>
<a href="/login">Sign in</a>
</nav>
</header>
<main>`;
}
const FOOT = `</main>
<script>
(function(){
if(!('IntersectionObserver' in window))return;
if(window.matchMedia('(prefers-reduced-motion: reduce)').matches){
document.querySelectorAll('.scroll-reveal').forEach(function(el){el.classList.add('in-view');});return;
}
var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in-view');io.unobserve(e.target);}});},{threshold:0.12,rootMargin:'0px 0px -10% 0px'});
document.querySelectorAll('.scroll-reveal').forEach(function(el){io.observe(el);});
// Disable submit on Pay click to prevent double-charge during Stripe redirect
var f = document.querySelector('form.up-form');
if (f) f.addEventListener('submit', function(){
var btn = f.querySelector('.up-cta');
if (btn) { btn.disabled = true; btn.textContent = 'Redirecting to Stripe…'; }
});
})();
</script>
</body></html>`;
// ─── GET /upgrade ──────────────────────────────────────────────────────────
router.get('/upgrade', (req, res) => {
let p: any = {};
try { if (req.query.prefill) p = JSON.parse(String(req.query.prefill)); } catch {}
const err = req.query.err ? `<div class="flash error">${esc(req.query.err)}</div>` : '';
res.send(shellHead('EZ Upgrade — Reserve') + `
<div class="up-vol">
<span class="v-rule"></span>
<span class="v-text">Volume I</span>
<span class="v-meta">Edition 2026 · EZ Upgrade · One-time $499</span>
</div>
<h1 class="display">Reserve your <em>EZ Upgrade.</em></h1>
<p class="lede">One-time <strong>$499</strong>. Typical go-live: three to four weeks from content intake — five to seven business days to first proof, then a fourteen-day revision window. Your existing domain, presented at the standard your firm deserves. Stripe handles the payment; you'll receive a confirmation receipt and a project tracker by email.</p>
${err}
<div class="up-grid">
<form method="post" action="/upgrade" class="up-form scroll-reveal">
<div class="up-fld">
<label class="up-fld-lbl">Your name</label>
<input class="up-fld-in" name="full_name" required value="${esc(p.full_name || '')}" autocomplete="name" placeholder="Jane Doe, Esq.">
</div>
<div class="up-fld">
<label class="up-fld-lbl">Email</label>
<input class="up-fld-in" name="email" type="email" required value="${esc(p.email || '')}" autocomplete="email" placeholder="you@firm.com">
</div>
<div class="up-fld">
<label class="up-fld-lbl">Firm name</label>
<input class="up-fld-in" name="firm_name" value="${esc(p.firm_name || '')}" autocomplete="organization" placeholder="Optional — pulled from State Bar if linked">
</div>
<div class="up-fld">
<label class="up-fld-lbl">Current website <span style="color:var(--ink-mute);font-size:9px;letter-spacing:.12em;text-transform:none;font-style:italic;font-family:var(--serif)">— we'll work from this</span></label>
<input class="up-fld-in" name="website" type="url" value="${esc(p.website || '')}" autocomplete="url" placeholder="https://your-current-site.com">
</div>
<div class="up-fld">
<label class="up-fld-lbl">Phone</label>
<input class="up-fld-in" name="phone" type="tel" value="${esc(p.phone || '')}" autocomplete="tel" placeholder="(415) 555-0100">
</div>
<input type="hidden" name="bar_number" value="${esc(p.bar_number || '')}">
<button type="submit" class="up-cta">Continue to payment <span style="font-style:italic;font-family:var(--serif);margin-left:6px">→</span></button>
<p class="up-mini" style="margin-top:18px"><strong>Payment processed by Stripe.</strong> Your full card number stays with Stripe; Counsel & Bar receives only the last four digits and billing details needed for your receipt and refund processing. Full refund available any time before first proof is delivered.</p>
<p class="up-mini" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--rule-faint)">By continuing you agree to the <strong>Build Agreement</strong>: seven-business-day typical go-live from content intake, 14-day revision window from first proof, first-year hosting included, full refund any time before first proof. Site-build deliverable only — directory listing remains free.</p>
</form>
<aside class="up-aside scroll-reveal s1">
<p class="up-aside-eyebrow">— Order summary —</p>
<div class="up-line">
<span class="lbl">EZ Upgrade — personalized site</span>
<span class="val">$499</span>
</div>
<div class="up-line included">
<span class="lbl">Setup & brand review</span>
<span class="val">included</span>
</div>
<div class="up-line included">
<span class="lbl">14-day revisions window</span>
<span class="val">included</span>
</div>
<div class="up-line included">
<span class="lbl">First-year hosting</span>
<span class="val">included</span>
</div>
<div class="up-total">
<span class="lbl">Total</span>
<span class="val">$499<sub>USD · one-time</sub></span>
</div>
<p class="up-mini" style="border-top:1px solid var(--rule-faint);padding-top:18px;margin-top:24px">No subscription, no recurring charges, no upsells. <strong>This is a site-build service, not a directory placement fee.</strong> The Counsel & Bar listing remains free for any California-licensed attorney whether or not they purchase this upgrade.</p>
</aside>
</div>
<section class="up-manifest scroll-reveal">
<p class="up-manifest-eyebrow">The build manifest</p>
<h2 class="up-manifest-title">Four beats from <em>reservation to live.</em></h2>
<p class="up-manifest-sub">No mystery, no Slack channel you'll never check. A schedule you can plan a calendar around.</p>
<div class="up-beats">
<div class="up-beat">
<span class="up-beat-num">i</span>
<p class="up-beat-eyebrow">Brief intake</p>
<h3 class="up-beat-h">We read everything you have.</h3>
<p class="up-beat-p">A short questionnaire plus a review of your current site, listed practice areas, and any case-acceptance criteria. You confirm what stays and what goes.</p>
<p class="up-beat-meta">— within 24 business hours</p>
</div>
<div class="up-beat">
<span class="up-beat-num">ii</span>
<p class="up-beat-eyebrow">First proof</p>
<h3 class="up-beat-h">A complete site, not a wireframe.</h3>
<p class="up-beat-p">Full pages — typography, palette, copy, structure — at the standard published in our visual standard. Not a mood board, not a Figma. A working URL.</p>
<p class="up-beat-meta">— five to seven business days</p>
</div>
<div class="up-beat">
<span class="up-beat-num">iii</span>
<p class="up-beat-eyebrow">Revisions</p>
<h3 class="up-beat-h">Two rounds. Tracked in writing.</h3>
<p class="up-beat-p">Copy edits, photo swaps, structural rearrangements — all welcome. Each round is acknowledged the same business day; both rounds close inside the 14-day window.</p>
<p class="up-beat-meta">— 14-day window from first proof</p>
</div>
<div class="up-beat">
<span class="up-beat-num">iv</span>
<p class="up-beat-eyebrow">Go-live</p>
<h3 class="up-beat-h">Your domain, our standard.</h3>
<p class="up-beat-p">Cutover to your existing domain at a time you choose; first-year hosting included; SSL, sitemap, schema.org markup, and redirect map all configured before launch.</p>
<p class="up-beat-meta">— same day as your approval</p>
</div>
</div>
</section>
` + FOOT);
});
// ─── POST /upgrade — create order + Stripe Checkout Session ───────────────
router.post('/upgrade', async (req, res) => {
if (!stripe) {
return res.redirect('/upgrade?err=' + encodeURIComponent('Payments are temporarily offline — try again in a few minutes.'));
}
if (!req.body || typeof req.body !== 'object') {
return res.status(400).send('Invalid request body');
}
const b = req.body as any;
try {
if (!b.full_name || !b.email) throw new Error('Name and email are required');
const ins = await query<{ id: number }>(`
INSERT INTO upgrade_orders (full_name, email, phone, bar_number, firm_name, website,
app_user_id, plan, amount_cents, ip, user_agent)
VALUES ($1, LOWER($2), $3, $4, $5, $6, $7, 'ez_upgrade_499', 49900, $8, $9)
RETURNING id
`, [
String(b.full_name).trim().slice(0, 120),
String(b.email).trim().slice(0, 200),
b.phone ? String(b.phone).trim().slice(0, 40) : null,
b.bar_number ? String(b.bar_number).trim().slice(0, 20) : null,
b.firm_name ? String(b.firm_name).trim().slice(0, 200) : null,
b.website ? String(b.website).trim().slice(0, 250) : null,
(req as any).user?.id || null,
req.ip || null,
(req.headers['user-agent'] || '').toString().slice(0, 200),
]);
const orderId = ins.rows[0].id;
// Stripe redirect URLs must NOT come from request headers — an attacker
// forging x-forwarded-host on a direct (non-proxied) hit could redirect
// the customer to evil.com after a real charge. Pin to PUBLIC_BASE_URL.
const base = process.env.PUBLIC_BASE_URL?.replace(/\/+$/, '')
|| 'https://lawyers.agentabrams.com';
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
customer_email: String(b.email).trim().toLowerCase(),
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: 'Counsel & Bar — EZ Upgrade',
description: 'Personalized website rebuild · typical go-live: 7 business days from content intake · 14-day revision window from first proof · first-year hosting included',
},
unit_amount: 49900,
},
quantity: 1,
}],
metadata: {
order_id: String(orderId),
full_name: String(b.full_name).trim(),
bar_number: String(b.bar_number || ''),
firm_name: String(b.firm_name || ''),
},
success_url: `${base}/upgrade/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${base}/upgrade?prefill=${encodeURIComponent(JSON.stringify(b))}`,
});
await query(`UPDATE upgrade_orders SET payment_link = $2 WHERE id = $1`, [orderId, session.url]);
res.redirect(303, session.url || '/upgrade');
} catch (e) {
console.error('[upgrade] checkout error:', e);
res.redirect('/upgrade?err=' + encodeURIComponent((e as Error).message.slice(0, 200)));
}
});
// ─── GET /upgrade/thanks ──────────────────────────────────────────────────
router.get('/upgrade/thanks', async (req, res) => {
const sid = (req.query.session_id as string) || '';
// Local-dev preview: ?_demo=paid forces the paid branch in non-prod.
// SECURITY: was checking req.hostname which is spoofable via Host header
// (especially with `trust proxy`). Now gated on NODE_ENV !== 'production'.
const isNonProd = process.env.NODE_ENV !== 'production';
const demoPaid = req.query._demo === 'paid' && isNonProd;
let paid = demoPaid;
let orderId: number | null = demoPaid ? 1 : null;
let amountPaid = 49900;
if (stripe && sid) {
try {
const s = await stripe.checkout.sessions.retrieve(sid);
if (s.payment_status === 'paid' && s.metadata?.order_id) {
paid = true;
orderId = parseInt(s.metadata.order_id, 10);
if (typeof s.amount_total === 'number') amountPaid = s.amount_total;
await query(
`UPDATE upgrade_orders SET status = 'paid', paid_at = NOW() WHERE id = $1 AND status = 'pending_payment'`,
[orderId]);
}
} catch (e) {
console.error('[upgrade] thanks lookup err:', e);
}
}
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const orderNo = orderId
? `EZ-${now.getFullYear()}-${pad(now.getMonth() + 1)}${pad(now.getDate())}-${String(orderId).padStart(4, '0')}`
: `EZ-${now.getFullYear()}-${pad(now.getMonth() + 1)}${pad(now.getDate())}-PEND`;
const tstamp = `${pad(now.getHours())}:${pad(now.getMinutes())} PT · ${now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
const dollars = `$${(amountPaid / 100).toFixed(0)}`;
const body = paid
? `
<div class="up-vol">
<span class="v-rule"></span>
<span class="v-text">Volume I</span>
<span class="v-meta">Edition 2026 · Order Confirmed</span>
</div>
<h1 class="display">Order <em>received,</em> counselor.</h1>
<p class="lede">Payment confirmed by Stripe. Your build slot is reserved and your project tracker is being prepared. Discovery questions arrive in your inbox within <strong>twenty-four business hours</strong>. The seven-business-day clock starts the moment you return your content brief.</p>
<div class="up-receipt scroll-reveal">
<div class="up-r-row">
<span class="up-r-label">Order №</span>
<span class="up-r-val">${orderNo}</span>
</div>
<div class="up-r-row">
<span class="up-r-label">Logged</span>
<span class="up-r-val">${tstamp}</span>
</div>
<div class="up-r-row">
<span class="up-r-label">Charged</span>
<span class="up-r-val">${dollars}<sub style="font-size:9px;color:var(--ink-mute);font-family:var(--sans);font-style:normal;letter-spacing:.18em;text-transform:uppercase;margin-left:6px">USD · one-time</sub></span>
</div>
</div>
<section class="up-next">
<h3 class="up-next-h">What happens next</h3>
<div class="up-next-grid">
<div class="up-next-cell scroll-reveal s1">
<span class="up-next-num">i.</span>
<h4>Intake brief — within 24 hours</h4>
<p>You'll receive a short content brief by email: firm story, practice areas, photos you'd like used, copy you've already written, links to existing materials. No call required unless you prefer one.</p>
</div>
<div class="up-next-cell scroll-reveal s2">
<span class="up-next-num">ii.</span>
<h4>First proof — five business days</h4>
<p>Working from your brief, we draft the home, about, services, and contact pages. You receive a private preview link and the seven-day go-live clock starts here.</p>
</div>
<div class="up-next-cell scroll-reveal s3">
<span class="up-next-num">iii.</span>
<h4>Revisions & go-live — 14-day window</h4>
<p>Mark up the proof. Two rounds of revisions included — copy edits, image swaps, layout adjustments within the original four-page scope. New pages or scope changes are quoted separately. We point your domain on go-live day. First-year hosting included; year two billed separately at our published rate.</p>
</div>
</div>
</section>
<div class="up-footer-row">
<p class="up-mini" style="margin:0;max-width:54ch">Receipt and refund details have been emailed to the address on file. <strong>Full refund</strong> available any time before the first proof is delivered. Timeline begins on receipt of your content brief; delays in returning the brief shift the proof and go-live dates by the same number of days. Site-build deliverable only; the Counsel & Bar listing remains free.</p>
<a href="/dashboard" class="up-cta-quiet">Open dashboard <span style="font-style:italic;font-family:var(--serif);margin-left:6px">→</span></a>
</div>
<aside class="up-trust scroll-reveal" style="margin-top:48px">
<p class="up-trust-eyebrow">For the record</p>
<p>Counsel & Bar is a directory of California-licensed attorneys, not a lawyer referral service under §6155. The EZ Upgrade is an independent site-build service; purchasing it does not grant priority placement, fee-share arrangements, or any change to the directory's neutral proximity-ranked surface. Stripe acts as our payment processor; full card data remains with Stripe per their PCI-compliant flow. Site copy you supply or approve remains your professional responsibility under California Rule of Professional Conduct 7.x — we can flag obvious advertising-rule risks but do not provide legal review.</p>
</aside>`
: `
<div class="up-vol">
<span class="v-rule"></span>
<span class="v-text">Volume I</span>
<span class="v-meta">Edition 2026 · Reservation Pending</span>
</div>
<h1 class="display">Reservation <em>noted.</em></h1>
<p class="lede">If your payment is still processing, give Stripe a few seconds and refresh this page. If you cancelled, no charge was made and your reservation is released — feel free to come back whenever you're ready.</p>
<p style="margin-top:32px"><a href="/upgrade" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal);border-bottom:1px solid var(--rule);padding-bottom:3px">← Try again</a></p>`;
res.send(shellHead(paid ? 'Order Confirmed' : 'Reservation Pending') + body + FOOT);
});
// ─── POST /webhooks/stripe (signed verification) ───────────────────────────
router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!stripe || !secret) return res.status(503).send('webhook not configured');
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (e) {
return res.status(400).send(`webhook signature err: ${(e as Error).message}`);
}
if (event.type === 'checkout.session.completed') {
const s = event.data.object as Stripe.Checkout.Session;
const orderId = s.metadata?.order_id ? parseInt(s.metadata.order_id, 10) : null;
if (orderId) {
await query(
`UPDATE upgrade_orders SET status = 'paid', paid_at = NOW() WHERE id = $1 AND status = 'pending_payment'`,
[orderId]);
}
}
res.json({ received: true });
});
// ─── /admin/orders ─────────────────────────────────────────────────────────
router.get('/admin/orders', requireAdmin, async (req, res) => {
const status = ((req.query.status as string) || '').trim();
const where = status && (ORDER_STATUSES as readonly string[]).includes(status) ? `WHERE status = $1` : '';
const params: any[] = status && (ORDER_STATUSES as readonly string[]).includes(status) ? [status] : [];
const r = await query(`
SELECT id, full_name, email, phone, firm_name, website, bar_number,
plan, amount_cents, status, payment_link, paid_at, created_at
FROM upgrade_orders ${where}
ORDER BY id DESC LIMIT 200
`, params);
const counts = await query<{ k: string; v: string }>(`
SELECT 'total' AS k, COUNT(*)::text AS v FROM upgrade_orders
UNION ALL SELECT 'pending', COUNT(*)::text FROM upgrade_orders WHERE status='pending_payment'
UNION ALL SELECT 'paid', COUNT(*)::text FROM upgrade_orders WHERE status='paid'
UNION ALL SELECT 'revenue', COALESCE(SUM(amount_cents),0)::text FROM upgrade_orders WHERE status IN ('paid','in_production','delivered')
`);
const cm: Record<string, string> = {}; for (const row of counts.rows) cm[row.k] = row.v;
const rev = (parseInt(cm.revenue || '0', 10) / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const rows = r.rows.map((o: any) => {
const opts = ORDER_STATUSES.map(s => `<option value="${s}"${o.status === s ? ' selected' : ''}>${s}</option>`).join('');
return `<tr>
<td>#${o.id}<br><span class="muted">${new Date(o.created_at).toISOString().slice(0,16).replace('T',' ')}</span></td>
<td><b>${esc(o.full_name)}</b><br><a href="mailto:${esc(o.email)}">${esc(o.email)}</a>${o.phone ? `<br><a href="tel:${esc(o.phone)}">${esc(o.phone)}</a>` : ''}</td>
<td>${esc(o.firm_name || '—')}<br><span class="muted">${esc(o.website || '')}</span></td>
<td>$${(o.amount_cents/100).toFixed(0)}<br><span class="muted">${esc(o.plan)}</span></td>
<td><span class="pill ${esc(o.status)}">${esc(o.status)}</span>${o.paid_at ? `<br><span class="muted">${new Date(o.paid_at).toISOString().slice(0,10)}</span>` : ''}</td>
<td>${o.payment_link ? `<a href="${esc(o.payment_link)}" target="_blank">Stripe link ↗</a>` : '<span class="muted">—</span>'}</td>
<td><form method="post" action="/admin/orders/${o.id}/status"><select name="status" onchange="this.form.submit()" style="background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);padding:6px 10px;font-size:11px">${opts}</select></form></td>
</tr>`;
}).join('');
res.send(shellHead('Admin · Orders') + `
<p class="eyebrow">Admin · Upgrade orders</p>
<h1 class="display">${cm.total || 0} orders<br><em style="color:var(--metal)">${rev}</em> in flight</h1>
<p class="muted" style="margin-bottom:32px"><b>${cm.pending || 0}</b> pending · <b>${cm.paid || 0}</b> paid · revenue includes paid + in-production + delivered</p>
<table>
<thead><tr><th>ID / When</th><th>Customer</th><th>Firm</th><th>Amount</th><th>Status</th><th>Stripe</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="7" style="padding:30px;text-align:center;color:var(--ink-mute)">No orders yet</td></tr>'}</tbody>
</table>
<p class="muted" style="margin-top:32px"><a href="/admin">← Back to admin</a> · <a href="/admin/leads">Leads queue</a></p>
` + FOOT);
});
router.post('/admin/orders/:id/status', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!req.body || typeof req.body !== 'object') {
return res.status(400).send('Invalid request body');
}
const status = (req.body.status as string)?.trim();
if (!(ORDER_STATUSES as readonly string[]).includes(status)) {
return res.redirect('/admin/orders?err=' + encodeURIComponent('Invalid status'));
}
const setPaid = status === 'paid' ? `, paid_at = COALESCE(paid_at, NOW())` : '';
const setDelivered = status === 'delivered' ? `, delivered_at = COALESCE(delivered_at, NOW())` : '';
await query(`UPDATE upgrade_orders SET status = $2 ${setPaid} ${setDelivered} WHERE id = $1`, [id, status]);
res.redirect('/admin/orders?msg=' + encodeURIComponent(`#${id} → ${status}`));
});
export default router;