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:
@@ -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',
|
||||
|
||||
104
src/lib/email/templates/contact-form-alert.tsx
Normal file
104
src/lib/email/templates/contact-form-alert.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
81
src/lib/email/templates/contact-form-client-confirmation.tsx
Normal file
81
src/lib/email/templates/contact-form-client-confirmation.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user