From 0fe3e984d1496a7bb6f299aaae80500bd639af66 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 03:36:56 +0200 Subject: [PATCH] feat(supplemental-info): pre-EOI public form flow Lets a sales rep send a client a one-shot link to fill out the information we need before drafting the EOI (intent, dimensions, signatory, timeline). Token-keyed: single-use, soft-expiring, scoped to one interest + client. Public POST endpoint accepts the form submission; CRM endpoint mints tokens for rep-initiated requests; portal page renders the form for the recipient. Schema: supplemental_form_tokens table (migration 0061) with port_id + interest_id + client_id refs, unique token, consumed_at marker. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../public/supplemental-info/[token]/page.tsx | 307 ++++++++++++++++ .../public/supplemental-info/[token]/route.ts | 68 ++++ .../[id]/supplemental-info-request/route.ts | 74 ++++ .../0061_supplemental_form_tokens.sql | 24 ++ src/lib/db/schema/index.ts | 3 + src/lib/db/schema/supplemental-forms.ts | 55 +++ .../services/supplemental-forms.service.ts | 327 ++++++++++++++++++ 7 files changed, 858 insertions(+) create mode 100644 src/app/(portal)/public/supplemental-info/[token]/page.tsx create mode 100644 src/app/api/public/supplemental-info/[token]/route.ts create mode 100644 src/app/api/v1/interests/[id]/supplemental-info-request/route.ts create mode 100644 src/lib/db/migrations/0061_supplemental_form_tokens.sql create mode 100644 src/lib/db/schema/supplemental-forms.ts create mode 100644 src/lib/services/supplemental-forms.service.ts diff --git a/src/app/(portal)/public/supplemental-info/[token]/page.tsx b/src/app/(portal)/public/supplemental-info/[token]/page.tsx new file mode 100644 index 00000000..3dc19f6d --- /dev/null +++ b/src/app/(portal)/public/supplemental-info/[token]/page.tsx @@ -0,0 +1,307 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { CheckCircle2, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { CountryCombobox } from '@/components/shared/country-combobox'; +import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; +import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; +import type { CountryCode } from '@/lib/i18n/countries'; + +interface PrefillData { + token: { expiresAt: string; consumed: boolean }; + client: { + fullName: string; + streetAddress: string | null; + city: string | null; + postalCode: string | null; + country: string | null; + primaryEmail: string | null; + primaryPhone: string | null; + primaryPhoneCountry: string | null; + }; + yacht: { + name: string | null; + lengthFt: string | null; + widthFt: string | null; + draftFt: string | null; + } | null; +} + +interface PageProps { + params: Promise<{ token: string }>; +} + +export default function SupplementalInfoPage({ params }: PageProps) { + const { token } = use(params); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + // Form fields + const [fullName, setFullName] = useState(''); + const [address, setAddress] = useState(''); + const [country, setCountry] = useState(null); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(null); + const [yachtName, setYachtName] = useState(''); + const [yachtLength, setYachtLength] = useState(''); + const [yachtWidth, setYachtWidth] = useState(''); + const [yachtDraft, setYachtDraft] = useState(''); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const res = await fetch(`/api/public/supplemental-info/${token}`); + if (!res.ok) { + setError( + res.status === 404 + ? 'This link is no longer valid. It may have expired or already been used.' + : 'Could not load this form. Please try again later.', + ); + return; + } + const payload = (await res.json()) as { data: PrefillData }; + if (cancelled) return; + setData(payload.data); + setFullName(payload.data.client.fullName ?? ''); + setAddress(payload.data.client.streetAddress ?? ''); + setCountry((payload.data.client.country as CountryCode | null) ?? null); + setEmail(payload.data.client.primaryEmail ?? ''); + if (payload.data.client.primaryPhone) { + setPhone({ + e164: payload.data.client.primaryPhone, + country: (payload.data.client.primaryPhoneCountry as CountryCode | null) ?? 'US', + }); + } + if (payload.data.yacht) { + setYachtName(payload.data.yacht.name ?? ''); + setYachtLength(payload.data.yacht.lengthFt ?? ''); + setYachtWidth(payload.data.yacht.widthFt ?? ''); + setYachtDraft(payload.data.yacht.draftFt ?? ''); + } + } catch { + setError('Could not load this form. Please check your connection and try again.'); + } finally { + if (!cancelled) setLoading(false); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [token]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!fullName.trim()) { + toast.error('Please enter your full name.'); + return; + } + setSubmitting(true); + try { + const res = await fetch(`/api/public/supplemental-info/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fullName: fullName.trim(), + address: address.trim() || null, + country: country ?? null, + email: email.trim() || null, + phoneE164: phone?.e164 ?? null, + phoneCountry: phone?.country ?? null, + yachtName: yachtName.trim() || null, + yachtLengthFt: parseFloat(yachtLength) || null, + yachtWidthFt: parseFloat(yachtWidth) || null, + yachtDraftFt: parseFloat(yachtDraft) || null, + }), + }); + if (!res.ok) { + const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + toast.error(payload.error?.message ?? 'Failed to submit. Please try again.'); + return; + } + setSubmitted(true); + } catch { + toast.error('Failed to submit. Please check your connection.'); + } finally { + setSubmitting(false); + } + } + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + +
+

{error}

+
+
+ ); + } + + if (data?.token.consumed) { + return ( + +
+ +

Thanks — we already have your details

+

+ This form was already submitted. Your sales contact will be in touch shortly. +

+
+
+ ); + } + + if (submitted) { + return ( + +
+ +

Thanks — got it

+

+ Your details have been sent to the team. Watch your inbox for your EOI document shortly. +

+
+
+ ); + } + + return ( + +
+
+

A few details before we draft your EOI

+

+ We've pre-filled what we have on file. Please review, correct anything that's + wrong, and add what's missing. +

+
+ +
+ Your details +
+ + setFullName(e.target.value)} + required + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + /> +
+ +
+ + +

+ Use a different number than the one you signed up with if you'd prefer to be + reached there instead. +

+
+ +
+ +