feat(founder): payment→register flow + founder-only registration gate

- 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=<token>;
  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.
This commit is contained in:
tanweer919
2026-07-18 00:23:55 +05:30
parent a459fefb7f
commit d82d71d0b2
8 changed files with 605 additions and 5 deletions
+39 -2
View File
@@ -81,7 +81,8 @@
<div class="step"><span class="n">3</span><span class="t"><b>Watch for launch updates.</b> As a Founding Member you'll hear first about new features and your rollout timeline.</span></div> <div class="step"><span class="n">3</span><span class="t"><b>Watch for launch updates.</b> As a Founding Member you'll hear first about new features and your rollout timeline.</span></div>
</div> </div>
<a class="cta" href="/">Close</a> <a class="cta" id="setupCta" href="/portal/register">Set up your account</a>
<p class="fine" id="setupStatus" style="margin-top:10px;">Preparing your account…</p>
<p class="fine"> <p class="fine">
Questions? Email <a href="mailto:support@lynkeduppro.com">support@lynkeduppro.com</a> and reference your order below. Questions? Email <a href="mailto:support@lynkeduppro.com">support@lynkeduppro.com</a> and reference your order below.
@@ -92,9 +93,10 @@
<script> <script>
(function () { (function () {
var q = new URLSearchParams(location.search); var q = new URLSearchParams(location.search);
var demo = q.get("demo") === "1";
// Demo mode ($0 no-card flow from page/19.html): reframe the page so nobody // Demo mode ($0 no-card flow from page/19.html): reframe the page so nobody
// thinks a real charge happened. // thinks a real charge happened.
if (q.get("demo") === "1") { if (demo) {
document.getElementById("badge").textContent = "🎬 Demo complete"; document.getElementById("badge").textContent = "🎬 Demo complete";
document.getElementById("headline").innerHTML = "That's the full flow — start to finish"; document.getElementById("headline").innerHTML = "That's the full flow — start to finish";
document.getElementById("lead").textContent = document.getElementById("lead").textContent =
@@ -104,6 +106,41 @@
// Show the Stripe session id (from success_url) so support can look up the order. // Show the Stripe session id (from success_url) so support can look up the order.
var id = q.get("session_id"); var id = q.get("session_id");
if (id) document.getElementById("ref").textContent = "Order reference: " + id; if (id) document.getElementById("ref").textContent = "Order reference: " + id;
var status = document.getElementById("setupStatus");
var cta = document.getElementById("setupCta");
if (demo || !id) {
// No real purchase to attribute — just offer the register page.
if (status) status.textContent = "";
return;
}
// Poll be-crm (via our proxy) for the founder token minted by the Stripe
// webhook, then redirect to the prefilled register page. The welcome email
// carries the same link, so this is a convenience — nothing is lost if the
// webhook is briefly delayed.
var tries = 0, MAX = 15; // ~30s at 2s intervals
function poll() {
tries++;
fetch("/api/founder/session?session_id=" + encodeURIComponent(id), { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.status === "ready" && d.token) {
var url = "/portal/register?ft=" + encodeURIComponent(d.token);
if (cta) cta.setAttribute("href", url);
if (status) status.textContent = "Redirecting you to finish setup…";
window.location.replace(url);
return;
}
if (tries < MAX) { setTimeout(poll, 2000); return; }
if (status) status.innerHTML = "We emailed you a secure link to finish setting up your account — check your inbox (and spam).";
})
.catch(function () {
if (tries < MAX) { setTimeout(poll, 2000); return; }
if (status) status.innerHTML = "We emailed you a secure link to finish setting up your account — check your inbox (and spam).";
});
}
poll();
})(); })();
</script> </script>
</body> </body>
+129
View File
@@ -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<string, number> = { 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<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} 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}`;
}
+27
View File
@@ -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 });
}
}
+27
View File
@@ -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 });
}
}
+28
View File
@@ -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<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} 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 });
}
+155
View File
@@ -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://<domain>/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<string, string>;
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<void> {
// 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 });
}
+77 -3
View File
@@ -55,10 +55,43 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
const [phoneVerified, setPhoneVerified] = useState(false); const [phoneVerified, setPhoneVerified] = useState(false);
// When arriving from a team invite, the email is fixed to the invited address. // When arriving from a team invite, the email is fixed to the invited address.
const [invitedEmail, setInvitedEmail] = useState(""); 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(() => { useEffect(() => {
if (onboard) return; 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]); }, [onboard]);
// Onboarding: prefill the verified email — from the session-storage hint the login // 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, mailingAddress: mailing,
mailingSameAsRegistered: mailingSame, mailingSameAsRegistered: mailingSame,
consentTerms: termsOk, consentPrivacy: privacyOk, consentTerms: termsOk, consentPrivacy: privacyOk,
// Paid-founder registration: claims the token + provisions owner in be-crm.
...(founderToken ? { founderToken } : {}),
}); });
} catch { } catch {
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; } // For onboarding OR a paid founder this step matters (owner provisioning), so
// register mode: non-fatal — the auth account exists; the profile can be filled in later. // 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 // 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"); router.push("/dashboard");
} }
// Founder-access gate: only a paid founder or a team invitee may register right now.
if (gate === "loading") {
return <div className="card anim-fade-up" style={{ textAlign: "center", padding: "44px 0", color: "var(--muted, #94a3b8)" }}>Checking your access</div>;
}
if (gate === "used") {
return <FounderNotice icon="check" title="This link has already been used"
body="Your Founders account is already set up. Please sign in to continue."
cta="Go to sign in" onCta={() => router.push("/portal/login")} />;
}
if (gate === "blocked") {
return <FounderNotice icon="lock" title="Founder access required"
body="Registration is currently open to Founding Members only. Get founder access to create your account, or sign in if you already have one."
cta="Get founder access" onCta={() => { window.location.href = "/"; }}
secondaryCta="Sign in" onSecondary={() => router.push("/portal/login")} />;
}
return ( return (
<div className="card anim-fade-up"> <div className="card anim-fade-up">
<Stepper current={step} labels={STEP_LABELS} /> <Stepper current={step} labels={STEP_LABELS} />
@@ -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 (
<div className="card anim-fade-up" style={{ textAlign: "center" }}>
<div style={{ display: "flex", justifyContent: "center", marginBottom: 14 }}>
<span className="addr-tile addr-tile-amber" style={{ width: 52, height: 52 }}><Icon name={icon} size={22} /></span>
</div>
<h1>{title}</h1>
<p className="sub" style={{ marginTop: 8 }}>{body}</p>
<button className="btn btn-primary" style={{ marginTop: 20 }} onClick={onCta}>{cta} <Icon name="arrowR" size={16} /></button>
{secondaryCta && onSecondary && (
<p className="foot-note" style={{ marginTop: 14 }}>
Already a member? <button className="link" onClick={onSecondary}>{secondaryCta}</button>
</p>
)}
</div>
);
}
/* ====================== STEP 0 — ACCOUNT ====================== */ /* ====================== STEP 0 — ACCOUNT ====================== */
function StepAccount(p: { function StepAccount(p: {
sso: { provider: string; email: string } | null; setSso: (v: { provider: string; email: string } | null) => void; sso: { provider: string; email: string } | null; setSso: (v: { provider: string; email: string } | null) => void;
+123
View File
@@ -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<string, string> };
const FIELD_LABELS: Record<string, string> = {
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<string, unknown>): ValidateResult {
const errors: Record<string, string> = {};
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<string, string>): Record<string, string> {
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 ?? {}),
};
}