From a4e30ea16ce642736a73dcb5250de224b091fd98 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 18:55:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch-23):=20supplemental-info=20?= =?UTF-8?q?=E2=80=94=20separate=20Generate=20link=20+=20Send=20by=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-button "Request more info" conflated link generation with email send. Once tokens became reusable until expiry (PR15), the two-step UX makes more sense — reps often need to copy the link and share it via WhatsApp / iMessage instead of letting SMTP route it. - API: POST /supplemental-info-request now accepts an optional `{ sendEmail?: boolean }` body (defaults true for back-compat). Generate-only callers pass `{ sendEmail: false }`. - UI: two buttons replace the single CTA — "Generate link" (always generates, never emails) + "Send by email" (the original full-blow behaviour). Re-clicking "Generate link" with a token already issued mints a fresh one (labeled "Regenerate link"). - Email body copy: drop "can only be used once" since PR15 made the link reusable until expiry. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/supplemental-info-request/route.ts | 21 ++++++++++++++---- .../supplemental-info-request-button.tsx | 22 ++++++++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts index 3825fa00..f5959670 100644 --- a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts +++ b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts @@ -14,9 +14,22 @@ import { getPortEmailConfig } from '@/lib/services/port-config'; * Generates a one-shot token + emails the client the public form URL. */ export const POST = withAuth( - withPermission('interests', 'edit', async (_req: NextRequest, ctx, params) => { + withPermission('interests', 'edit', async (req: NextRequest, ctx, params) => { try { const interestId = params.id as string; + // Two-step UX: rep can generate a link without firing the email + // (so they can copy + share manually through WhatsApp etc.), then + // come back and send the templated email separately. Default stays + // `true` for back-compat. The body is optional — older callers + // POST with no body and still trigger the email. + let shouldSendEmail = true; + try { + const body = (await req.clone().json()) as { sendEmail?: boolean }; + if (typeof body?.sendEmail === 'boolean') shouldSendEmail = body.sendEmail; + } catch { + // No JSON body — keep the default. + } + const result = await issueToken({ interestId, portId: ctx.portId, @@ -32,7 +45,7 @@ export const POST = withAuth( ? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}` : `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`; - if (result.clientEmail) { + if (shouldSendEmail && result.clientEmail) { const html = `

Hello ${escapeHtml(result.clientName)},

Before we draft your Expression of Interest, we need to confirm a few details. @@ -45,7 +58,7 @@ export const POST = withAuth(

- This link expires on ${result.expiresAt.toUTCString()} and can only be used once. + This link expires on ${result.expiresAt.toUTCString()}. If you didn't expect this email, please let us know.

`; @@ -63,7 +76,7 @@ export const POST = withAuth( data: { link, expiresAt: result.expiresAt.toISOString(), - emailSent: !!result.clientEmail, + emailSent: shouldSendEmail && !!result.clientEmail, }, }); } catch (error) { diff --git a/src/components/interests/supplemental-info-request-button.tsx b/src/components/interests/supplemental-info-request-button.tsx index d6a0aeee..2eb98d50 100644 --- a/src/components/interests/supplemental-info-request-button.tsx +++ b/src/components/interests/supplemental-info-request-button.tsx @@ -40,16 +40,19 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) const [link, setLink] = useState(null); const mutation = useMutation({ - mutationFn: () => + mutationFn: (vars: { sendEmail: boolean }) => apiFetch(`/api/v1/interests/${interestId}/supplemental-info-request`, { method: 'POST', + body: vars, }), onSuccess: (res) => { setLink(res.data.link); if (res.data.emailSent) { - toast.success('Email sent — link also shown below for sharing manually.'); + toast.success('Email sent. Link also shown below for sharing manually.'); } else { - toast.message('Link generated — no client email on file, share manually.'); + toast.message( + 'Link generated. Click "Send by email" to mail it, or copy it to share manually.', + ); } }, onError: (err) => @@ -76,11 +79,20 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) + {link ? ( <>