From d82d71d0b25eb336bdc81a0cfbc521f152054436 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Sat, 18 Jul 2026 00:23:55 +0530 Subject: [PATCH] =?UTF-8?q?feat(founder):=20payment=E2=86=92register=20flo?= =?UTF-8?q?w=20+=20founder-only=20registration=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Next API routes: /api/register + /api/checkout (Stripe session) + /api/stripe-webhook (signature-verified; fulfils through be-crm), ported from the landing's serverless functions. Shared founder lead validation in src/lib/founder/lead.ts. - /api/founder/lookup + /api/founder/session proxy be-crm's public endpoints. - /thanks polls session_id β†’ founder token β†’ redirects to /portal/register?ft=; falls back to the emailed link if the webhook is briefly delayed. - register-flow: reads ?ft=, validates + prefills (email locked, name, phone) from the paid founder record, passes founderToken to crm.account.register (claims token + provisions owner). Open registration is now blocked β€” only a paid founder (ft) or a team invitee may register; everyone else sees a 'Founder access required' gate. --- public/thanks.html | 41 ++++++- src/app/api/checkout/route.ts | 129 ++++++++++++++++++++ src/app/api/founder/lookup/route.ts | 27 +++++ src/app/api/founder/session/route.ts | 27 +++++ src/app/api/register/route.ts | 28 +++++ src/app/api/stripe-webhook/route.ts | 155 ++++++++++++++++++++++++ src/components/portal/register-flow.tsx | 80 +++++++++++- src/lib/founder/lead.ts | 123 +++++++++++++++++++ 8 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 src/app/api/checkout/route.ts create mode 100644 src/app/api/founder/lookup/route.ts create mode 100644 src/app/api/founder/session/route.ts create mode 100644 src/app/api/register/route.ts create mode 100644 src/app/api/stripe-webhook/route.ts create mode 100644 src/lib/founder/lead.ts diff --git a/public/thanks.html b/public/thanks.html index dbd6fa4..de9d30b 100644 --- a/public/thanks.html +++ b/public/thanks.html @@ -81,7 +81,8 @@
3Watch for launch updates. As a Founding Member you'll hear first about new features and your rollout timeline.
- Close + Set up your account +

Preparing your account…

Questions? Email support@lynkeduppro.com and reference your order below. @@ -92,9 +93,10 @@ diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..f01b55e --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server"; +import { validateLead, leadToStripeMetadata, SELF_SERVE_MAX } from "@/lib/founder/lead"; + +// Validates the buyer info captured by the pre-checkout form, then creates a +// Stripe Checkout Session and returns its hosted-checkout URL. The Stripe secret +// key stays server-side and is NEVER exposed to the browser. Ported from the +// landing's api/checkout.js. +// +// Env: +// STRIPE_SECRET_KEY (required) sk_test_… / sk_live_… β€” swap to switch modes +// CHECKOUT_PRICE cents, default 200000 (USD 2000.00) +// CHECKOUT_CURRENCY ISO code, default usd +// CHECKOUT_PRODUCT_NAME line-item name, default "Founders Lifetime Access" + +export const runtime = "nodejs"; + +// Bump when the SMS consent wording in public/square-checkout.js changes (TCPA evidence). +const SMS_CONSENT_VERSION = "v1"; +// Server-side promo table β€” the client can only pass a code name, never a price. +const PROMOS: Record = { SAVE30: 30, SAVE50: 50, SAVE70: 70, SAVE99: 99, JUSTIN99: 99 }; + +export async function POST(request: Request) { + try { + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + return NextResponse.json({ error: "Missing STRIPE_SECRET_KEY environment variable on the server." }, { status: 500 }); + } + + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + body = {}; + } + + // Never trust the browser: the form validates for UX, this validates for real. + const result = validateLead(body); + if (!result.ok) { + return NextResponse.json({ error: "Please check the highlighted fields.", fields: result.errors }, { status: 400 }); + } + const lead = result.lead; + + // Orders over the self-serve limit are NOT payable online β€” they go to sales. + if (lead.licenses > SELF_SERVE_MAX) { + return NextResponse.json( + { error: `Orders of more than ${SELF_SERVE_MAX} licenses are handled by our sales team.`, path: "sales", licenses: lead.licenses }, + { status: 409 }, + ); + } + + const url = new URL(request.url); + const origin = originFromRequest(request, url); + + let amount = parseInt(process.env.CHECKOUT_PRICE || "200000", 10); // USD 2000.00 in cents + let currency = (process.env.CHECKOUT_CURRENCY || "usd").toLowerCase(); + let productName = process.env.CHECKOUT_PRODUCT_NAME || "Founders Lifetime Access"; + + // Optional $1 demo plan (?plan=demo) β€” the amount is fixed server-side. + const plan = url.searchParams.get("plan"); + if (plan === "demo") { + amount = 100; + currency = "usd"; + productName = "LynkedUp Pro Demo Access ($1)"; + } + + // Promo codes (?promo=CODE) β€” percent-off applied here, never trusting a client price. + const promoCode = (url.searchParams.get("promo") || "").trim().toUpperCase(); + let appliedPct = 0; + if (plan !== "demo" && promoCode && Object.prototype.hasOwnProperty.call(PROMOS, promoCode)) { + appliedPct = PROMOS[promoCode]; + amount = Math.max(50, Math.round((amount * (100 - appliedPct)) / 100)); // Stripe min charge $0.50 + productName = `${productName} (${appliedPct}% off Β· code ${promoCode})`; + } + + const consentIp = String(request.headers.get("x-forwarded-for") || "").split(",")[0].trim(); + const capturedAt = new Date().toISOString(); + // Let Stripe multiply via quantity so the Dashboard shows "N x $X". Demo is a single unit. + const quantity = plan === "demo" ? 1 : lead.licenses; + + const metadata = leadToStripeMetadata(lead, { + sms_consent_version: SMS_CONSENT_VERSION, + sms_consent_at: lead.smsConsent ? capturedAt : "", + sms_consent_ip: consentIp, + plan: plan || "founders", + promo_code: appliedPct ? promoCode : "", + unit_amount_cents: String(amount), + total_amount_cents: String(amount * quantity), + }); + + const params = new URLSearchParams({ + mode: "payment", + success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/?canceled=1`, + "line_items[0][quantity]": String(quantity), + "line_items[0][price_data][currency]": currency, + "line_items[0][price_data][unit_amount]": String(amount), + "line_items[0][price_data][product_data][name]": productName, + customer_email: lead.email, + customer_creation: "always", + billing_address_collection: "required", + }); + // Mirror onto the Session (drives our webhook) AND the PaymentIntent (visible in Dashboard). + for (const [k, v] of Object.entries(metadata)) { + params.set(`metadata[${k}]`, String(v ?? "")); + params.set(`payment_intent_data[metadata][${k}]`, String(v ?? "")); + } + + const r = await fetch("https://api.stripe.com/v1/checkout/sessions", { + method: "POST", + headers: { Authorization: `Bearer ${secretKey}`, "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + const data = await r.json(); + if (!r.ok) return NextResponse.json({ error: "Stripe checkout creation failed", detail: data }, { status: 502 }); + if (!data.url) return NextResponse.json({ error: "Stripe did not return a checkout URL", detail: data }, { status: 502 }); + + return NextResponse.json({ url: data.url }); + } catch (e) { + return NextResponse.json({ error: String((e as Error)?.message || e) }, { status: 500 }); + } +} + +// Build the public origin from the incoming request so success/cancel redirects +// work on any deployment (preview or production). +function originFromRequest(request: Request, url: URL): string { + const proto = (request.headers.get("x-forwarded-proto") || url.protocol.replace(":", "") || "https").split(",")[0]; + const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || url.host; + return `${proto}://${host}`; +} diff --git a/src/app/api/founder/lookup/route.ts b/src/app/api/founder/lookup/route.ts new file mode 100644 index 0000000..a242887 --- /dev/null +++ b/src/app/api/founder/lookup/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +// Server-side proxy to be-crm's public founder-access lookup, so the register page +// can prefill the paid founder's details (and confirm the token is still valid) +// before they have a session. Server-to-server avoids CORS. The token is the only +// credential β€” it was emailed to the founder / carried from /thanks. + +export const runtime = "nodejs"; + +const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, ""); + +export async function GET(request: Request) { + const token = new URL(request.url).searchParams.get("token") ?? ""; + if (token.length < 16 || token.length > 256) { + return NextResponse.json({ ok: false, status: "invalid" }, { status: 400 }); + } + try { + const res = await fetch(`${CRM_BASE_URL}/public/founder-access/lookup?token=${encodeURIComponent(token)}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 }); + return NextResponse.json(await res.json()); + } catch { + return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 }); + } +} diff --git a/src/app/api/founder/session/route.ts b/src/app/api/founder/session/route.ts new file mode 100644 index 0000000..6b48280 --- /dev/null +++ b/src/app/api/founder/session/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +// Server-side proxy to be-crm's founder-access by-session lookup. The /thanks page +// (post-Stripe) polls this with the checkout session_id until the webhook has +// fulfilled the purchase and a founder token exists, then redirects to the +// prefilled register page. `pending` means "poll again shortly". + +export const runtime = "nodejs"; + +const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, ""); + +export async function GET(request: Request) { + const sessionId = new URL(request.url).searchParams.get("session_id") ?? ""; + if (!sessionId || sessionId.length > 256) { + return NextResponse.json({ ok: false, status: "pending" }, { status: 400 }); + } + try { + const res = await fetch(`${CRM_BASE_URL}/public/founder-access/by-session?session_id=${encodeURIComponent(sessionId)}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) return NextResponse.json({ ok: false, status: "pending" }, { status: 502 }); + return NextResponse.json(await res.json()); + } catch { + return NextResponse.json({ ok: false, status: "pending" }, { status: 502 }); + } +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts new file mode 100644 index 0000000..a76aa22 --- /dev/null +++ b/src/app/api/register/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { validateLead, SELF_SERVE_MAX } from "@/lib/founder/lead"; + +// Step-1 record capture for the founder-access form, BEFORE the payment branch. +// Validates the same required fields as checkout and tells the client which path +// to take by license count (<=SELF_SERVE_MAX -> Stripe checkout, else -> sales). +// No payment happens here; the authoritative "paid" fulfillment is the Stripe +// webhook -> be-crm. Ported from the landing's api/register.js. + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + body = {}; + } + + const result = validateLead(body); + if (!result.ok) { + return NextResponse.json({ error: "Please check the highlighted fields.", fields: result.errors }, { status: 400 }); + } + + const lead = result.lead; + const path = lead.licenses > SELF_SERVE_MAX ? "sales" : "checkout"; + return NextResponse.json({ ok: true, path, licenses: lead.licenses }); +} diff --git a/src/app/api/stripe-webhook/route.ts b/src/app/api/stripe-webhook/route.ts new file mode 100644 index 0000000..4ae35ba --- /dev/null +++ b/src/app/api/stripe-webhook/route.ts @@ -0,0 +1,155 @@ +import crypto from "node:crypto"; + +// Stripe webhook β€” the ONLY trustworthy signal that money actually moved. On a +// paid Founders-License checkout it hands the purchase to be-crm, which mints the +// founder token, sends the welcome email with the register link, and marks the +// email owner-eligible. Ported from the landing's api/stripe-webhook.mjs (which +// pushed to LeadPulse); here the fulfilment target is be-crm. +// +// Env: +// STRIPE_WEBHOOK_SECRET whsec_… (Stripe Dashboard -> Webhooks -> signing secret) +// CRM_BASE_URL be-crm base (default https://crm.lynkedup.cloud) +// FOUNDER_FULFILL_SECRET shared secret sent to be-crm's public fulfill endpoint +// Stripe endpoint URL: https:///api/stripe-webhook +// Events: checkout.session.completed (+ checkout.session.async_payment_succeeded) +// +// Route Handlers receive the exact raw bytes via request.arrayBuffer() (no body +// parser), which the signature check requires. Node runtime for node:crypto. + +export const runtime = "nodejs"; + +const TOLERANCE_SECONDS = 300; // Stripe's documented replay window +const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, ""); + +interface StripeSession { + id: string; + payment_status?: string; + customer?: string | null; + payment_intent?: string | null; + amount_total?: number; + currency?: string; + created?: number; + metadata?: Record; + customer_details?: { email?: string; address?: unknown } | null; +} + +function verifyStripeSignature(raw: Buffer, header: string | null, secret: string): { type: string; data: { object: StripeSession } } { + if (!header) throw new Error("Missing Stripe-Signature header"); + let timestamp: string | null = null; + const v1: string[] = []; + for (const part of header.split(",")) { + const i = part.indexOf("="); + if (i === -1) continue; + const key = part.slice(0, i).trim(); + const value = part.slice(i + 1).trim(); + if (key === "t") timestamp = value; + else if (key === "v1") v1.push(value); // only v1 β€” ignoring v0 prevents a downgrade attack + } + if (!timestamp || !/^\d+$/.test(timestamp) || !v1.length) throw new Error("Malformed Stripe-Signature header"); + + const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), raw]); + const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest(); + const matched = v1.some((sig) => { + if (!/^[a-f0-9]{64}$/i.test(sig)) return false; + const got = Buffer.from(sig, "hex"); + return got.length === expected.length && crypto.timingSafeEqual(got, expected); + }); + if (!matched) throw new Error("No matching v1 signature"); + + const age = Math.floor(Date.now() / 1000) - Number(timestamp); + if (Math.abs(age) > TOLERANCE_SECONDS) throw new Error(`Timestamp outside tolerance (age ${age}s)`); + + return JSON.parse(raw.toString("utf8")); +} + +async function fulfil(session: StripeSession): Promise { + // Card payments arrive already paid; delayed methods can complete a session while + // still unpaid. Only a paid session is a real purchase. + if (session.payment_status !== "paid") { + console.log("[webhook] session complete but not paid β€” ignoring", { id: session.id, status: session.payment_status }); + return; + } + const md = session.metadata || {}; + const email = (md.email || session.customer_details?.email || "").toLowerCase(); + if (!email) { + console.error("[webhook] paid session with no email β€” cannot attribute", { id: session.id }); + return; + } + + const secret = process.env.FOUNDER_FULFILL_SECRET; + if (!secret) { + // Throw so Stripe retries once the secret is configured (nothing is lost β€” the + // session metadata replays the whole purchase on retry). + throw new Error("FOUNDER_FULFILL_SECRET is not set"); + } + + const payload = { + email, + firstName: md.first_name || "", + lastName: md.last_name || "", + contactName: md.contact_name || "", + companyName: md.company_name || "", + phone: md.phone || "", + streetAddress: md.street_address || "", + city: md.city || "", + state: md.state || "", + zip: md.zip || "", + businessAddress: md.business_address || "", + salesReps: md.sales_reps || "", + licenses: Number(md.num_licenses) || 1, + plan: md.plan || "founders", + promoCode: md.promo_code || null, + smsConsent: md.sms_consent === "true", + smsConsentVersion: md.sms_consent_version || null, + smsConsentAt: md.sms_consent_at || null, + stripeSessionId: session.id, + stripeCustomerId: session.customer || null, + stripePaymentIntent: session.payment_intent || null, + amountTotalCents: session.amount_total ?? null, + currency: session.currency || null, + billingAddress: session.customer_details?.address ?? null, + purchasedAt: session.created ? new Date(session.created * 1000).toISOString() : new Date().toISOString(), + }; + + const res = await fetch(`${CRM_BASE_URL}/public/founder-access/fulfill`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Founder-Secret": secret }, + body: JSON.stringify(payload), + cache: "no-store", + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`be-crm fulfil failed: ${res.status} ${detail.slice(0, 200)}`); + } + console.log("[webhook] purchase fulfilled", { email, session: session.id }); +} + +export async function POST(request: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + console.error("[webhook] STRIPE_WEBHOOK_SECRET is not set"); + return new Response("Webhook secret not configured", { status: 500 }); // 500 -> Stripe retries + } + + const raw = Buffer.from(await request.arrayBuffer()); + let event: { type: string; data: { object: StripeSession } }; + try { + event = verifyStripeSignature(raw, request.headers.get("stripe-signature"), secret); + } catch (err) { + console.error("[webhook] signature verification failed:", (err as Error).message); + return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 }); // 400 -> don't retry + } + + try { + if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") { + await fulfil(event.data.object); + } else { + console.log("[webhook] ignoring event type", event.type); + } + } catch (err) { + console.error("[webhook] handler failed", { type: event.type, error: String((err as Error)?.message) }); + return new Response("Handler error", { status: 500 }); // 500 -> Stripe retries (metadata replays the lead) + } + + return Response.json({ received: true }); +} diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index e50efc3..5374704 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -55,10 +55,43 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa const [phoneVerified, setPhoneVerified] = useState(false); // When arriving from a team invite, the email is fixed to the invited address. const [invitedEmail, setInvitedEmail] = useState(""); + // Paid founder-access token (from the ?ft= link / the /thanks redirect / welcome email). + const [founderToken, setFounderToken] = useState(""); + // Registration is founder-access-only right now: a paid founder (ft token) or a team + // invitee may register; open sign-ups are blocked. "used" = the founder link is spent. + const [gate, setGate] = useState<"loading" | "allowed" | "blocked" | "used">(onboard ? "allowed" : "loading"); useEffect(() => { if (onboard) return; - try { const e = sessionStorage.getItem("invite_email"); if (e) { setEmail(e); setInvitedEmail(e); } } catch { /* ignore */ } + const ft = new URLSearchParams(window.location.search).get("ft") ?? ""; + let inviteEmail = ""; + try { inviteEmail = sessionStorage.getItem("invite_email") ?? ""; } catch { /* ignore */ } + + if (ft) { + // Validate + prefill from the paid founder record before letting them register. + fetch(`/api/founder/lookup?token=${encodeURIComponent(ft)}`, { cache: "no-store" }) + .then((r) => r.json()) + .then((d: { ok?: boolean; status?: string; email?: string; firstName?: string; lastName?: string; phone?: string }) => { + if (d?.ok && d.status === "valid") { + setFounderToken(ft); + if (d.email) { setEmail(d.email); setInvitedEmail(d.email); } // reuse the email-lock UI + if (d.firstName) setFirst(d.firstName); + if (d.lastName) setLast(d.lastName); + if (d.phone) setPhone(String(d.phone).replace(/\D/g, "").slice(-10)); + setGate("allowed"); + } else if (d?.status === "used") { + setGate("used"); + } else { + setGate("blocked"); + } + }) + .catch(() => setGate("blocked")); + } else if (inviteEmail) { + setEmail(inviteEmail); setInvitedEmail(inviteEmail); + setGate("allowed"); // invited team member + } else { + setGate("blocked"); // open registration is disabled + } }, [onboard]); // Onboarding: prefill the verified email β€” from the session-storage hint the login @@ -132,10 +165,13 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa mailingAddress: mailing, mailingSameAsRegistered: mailingSame, consentTerms: termsOk, consentPrivacy: privacyOk, + // Paid-founder registration: claims the token + provisions owner in be-crm. + ...(founderToken ? { founderToken } : {}), }); } catch { - if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; } - // register mode: non-fatal β€” the auth account exists; the profile can be filled in later. + // For onboarding OR a paid founder this step matters (owner provisioning), so + // surface it. In plain register mode it's non-fatal β€” the auth account exists. + if (onboard || founderToken) { setSubmitErr("Couldn't finish setting up your account. Please try again."); return; } } // 3) If the user arrived from a team invitation link, redeem it now β€” this @@ -154,6 +190,22 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa router.push("/dashboard"); } + // Founder-access gate: only a paid founder or a team invitee may register right now. + if (gate === "loading") { + return

Checking your access…
; + } + if (gate === "used") { + return router.push("/portal/login")} />; + } + if (gate === "blocked") { + return { window.location.href = "/"; }} + secondaryCta="Sign in" onSecondary={() => router.push("/portal/login")} />; + } + return (
@@ -192,6 +244,28 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa ); } +/* ====================== FOUNDER-ACCESS GATE ====================== */ +function FounderNotice({ icon, title, body, cta, onCta, secondaryCta, onSecondary }: { + icon: string; title: string; body: string; cta: string; onCta: () => void; + secondaryCta?: string; onSecondary?: () => void; +}) { + return ( +
+
+ +
+

{title}

+

{body}

+ + {secondaryCta && onSecondary && ( +

+ Already a member? +

+ )} +
+ ); +} + /* ====================== STEP 0 β€” ACCOUNT ====================== */ function StepAccount(p: { sso: { provider: string; email: string } | null; setSso: (v: { provider: string; email: string } | null) => void; diff --git a/src/lib/founder/lead.ts b/src/lib/founder/lead.ts new file mode 100644 index 0000000..a218d4d --- /dev/null +++ b/src/lib/founder/lead.ts @@ -0,0 +1,123 @@ +// Shared founder-access lead shape + server-side validation. Ported from the +// landing's api/_lead.js: we collect the buyer's details on our own form and only +// then hand off to Stripe, so the payment method (incl. Apple Pay express) can +// never skip contact capture. The client form validates for UX; this validates +// for real. Keep in sync with public/square-checkout.js. + +export const SALES_REP_BUCKETS = ["1", "2-5", "6-10", "11-25", "26-50", "51+"] as const; + +// 1..SELF_SERVE_MAX -> self-serve Stripe checkout. >SELF_SERVE_MAX -> sales. +export const MAX_LICENSES = 10; +export const SELF_SERVE_MAX = 5; + +export interface Lead { + firstName: string; lastName: string; contactName: string; + companyName: string; salesReps: string; + streetAddress: string; city: string; state: string; zip: string; businessAddress: string; + phone: string; email: string; smsConsent: boolean; licenses: number; +} + +export type ValidateResult = + | { ok: true; lead: Lead } + | { ok: false; errors: Record }; + +const FIELD_LABELS: Record = { + firstName: "First name", lastName: "Last name", companyName: "Company name", + salesReps: "Number of sales reps", streetAddress: "Street address", city: "City", + state: "State", zip: "ZIP code", phone: "Phone number", email: "Email address", + smsConsent: "SMS consent", +}; + +function str(v: unknown): string { + return typeof v === "string" ? v.trim() : ""; +} +// Deliberately permissive β€” a marketing lead form, not an auth system. +function isEmail(v: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v); +} +function digitCount(v: string): number { + return (v.match(/\d/g) || []).length; +} + +/** Validate + normalize the payload posted from the checkout form. */ +export function validateLead(body: Record): ValidateResult { + const errors: Record = {}; + + const firstName = str(body.firstName); + const lastName = str(body.lastName); + const companyName = str(body.companyName); + const salesReps = str(body.salesReps); + const streetAddress = str(body.streetAddress); + const city = str(body.city); + const state = str(body.state); + const zip = str(body.zip); + const phone = str(body.phone); + const email = str(body.email).toLowerCase(); + const smsConsent = body.smsConsent === true || body.smsConsent === "true" || body.smsConsent === "on"; + + const contactName = `${firstName} ${lastName}`.trim(); + const businessAddress = [streetAddress, city, [state, zip].filter(Boolean).join(" ")] + .filter(Boolean) + .join(", "); + + // Absent -> 1 (older clients / the $1 demo). Present but out of range -> error, + // so the buyer sees it rather than being silently clamped to a different charge. + let licenses = 1; + const raw = body.licenses; + if (raw !== undefined && raw !== null && String(raw).trim() !== "") { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > MAX_LICENSES) { + errors.licenses = `Please choose between 1 and ${MAX_LICENSES} licenses.`; + } else { + licenses = n; + } + } + + if (firstName.length < 1) errors.firstName = "Please enter your first name."; + if (lastName.length < 1) errors.lastName = "Please enter your last name."; + if (companyName.length < 2) errors.companyName = "Please enter your company name."; + if (!SALES_REP_BUCKETS.includes(salesReps as (typeof SALES_REP_BUCKETS)[number])) { + errors.salesReps = "Please select how many sales reps you have."; + } + if (streetAddress.length < 3) errors.streetAddress = "Please enter your street address."; + if (city.length < 2) errors.city = "Please enter your city."; + if (state.length < 2) errors.state = "Please enter your state."; + if (!/^\d{5}(-\d{4})?$/.test(zip)) errors.zip = "Please enter a valid ZIP code."; + + const digits = digitCount(phone); + if (digits < 10 || digits > 15) errors.phone = "Please enter a valid phone number."; + if (!isEmail(email)) errors.email = "Please enter a valid email address."; + // SMS consent is required β€” the founder flow depends on being able to SMS members. + if (!smsConsent) errors.smsConsent = "Please agree to receive SMS updates to continue."; + + // Guard against absurd input reaching Stripe metadata (500-char cap) or the CRM. + for (const [key, label] of Object.entries(FIELD_LABELS)) { + const value = str(body[key]); + if (!errors[key] && value.length > 300) errors[key] = `${label} is too long.`; + } + + if (Object.keys(errors).length) return { ok: false, errors }; + + return { + ok: true, + lead: { + firstName, lastName, contactName, companyName, salesReps, + streetAddress, city, state, zip, businessAddress, + phone, email, smsConsent, licenses, + }, + }; +} + +/** Stripe metadata (strings only; <=50 keys, values <=500 chars). Stripe is the + * system of record for what was captured at purchase, so a failed fulfill can be + * replayed from the webhook. */ +export function leadToStripeMetadata(lead: Lead, extra?: Record): Record { + return { + first_name: lead.firstName, last_name: lead.lastName, contact_name: lead.contactName, + company_name: lead.companyName, sales_reps: lead.salesReps, + street_address: lead.streetAddress, city: lead.city, state: lead.state, zip: lead.zip, + business_address: lead.businessAddress, phone: lead.phone, email: lead.email, + sms_consent: lead.smsConsent ? "true" : "false", num_licenses: String(lead.licenses || 1), + ...(extra ?? {}), + }; +}