feat(uat-batch-23): supplemental-info — separate Generate link + Send by email
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) <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,22 @@ import { getPortEmailConfig } from '@/lib/services/port-config';
|
|||||||
* Generates a one-shot token + emails the client the public form URL.
|
* Generates a one-shot token + emails the client the public form URL.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('interests', 'edit', async (_req: NextRequest, ctx, params) => {
|
withPermission('interests', 'edit', async (req: NextRequest, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const interestId = params.id as string;
|
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({
|
const result = await issueToken({
|
||||||
interestId,
|
interestId,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
@@ -32,7 +45,7 @@ export const POST = withAuth(
|
|||||||
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
|
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
|
||||||
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
|
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
|
||||||
|
|
||||||
if (result.clientEmail) {
|
if (shouldSendEmail && result.clientEmail) {
|
||||||
const html = `
|
const html = `
|
||||||
<p>Hello ${escapeHtml(result.clientName)},</p>
|
<p>Hello ${escapeHtml(result.clientName)},</p>
|
||||||
<p>Before we draft your Expression of Interest, we need to confirm a few details.
|
<p>Before we draft your Expression of Interest, we need to confirm a few details.
|
||||||
@@ -45,7 +58,7 @@ export const POST = withAuth(
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="color:#64748b;font-size:12px">
|
<p style="color:#64748b;font-size:12px">
|
||||||
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.
|
If you didn't expect this email, please let us know.
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
@@ -63,7 +76,7 @@ export const POST = withAuth(
|
|||||||
data: {
|
data: {
|
||||||
link,
|
link,
|
||||||
expiresAt: result.expiresAt.toISOString(),
|
expiresAt: result.expiresAt.toISOString(),
|
||||||
emailSent: !!result.clientEmail,
|
emailSent: shouldSendEmail && !!result.clientEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -40,16 +40,19 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
|||||||
const [link, setLink] = useState<string | null>(null);
|
const [link, setLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (vars: { sendEmail: boolean }) =>
|
||||||
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
|
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: vars,
|
||||||
}),
|
}),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setLink(res.data.link);
|
setLink(res.data.link);
|
||||||
if (res.data.emailSent) {
|
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 {
|
} 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) =>
|
onError: (err) =>
|
||||||
@@ -76,11 +79,20 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => mutation.mutate()}
|
variant={link ? 'outline' : 'default'}
|
||||||
|
onClick={() => mutation.mutate({ sendEmail: false })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Generating…' : link ? 'Regenerate link' : 'Generate link'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => mutation.mutate({ sendEmail: true })}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
<Mail className="mr-1.5 size-3.5" aria-hidden />
|
<Mail className="mr-1.5 size-3.5" aria-hidden />
|
||||||
{mutation.isPending ? 'Generating…' : link ? 'Resend' : 'Request more info'}
|
{link ? 'Send by email' : 'Generate + email'}
|
||||||
</Button>
|
</Button>
|
||||||
{link ? (
|
{link ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user