forked from Goutam/lynkeduppro-crm
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:
+39
-2
@@ -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>
|
||||
|
||||
<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">
|
||||
Questions? Email <a href="mailto:support@lynkeduppro.com">support@lynkeduppro.com</a> and reference your order below.
|
||||
@@ -92,9 +93,10 @@
|
||||
<script>
|
||||
(function () {
|
||||
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
|
||||
// thinks a real charge happened.
|
||||
if (q.get("demo") === "1") {
|
||||
if (demo) {
|
||||
document.getElementById("badge").textContent = "🎬 Demo complete";
|
||||
document.getElementById("headline").innerHTML = "That's the full flow — start to finish";
|
||||
document.getElementById("lead").textContent =
|
||||
@@ -104,6 +106,41 @@
|
||||
// Show the Stripe session id (from success_url) so support can look up the order.
|
||||
var id = q.get("session_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>
|
||||
</body>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 <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 (
|
||||
<div className="card anim-fade-up">
|
||||
<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 ====================== */
|
||||
function StepAccount(p: {
|
||||
sso: { provider: string; email: string } | null; setSso: (v: { provider: string; email: string } | null) => void;
|
||||
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user