feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
Website register-interest form now offers the 3 residence types as a multi-select; the choice + preferred-contact flow into the CRM inquiry payload, the inbox detail, and the residential emails. - inquiry inbox detail surfaces residence type(s), preferred contact, type-of-interest, comments (full data capture) - residential-inquiry emails: client confirmation names the chosen villa(s); sales alert converted to the canonical detail-line format (uniform with berth/contact) + residence type(s)/preferred contact + plain-text part - website-intake-fields parses residence_types[] + method_of_contact - contact_form alerts split to their own recipient key (contact_notification_recipients) - Residential Interests section: new residence_type field (schema + migration 0099, validators, inline select on the detail) - contact-form-alert email refactor shipped (interest-alert style) Tests: website-intake-fields, residential-inquiry templates, contact-form-alert, residential-interest validators. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
This commit is contained in:
@@ -149,9 +149,17 @@ const KNOWN_SETTINGS: Array<{
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
label: 'Berth & contact inquiry alerts',
|
||||
label: 'Berth inquiry alerts',
|
||||
description:
|
||||
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||
'Who receives staff alerts for new berth inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'contact_notification_recipients',
|
||||
label: 'Contact-form alerts',
|
||||
description:
|
||||
'Who receives staff alerts for new website contact-form submissions: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Falls back to Inquiry Contact Email when empty.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
|
||||
@@ -77,10 +77,29 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
|
||||
const p = (data?.payload ?? {}) as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
|
||||
// Read a payload value that may be a string[] (e.g. residence_types, the
|
||||
// contact form's interest[]) OR a lone string, and present it comma-joined.
|
||||
const list = (k: string): string => {
|
||||
const v = p[k];
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string').join(', ');
|
||||
return typeof v === 'string' ? v : '';
|
||||
};
|
||||
// The free-text message a lead left. Website forms use different keys
|
||||
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
|
||||
// common ones and surface it for every inquiry kind.
|
||||
const comment = str('comments') || str('message') || str('comment') || str('notes');
|
||||
// Preferred method of contact (register form: 'email' | 'phone'). Surfaced so
|
||||
// reps honour the lead's stated contact request.
|
||||
const preferredContactRaw = str('method_of_contact').toLowerCase();
|
||||
const preferredContact =
|
||||
preferredContactRaw === 'email'
|
||||
? 'Email'
|
||||
: preferredContactRaw === 'phone'
|
||||
? 'Phone call back'
|
||||
: '';
|
||||
const residenceTypes = list('residence_types');
|
||||
// Contact-form "type of interest" (owner/broker/investor/…), stored as an array.
|
||||
const contactInterest = list('interest');
|
||||
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
@@ -91,10 +110,17 @@ export function InquiryDetail({ id }: { id: string }) {
|
||||
<Row label="Name" value={data?.contactName} />
|
||||
<Row label="Email" value={data?.contactEmail} />
|
||||
<Row label="Phone" value={str('phone')} />
|
||||
{data?.kind === 'residence_inquiry' ? (
|
||||
<Row label="Residence type(s)" value={residenceTypes} />
|
||||
) : null}
|
||||
{data?.kind === 'residence_inquiry' ? (
|
||||
<Row label="Place of residence" value={str('address')} />
|
||||
) : null}
|
||||
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
|
||||
{data?.kind === 'contact_form' && contactInterest ? (
|
||||
<Row label="Type of interest" value={contactInterest} />
|
||||
) : null}
|
||||
{preferredContact ? <Row label="Preferred contact" value={preferredContact} /> : null}
|
||||
{comment ? (
|
||||
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
|
||||
) : null}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ResidentialInterest {
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
residenceType: string | null;
|
||||
assignedTo: string | null;
|
||||
client: { id: string; fullName: string } | null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
import { RESIDENCE_TYPES } from '@/lib/validators/residential';
|
||||
|
||||
interface ResidentialInterest {
|
||||
id: string;
|
||||
@@ -17,6 +18,7 @@ interface ResidentialInterest {
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
preferences: string | null;
|
||||
residenceType: string | null;
|
||||
assignedTo: string | null;
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ interface Args {
|
||||
}
|
||||
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
const RESIDENCE_TYPE_OPTIONS = RESIDENCE_TYPES.map((t) => ({ value: t, label: t }));
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -151,6 +154,15 @@ function OverviewTab({
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Details</h3>
|
||||
<Row label="Residence type">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={RESIDENCE_TYPE_OPTIONS}
|
||||
value={interest.residenceType}
|
||||
onSave={save('residenceType')}
|
||||
placeholder="Not set"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Preferences">
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Residential interests: structured residence unit type the lead is pursuing
|
||||
-- (e.g. "Two Bedroom Marina Villa"). Mirrors the multi-select on the website's
|
||||
-- register-interest form. Nullable; additive — safe to apply online.
|
||||
ALTER TABLE residential_interests
|
||||
ADD COLUMN IF NOT EXISTS residence_type text;
|
||||
@@ -97,6 +97,13 @@ export const residentialInterests = pgTable(
|
||||
* heavily. Schema can grow into structured columns later if needed.
|
||||
*/
|
||||
preferences: text('preferences'),
|
||||
/**
|
||||
* Structured residence unit type the lead is pursuing (e.g. "Two Bedroom
|
||||
* Marina Villa"). Mirrors the multi-select on the website's register-interest
|
||||
* form; on a structured interest it captures the single unit type being
|
||||
* worked. Nullable - older rows + manual entries may leave it unset.
|
||||
*/
|
||||
residenceType: text('residence_type'),
|
||||
/**
|
||||
* better-auth user id of the residential team member working this lead.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Text, render } from '@react-email/components';
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
@@ -17,6 +17,9 @@ export interface ContactFormSalesAlertData {
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
// Mirrors the interest-registration alert (inquiry-sales-notification.tsx):
|
||||
// friendly intro, `**Label:** value` detail lines, inline CRM follow-up link,
|
||||
// and a plain-text part — so contact-form alerts read identically to interest ones.
|
||||
function SalesAlertBody({
|
||||
portName,
|
||||
data,
|
||||
@@ -26,61 +29,42 @@ function SalesAlertBody({
|
||||
data: ContactFormSalesAlertData;
|
||||
accent: string;
|
||||
}) {
|
||||
const labelCell = { color: '#666', width: '140px' } as const;
|
||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||
const comments = data.comments?.trim() ? data.comments : '(none provided)';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
New contact form submission
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
A new contact-form enquiry has come in for <strong>{portName}</strong>. {data.fullName} got
|
||||
in touch via the website contact page - full details below:
|
||||
</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>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {data.fullName}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Email:</strong> {data.email}
|
||||
</Text>
|
||||
{data.interestType ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Interest:</strong> {data.interestType}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
|
||||
<Text style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
|
||||
<strong>Comments:</strong> {comments}
|
||||
</Text>
|
||||
{data.crmDeepLink ? (
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Open the{' '}
|
||||
<Link
|
||||
href={safeUrl(data.crmDeepLink)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
>
|
||||
{portName} CRM
|
||||
</Link>{' '}
|
||||
to follow up.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +73,7 @@ export async function contactFormSalesAlert(
|
||||
data: ContactFormSalesAlertData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'our team';
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New contact form submission - ${data.fullName}`;
|
||||
@@ -97,8 +81,27 @@ export async function contactFormSalesAlert(
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
});
|
||||
|
||||
const comments = data.comments?.trim() ? data.comments : '(none provided)';
|
||||
const text = [
|
||||
'Hello,',
|
||||
'',
|
||||
`A new contact-form enquiry has come in for ${portName}. ${data.fullName} got in touch via the website contact page - full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
...(data.interestType ? [`Interest: ${data.interestType}`] : []),
|
||||
`Comments: ${comments}`,
|
||||
'',
|
||||
...(data.crmDeepLink
|
||||
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
|
||||
: []),
|
||||
`- ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Link, Text, render } from '@react-email/components';
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
@@ -11,18 +11,35 @@ interface RenderOpts {
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
residenceTypes?: string[];
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable list of the residence types a lead selected, e.g.
|
||||
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
|
||||
* Falls back to a generic phrase when nothing was selected so the copy
|
||||
* always reads naturally.
|
||||
*/
|
||||
function residencePhrase(portName: string, types: string[] | undefined): string {
|
||||
const list = (types ?? []).filter(Boolean);
|
||||
if (list.length === 0) return `the residences at ${portName}`;
|
||||
if (list.length === 1) return `the ${list[0]}`;
|
||||
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`;
|
||||
return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
|
||||
}
|
||||
|
||||
function ClientConfirmationBody({
|
||||
portName,
|
||||
firstName,
|
||||
contactEmail,
|
||||
residencePhraseText,
|
||||
accent,
|
||||
}: {
|
||||
portName: string;
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
residencePhraseText: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -34,7 +51,7 @@ function ClientConfirmationBody({
|
||||
Dear {firstName},
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Thank you for your interest in the residences at {portName}. Our residential sales team has
|
||||
Thank you for your interest in {residencePhraseText}. Our residential sales team has
|
||||
received your enquiry, and a member of the team will be in touch shortly with the details
|
||||
you've requested.
|
||||
</Text>
|
||||
@@ -66,18 +83,31 @@ export async function residentialClientConfirmation(
|
||||
? overrides.subject
|
||||
: `Thank you for your interest in ${portName} Residences`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
|
||||
const body = await render(
|
||||
<ClientConfirmationBody
|
||||
portName={portName}
|
||||
firstName={data.firstName}
|
||||
contactEmail={data.contactEmail}
|
||||
residencePhraseText={residencePhraseText}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
const text = [
|
||||
`Dear ${data.firstName},`,
|
||||
'',
|
||||
`Thank you for your interest in ${residencePhraseText}. Our residential sales team has received your enquiry, and a member of the team will be in touch shortly with the details you've requested.`,
|
||||
'',
|
||||
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`,
|
||||
'',
|
||||
'With warm regards,',
|
||||
`The ${portName} Residential Team`,
|
||||
].join('\n');
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +115,7 @@ export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
residenceTypes?: string[];
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
@@ -93,6 +124,12 @@ export interface ResidentialSalesAlertData {
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
function formatPreferredContact(method: 'email' | 'phone' | undefined): string | undefined {
|
||||
if (method === 'email') return 'Email';
|
||||
if (method === 'phone') return 'Phone call back';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function SalesAlertBody({
|
||||
portName,
|
||||
data,
|
||||
@@ -102,77 +139,65 @@ function SalesAlertBody({
|
||||
data: ResidentialSalesAlertData;
|
||||
accent: string;
|
||||
}) {
|
||||
const labelCell = { color: '#666', width: '140px' } as const;
|
||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
|
||||
const preferredContact = formatPreferredContact(data.preferredContactMethod);
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
New residential inquiry
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
A new residential enquiry has come in for <strong>{portName}</strong>. {data.fullName} has
|
||||
asked us to be in touch - full details below:
|
||||
</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>
|
||||
<tr>
|
||||
<td style={labelCell}>Phone</td>
|
||||
<td>{data.phone}</td>
|
||||
</tr>
|
||||
{data.placeOfResidence ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Residence</td>
|
||||
<td>{data.placeOfResidence}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.preferredContactMethod ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Prefers</td>
|
||||
<td>{data.preferredContactMethod}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.preferences ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Preferences</td>
|
||||
<td>{data.preferences}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.notes ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Notes</td>
|
||||
<td>{data.notes}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {data.fullName}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Email:</strong> {data.email}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Telephone:</strong> {data.phone}
|
||||
</Text>
|
||||
{residenceTypes.length > 0 ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Residence type(s):</strong> {residenceTypes.join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
{preferredContact ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Preferred contact:</strong> {preferredContact}
|
||||
</Text>
|
||||
) : null}
|
||||
{data.placeOfResidence ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Place of residence:</strong> {data.placeOfResidence}
|
||||
</Text>
|
||||
) : null}
|
||||
{data.preferences ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Preferences:</strong> {data.preferences}
|
||||
</Text>
|
||||
) : null}
|
||||
{data.notes ? (
|
||||
<Text style={detailStyle}>
|
||||
<strong>Comments:</strong> {data.notes}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
{data.crmDeepLink ? (
|
||||
<div style={{ textAlign: 'center', margin: '24px 0' }}>
|
||||
<Button
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Open the{' '}
|
||||
<Link
|
||||
href={safeUrl(data.crmDeepLink)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '12px 28px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
>
|
||||
Open in CRM
|
||||
</Button>
|
||||
</div>
|
||||
{portName} CRM
|
||||
</Link>{' '}
|
||||
to follow up.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -189,8 +214,32 @@ export async function residentialSalesAlert(
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
});
|
||||
|
||||
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
|
||||
const preferredContact = formatPreferredContact(data.preferredContactMethod);
|
||||
const text = [
|
||||
'Hello,',
|
||||
'',
|
||||
`A new residential enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch - full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
`Telephone: ${data.phone}`,
|
||||
...(residenceTypes.length > 0 ? [`Residence type(s): ${residenceTypes.join(', ')}`] : []),
|
||||
...(preferredContact ? [`Preferred contact: ${preferredContact}`] : []),
|
||||
...(data.placeOfResidence ? [`Place of residence: ${data.placeOfResidence}`] : []),
|
||||
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
|
||||
...(data.notes ? [`Comments: ${data.notes}`] : []),
|
||||
'',
|
||||
...(data.crmDeepLink
|
||||
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
|
||||
: []),
|
||||
`- ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,7 +151,12 @@ export async function sendWebsiteSubmissionEmails(
|
||||
if (kind === 'residence_inquiry') {
|
||||
if (fields.email) {
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{ firstName: fields.firstName, contactEmail, portName },
|
||||
{
|
||||
firstName: fields.firstName,
|
||||
contactEmail,
|
||||
residenceTypes: fields.residenceTypes,
|
||||
portName,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
const subject = await resolveSubject({
|
||||
@@ -160,7 +165,14 @@ export async function sendWebsiteSubmissionEmails(
|
||||
fallback: confirmation.subject,
|
||||
tokens: { portName, recipientName: fields.firstName },
|
||||
});
|
||||
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
|
||||
await sendEmail(
|
||||
fields.email,
|
||||
subject,
|
||||
confirmation.html,
|
||||
undefined,
|
||||
confirmation.text,
|
||||
portId,
|
||||
);
|
||||
}
|
||||
|
||||
const recipients = await resolveRecipients(portId, 'residential_notification_recipients');
|
||||
@@ -170,6 +182,8 @@ export async function sendWebsiteSubmissionEmails(
|
||||
fullName: fields.fullName,
|
||||
email: fields.email,
|
||||
phone: fields.phone,
|
||||
residenceTypes: fields.residenceTypes,
|
||||
preferredContactMethod: fields.preferredContact ?? undefined,
|
||||
placeOfResidence: fields.placeOfResidence ?? undefined,
|
||||
notes: fields.comments ?? undefined,
|
||||
crmDeepLink: crmUrl,
|
||||
@@ -183,7 +197,7 @@ export async function sendWebsiteSubmissionEmails(
|
||||
fallback: alert.subject,
|
||||
tokens: { portName, clientName: fields.fullName, email: fields.email, phone: fields.phone },
|
||||
});
|
||||
await sendEmail(recipients, subject, alert.html, undefined, undefined, portId);
|
||||
await sendEmail(recipients, subject, alert.html, undefined, alert.text, portId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +220,11 @@ export async function sendWebsiteSubmissionEmails(
|
||||
await sendEmail(fields.email, subject, confirmation.html, undefined, undefined, portId);
|
||||
}
|
||||
|
||||
const recipients = await resolveRecipients(portId, 'inquiry_notification_recipients');
|
||||
// Contact-form alerts go to their own recipient list (the website routed
|
||||
// them to hello@ separately from berth alerts). Falls back to
|
||||
// inquiry_notification_recipients only via the shared resolver's
|
||||
// inquiry_contact_email fallback when unset.
|
||||
const recipients = await resolveRecipients(portId, 'contact_notification_recipients');
|
||||
if (recipients.length > 0) {
|
||||
const alert = await contactFormSalesAlert(
|
||||
{
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface InquiryFields {
|
||||
comments: string | null;
|
||||
/** The contact form's `interest` (string or string[]) joined for display. */
|
||||
interestType: string | null;
|
||||
/** The residence form's `residence_types` multi-select (villa types chosen). */
|
||||
residenceTypes: string[];
|
||||
/** The register form's `method_of_contact` preference: 'email' | 'phone'. */
|
||||
preferredContact: 'email' | 'phone' | null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
@@ -44,6 +48,20 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
|
||||
? rawInterest.filter((v): v is string => typeof v === 'string').join(', ') || null
|
||||
: str(rawInterest) || null;
|
||||
|
||||
// The residence form posts `residence_types` as an array of villa-type
|
||||
// strings. Defensively coerce a lone string to a single-item array so a
|
||||
// future single-select form variant still maps cleanly.
|
||||
const rawResidenceTypes = payload.residence_types;
|
||||
const residenceTypes = Array.isArray(rawResidenceTypes)
|
||||
? rawResidenceTypes.filter((v): v is string => typeof v === 'string' && v.trim() !== '')
|
||||
: str(rawResidenceTypes)
|
||||
? [str(rawResidenceTypes)]
|
||||
: [];
|
||||
|
||||
const methodOfContact = str(payload.method_of_contact).toLowerCase();
|
||||
const preferredContact =
|
||||
methodOfContact === 'email' ? 'email' : methodOfContact === 'phone' ? 'phone' : null;
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
@@ -54,5 +72,7 @@ export function extractInquiryFields(payload: Record<string, unknown>): InquiryF
|
||||
placeOfResidence,
|
||||
comments,
|
||||
interestType,
|
||||
residenceTypes,
|
||||
preferredContact,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -702,7 +702,7 @@ export const REGISTRY: SettingEntry[] = [
|
||||
section: 'operations.intake',
|
||||
label: 'CRM-owned website inquiry emails',
|
||||
description:
|
||||
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients / residential_notification_recipients (fallback inquiry_contact_email).',
|
||||
'When enabled, the CRM sends the registrant confirmation + staff alert for inquiries captured from the marketing website (/api/public/website-inquiries), reusing the branded inquiry templates and the per-port From address. Leave OFF until cutover so the website keeps sending its own emails and we never double-send. Recipients come from inquiry_notification_recipients (berth) / contact_notification_recipients (contact form) / residential_notification_recipients (residences), each falling back to inquiry_contact_email.',
|
||||
type: 'boolean',
|
||||
scope: 'port',
|
||||
defaultValue: false,
|
||||
|
||||
@@ -69,12 +69,28 @@ export const DEFAULT_RESIDENTIAL_PIPELINE_STAGES = [
|
||||
/** Backwards-compat alias kept for any existing imports. */
|
||||
export const PIPELINE_STAGES = DEFAULT_RESIDENTIAL_PIPELINE_STAGES;
|
||||
|
||||
/**
|
||||
* Residence unit types offered at Port Nimara. Single source of truth for the
|
||||
* residential interest's `residenceType` field + the residential UI select.
|
||||
* Mirrors (intentionally duplicated, separate repo) the website register form's
|
||||
* multi-select options.
|
||||
*/
|
||||
export const RESIDENCE_TYPES = [
|
||||
'Two Bedroom Marina Villa',
|
||||
'Four Bedroom Oceanfront Villa',
|
||||
'Five Bedroom Oceanfront Villa',
|
||||
] as const;
|
||||
|
||||
export const createResidentialInterestSchema = z.object({
|
||||
residentialClientId: z.string().min(1),
|
||||
pipelineStage: z.string().optional().default('new'),
|
||||
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
|
||||
notes: z.string().optional(),
|
||||
preferences: z.string().optional(),
|
||||
// Accept the known unit types or null/'' (cleared via the inline select).
|
||||
residenceType: z
|
||||
.preprocess((v) => (v === '' ? null : v), z.enum(RESIDENCE_TYPES).nullable())
|
||||
.optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
39
tests/unit/email/contact-form-alert.test.ts
Normal file
39
tests/unit/email/contact-form-alert.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
|
||||
|
||||
describe('contactFormSalesAlert', () => {
|
||||
it('renders a branded HTML alert with all submitted details + a follow-up link', async () => {
|
||||
const { subject, html, text } = await contactFormSalesAlert({
|
||||
fullName: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
interestType: 'Owner, Crew',
|
||||
comments: 'Interested in a berth for a 40m yacht.',
|
||||
crmDeepLink: 'https://crm.portnimara.com/inquiries/abc',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
|
||||
expect(subject).toContain('Jane Doe');
|
||||
// Interest-registration style: friendly intro + detail lines + CRM follow-up link.
|
||||
expect(html).toContain('A new contact-form enquiry has come in');
|
||||
expect(html).toContain('Jane Doe');
|
||||
expect(html).toContain('jane@example.com');
|
||||
expect(html).toContain('Owner, Crew');
|
||||
expect(html).toContain('Interested in a berth for a 40m yacht.');
|
||||
expect(html).toContain('to follow up');
|
||||
// Plain-text part mirrors the interest alert.
|
||||
expect(text).toContain('A new contact-form enquiry');
|
||||
expect(text).toContain('Comments: Interested in a berth for a 40m yacht.');
|
||||
});
|
||||
|
||||
it('falls back gracefully when interest + comments are absent', async () => {
|
||||
const { html, text } = await contactFormSalesAlert({
|
||||
fullName: 'Bob Smith',
|
||||
email: 'bob@example.com',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(html).toContain('(none provided)');
|
||||
expect(text).toContain('Comments: (none provided)');
|
||||
expect(html).not.toContain('Interest:');
|
||||
});
|
||||
});
|
||||
68
tests/unit/email/residential-inquiry.test.ts
Normal file
68
tests/unit/email/residential-inquiry.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
|
||||
describe('residentialClientConfirmation', () => {
|
||||
it('reflects the chosen residence types in the thank-you copy', async () => {
|
||||
const { html, text } = await residentialClientConfirmation({
|
||||
firstName: 'Mia',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(html).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
|
||||
expect(text).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
|
||||
expect(html).toContain('Mia');
|
||||
});
|
||||
|
||||
it('falls back to a generic phrase when no types are selected', async () => {
|
||||
const { html } = await residentialClientConfirmation({
|
||||
firstName: 'Sam',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(html).toContain('the residences at Port Nimara');
|
||||
});
|
||||
});
|
||||
|
||||
describe('residentialSalesAlert', () => {
|
||||
it('renders residence type(s) + preferred contact + comments in the detail-line format', async () => {
|
||||
const { html, text } = await residentialSalesAlert({
|
||||
fullName: 'Mia Ng',
|
||||
email: 'mia@example.com',
|
||||
phone: '+15551234',
|
||||
residenceTypes: ['Two Bedroom Marina Villa'],
|
||||
preferredContactMethod: 'phone',
|
||||
notes: 'Looking for a winter completion.',
|
||||
crmDeepLink: 'https://crm.portnimara.com/port-nimara',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
// Uniform with the berth/contact alerts: friendly intro + bold detail lines + CRM link.
|
||||
expect(html).toContain('A new residential enquiry has come in');
|
||||
expect(html).toContain('Residence type(s):');
|
||||
expect(html).toContain('Two Bedroom Marina Villa');
|
||||
expect(html).toContain('Preferred contact:');
|
||||
expect(html).toContain('Phone call back');
|
||||
expect(html).toContain('Looking for a winter completion.');
|
||||
expect(html).toContain('to follow up');
|
||||
// Plain-text part mirrors the other alerts.
|
||||
expect(text).toContain('Residence type(s): Two Bedroom Marina Villa');
|
||||
expect(text).toContain('Preferred contact: Phone call back');
|
||||
expect(text).toContain('Comments: Looking for a winter completion.');
|
||||
});
|
||||
|
||||
it('omits optional rows cleanly when absent', async () => {
|
||||
const { html } = await residentialSalesAlert({
|
||||
fullName: 'Bob Smith',
|
||||
email: 'bob@example.com',
|
||||
phone: '+1999',
|
||||
portName: 'Port Nimara',
|
||||
});
|
||||
expect(html).not.toContain('Residence type(s):');
|
||||
expect(html).not.toContain('Preferred contact:');
|
||||
expect(html).toContain('Bob Smith');
|
||||
});
|
||||
});
|
||||
44
tests/unit/validators/residential-interest.test.ts
Normal file
44
tests/unit/validators/residential-interest.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RESIDENCE_TYPES,
|
||||
createResidentialInterestSchema,
|
||||
updateResidentialInterestSchema,
|
||||
} from '@/lib/validators/residential';
|
||||
|
||||
describe('residential interest residenceType', () => {
|
||||
it('accepts a known residence type', () => {
|
||||
const parsed = createResidentialInterestSchema.parse({
|
||||
residentialClientId: 'rc_1',
|
||||
residenceType: 'Two Bedroom Marina Villa',
|
||||
});
|
||||
expect(parsed.residenceType).toBe('Two Bedroom Marina Villa');
|
||||
});
|
||||
|
||||
it('coerces empty string to null (inline-select clear)', () => {
|
||||
const parsed = updateResidentialInterestSchema.parse({ residenceType: '' });
|
||||
expect(parsed.residenceType).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts explicit null', () => {
|
||||
const parsed = updateResidentialInterestSchema.parse({ residenceType: null });
|
||||
expect(parsed.residenceType).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an unknown residence type', () => {
|
||||
expect(() =>
|
||||
createResidentialInterestSchema.parse({
|
||||
residentialClientId: 'rc_1',
|
||||
residenceType: 'Penthouse Suite',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('exposes the three offered unit types', () => {
|
||||
expect(RESIDENCE_TYPES).toEqual([
|
||||
'Two Bedroom Marina Villa',
|
||||
'Four Bedroom Oceanfront Villa',
|
||||
'Five Bedroom Oceanfront Villa',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,31 @@ describe('extractInquiryFields', () => {
|
||||
expect(f.fullName).toBe('Sam Lee');
|
||||
});
|
||||
|
||||
it('maps residence_types[] + method_of_contact from the register form', () => {
|
||||
const f = extractInquiryFields({
|
||||
first_name: 'Mia',
|
||||
last_name: 'Ng',
|
||||
email: 'mia@example.com',
|
||||
interest: 'residences',
|
||||
residence_types: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
|
||||
method_of_contact: 'phone',
|
||||
});
|
||||
expect(f.residenceTypes).toEqual(['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa']);
|
||||
expect(f.preferredContact).toBe('phone');
|
||||
});
|
||||
|
||||
it('coerces a lone residence_types string to a single-item array and filters blanks', () => {
|
||||
const f = extractInquiryFields({
|
||||
residence_types: ['Two Bedroom Marina Villa', '', 7 as unknown as string],
|
||||
method_of_contact: 'EMAIL',
|
||||
});
|
||||
expect(f.residenceTypes).toEqual(['Two Bedroom Marina Villa']);
|
||||
expect(f.preferredContact).toBe('email');
|
||||
|
||||
const single = extractInquiryFields({ residence_types: 'Four Bedroom Oceanfront Villa' });
|
||||
expect(single.residenceTypes).toEqual(['Four Bedroom Oceanfront Villa']);
|
||||
});
|
||||
|
||||
it('maps a contact form payload (interest[] -> joined interestType + comments)', () => {
|
||||
const f = extractInquiryFields({
|
||||
first_name: 'Ann',
|
||||
@@ -70,6 +95,8 @@ describe('extractInquiryFields', () => {
|
||||
placeOfResidence: null,
|
||||
comments: null,
|
||||
interestType: null,
|
||||
residenceTypes: [],
|
||||
preferredContact: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user