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. +

+
+ +
+ +