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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:36:56 +02:00
parent e11529ffcc
commit 0fe3e984d1
7 changed files with 858 additions and 0 deletions

View File

@@ -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<string | null>(null);
const [data, setData] = useState<PrefillData | null>(null);
// Form fields
const [fullName, setFullName] = useState('');
const [address, setAddress] = useState('');
const [country, setCountry] = useState<CountryCode | null>(null);
const [email, setEmail] = useState('');
const [phone, setPhone] = useState<PhoneInputValue | null>(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 (
<BrandedAuthShell>
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</BrandedAuthShell>
);
}
if (error) {
return (
<BrandedAuthShell>
<div className="text-center space-y-2 py-6">
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</BrandedAuthShell>
);
}
if (data?.token.consumed) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3 py-6">
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks we already have your details</h1>
<p className="text-sm text-muted-foreground">
This form was already submitted. Your sales contact will be in touch shortly.
</p>
</div>
</BrandedAuthShell>
);
}
if (submitted) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3 py-6">
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks got it</h1>
<p className="text-sm text-muted-foreground">
Your details have been sent to the team. Watch your inbox for your EOI document shortly.
</p>
</div>
</BrandedAuthShell>
);
}
return (
<BrandedAuthShell>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1 text-center">
<h1 className="text-xl font-semibold">A few details before we draft your EOI</h1>
<p className="text-sm text-muted-foreground">
We&apos;ve pre-filled what we have on file. Please review, correct anything that&apos;s
wrong, and add what&apos;s missing.
</p>
</div>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Your details</legend>
<div className="space-y-1.5">
<Label htmlFor="fullName">Full name</Label>
<Input
id="fullName"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Phone</Label>
<PhoneInput id="phone" value={phone} onChange={setPhone} placeholder="Phone number" />
<p className="text-[11px] text-muted-foreground">
Use a different number than the one you signed up with if you&apos;d prefer to be
reached there instead.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="address">Address</Label>
<Textarea
id="address"
rows={2}
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Street, city, postal code"
/>
</div>
<div className="space-y-1.5">
<Label>Country</Label>
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
</div>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Your yacht (optional)</legend>
<div className="space-y-1.5">
<Label htmlFor="yachtName">Yacht name</Label>
<Input
id="yachtName"
value={yachtName}
onChange={(e) => setYachtName(e.target.value)}
placeholder="Name on the hull"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="length">Length (ft)</Label>
<Input
id="length"
type="number"
step="0.1"
value={yachtLength}
onChange={(e) => setYachtLength(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="width">Width (ft)</Label>
<Input
id="width"
type="number"
step="0.1"
value={yachtWidth}
onChange={(e) => setYachtWidth(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="draft">Draft (ft)</Label>
<Input
id="draft"
type="number"
step="0.1"
value={yachtDraft}
onChange={(e) => setYachtDraft(e.target.value)}
placeholder="0"
/>
</div>
</div>
</fieldset>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
<p className="text-center text-[11px] text-muted-foreground">
This link is private to you and expires after one use.
</p>
</form>
</BrandedAuthShell>
);
}