feat(intake): CRM-owned website inquiry emails + in-app notifications

Flag-gated (website_intake_email_enabled, default OFF) sending of registrant confirmation + staff alert for inquiries captured at /api/public/website-inquiries, reusing the branded berth + residential templates and adding contact-form client-confirmation + sales-alert templates. In-app (bell) notifications fire on every fresh capture, independent of the flag. Recipients resolve from the existing inquiry_/residential_notification_recipients settings; fires only on a fresh (non-deduped) insert so retries never re-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:22:08 +02:00
parent f699533224
commit 990b566eff
9 changed files with 675 additions and 1 deletions

View File

@@ -22,6 +22,8 @@ export const TEMPLATE_KEYS = [
'inquiry_sales_notification',
'residential_inquiry_client_confirmation',
'residential_inquiry_sales_alert',
'contact_form_sales_alert',
'contact_form_client_confirmation',
// M-EM04: daily notification digest. The digest service previously
// resolved its subject via `'crm_invite' as any` because no entry
// existed; making it a first-class key removes the cast and lets
@@ -101,6 +103,20 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry - {{clientName}}',
},
contact_form_sales_alert: {
key: 'contact_form_sales_alert',
label: 'Contact form - sales alert',
description: 'Internal alert sent to the sales team when a website contact form is submitted.',
mergeTokens: ['portName', 'clientName', 'email'],
defaultSubject: 'New contact form submission - {{clientName}}',
},
contact_form_client_confirmation: {
key: 'contact_form_client_confirmation',
label: 'Contact form - client confirmation',
description: 'Auto-reply sent to a visitor after they submit the general website contact form.',
mergeTokens: ['portName', 'recipientName'],
defaultSubject: 'Thank you for contacting {{portName}}',
},
notification_digest: {
key: 'notification_digest',
label: 'Notification digest',

View File

@@ -0,0 +1,104 @@
import { Button, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
branding?: BrandingShell | null;
subject?: string | null;
}
export interface ContactFormSalesAlertData {
fullName: string;
email: string;
interestType?: string | null;
comments?: string | null;
crmDeepLink?: string;
portName?: string;
}
function SalesAlertBody({
portName,
data,
accent,
}: {
portName: string;
data: ContactFormSalesAlertData;
accent: string;
}) {
const labelCell = { color: '#666', width: '140px' } as const;
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
New contact form submission
</Text>
<table
role="presentation"
width="100%"
cellPadding={6}
cellSpacing={0}
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
>
<tbody>
<tr>
<td style={labelCell}>Name</td>
<td>{data.fullName}</td>
</tr>
<tr>
<td style={labelCell}>Email</td>
<td>{data.email}</td>
</tr>
{data.interestType ? (
<tr>
<td style={labelCell}>Interest</td>
<td>{data.interestType}</td>
</tr>
) : null}
{data.comments ? (
<tr>
<td style={labelCell}>Comments</td>
<td>{data.comments}</td>
</tr>
) : null}
</tbody>
</table>
{data.crmDeepLink ? (
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<Button
href={safeUrl(data.crmDeepLink)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '12px 28px',
borderRadius: '5px',
fontWeight: 'bold',
}}
>
Open in CRM
</Button>
</div>
) : null}
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
</>
);
}
export async function contactFormSalesAlert(
data: ContactFormSalesAlertData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New contact form submission - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,
});
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}

View File

@@ -0,0 +1,81 @@
import { Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
branding?: BrandingShell | null;
subject?: string | null;
}
export interface ContactFormClientConfirmationData {
firstName: string;
contactEmail: string;
portName?: string;
}
function ClientConfirmationBody({
portName,
firstName,
contactEmail,
accent,
}: {
portName: string;
firstName: string;
contactEmail: string;
accent: string;
}) {
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Thank you for getting in touch
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
Dear {firstName},
</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
Thank you for reaching out to {portName}. We have received your message and a member of our
team will be in touch with you shortly.
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
If anything else comes to mind in the meantime, please write to us at{' '}
<Link
href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }}
>
{contactEmail}
</Link>
.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}
export async function contactFormClientConfirmation(
data: ContactFormClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `Thank you for contacting ${portName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
<ClientConfirmationBody
portName={portName}
firstName={data.firstName}
contactEmail={data.contactEmail}
accent={accent}
/>,
{ pretty: false },
);
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
};
}