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:
307
src/app/(portal)/public/supplemental-info/[token]/page.tsx
Normal file
307
src/app/(portal)/public/supplemental-info/[token]/page.tsx
Normal 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've pre-filled what we have on file. Please review, correct anything that's
|
||||
wrong, and add what'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'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>
|
||||
);
|
||||
}
|
||||
68
src/app/api/public/supplemental-info/[token]/route.ts
Normal file
68
src/app/api/public/supplemental-info/[token]/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Public — no auth. Loads the prefill data for the form. The token in
|
||||
* the URL is the only credential; rejects expired / unknown tokens with
|
||||
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
ctx: { params: Promise<{ token: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { token } = await ctx.params;
|
||||
const data = await loadByToken(token);
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Link not found or expired' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
const submissionSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
address: z.string().max(500).nullable().optional(),
|
||||
country: z.string().length(2).nullable().optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
phoneE164: z
|
||||
.string()
|
||||
.regex(/^\+[1-9]\d{1,14}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
phoneCountry: z.string().length(2).nullable().optional(),
|
||||
yachtName: z.string().max(200).nullable().optional(),
|
||||
yachtLengthFt: z.number().positive().nullable().optional(),
|
||||
yachtWidthFt: z.number().positive().nullable().optional(),
|
||||
yachtDraftFt: z.number().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ token: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { token } = await ctx.params;
|
||||
const body = submissionSchema.parse(await req.json());
|
||||
await applySubmission(token, {
|
||||
fullName: body.fullName,
|
||||
address: body.address ?? null,
|
||||
country: body.country ?? null,
|
||||
email: body.email ?? null,
|
||||
phoneE164: body.phoneE164 ?? null,
|
||||
phoneCountry: body.phoneCountry ?? null,
|
||||
yachtName: body.yachtName ?? null,
|
||||
yachtLengthFt: body.yachtLengthFt ?? null,
|
||||
yachtWidthFt: body.yachtWidthFt ?? null,
|
||||
yachtDraftFt: body.yachtDraftFt ?? null,
|
||||
});
|
||||
return NextResponse.json({ data: { success: true } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { issueToken } from '@/lib/services/supplemental-forms.service';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/**
|
||||
* POST /api/v1/interests/[id]/supplemental-info-request
|
||||
*
|
||||
* Auth: requires `interests.edit` so any rep working the deal can fire it.
|
||||
* Generates a one-shot token + emails the client the public form URL.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('interests', 'edit', async (_req: NextRequest, ctx, params) => {
|
||||
try {
|
||||
const interestId = params.id as string;
|
||||
const result = await issueToken({
|
||||
interestId,
|
||||
portId: ctx.portId,
|
||||
issuedBy: ctx.userId,
|
||||
});
|
||||
|
||||
const link = `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
|
||||
|
||||
if (result.clientEmail) {
|
||||
const html = `
|
||||
<p>Hello ${escapeHtml(result.clientName)},</p>
|
||||
<p>Before we draft your Expression of Interest, we need to confirm a few details.
|
||||
The form below is pre-filled with what we have on file — please review, correct
|
||||
anything that's wrong, and add what's missing.</p>
|
||||
<p style="text-align:center;margin:24px 0">
|
||||
<a href="${link}"
|
||||
style="background:#1e3a8a;color:#fff;text-decoration:none;padding:12px 24px;border-radius:6px;display:inline-block">
|
||||
Open the form
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:12px">
|
||||
This link expires on ${result.expiresAt.toUTCString()} and can only be used once.
|
||||
If you didn't expect this email, please let us know.
|
||||
</p>
|
||||
`;
|
||||
await sendEmail(
|
||||
result.clientEmail,
|
||||
'Please complete a few details before we draft your EOI',
|
||||
html,
|
||||
undefined,
|
||||
undefined,
|
||||
ctx.portId,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
link,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
emailSent: !!result.clientEmail,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user