feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m17s

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:
2026-06-25 20:58:53 +02:00
parent 64a488dc15
commit 866930c943
16 changed files with 469 additions and 126 deletions

View File

@@ -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: [],
},

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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.
*/

View File

@@ -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,
};
}

View File

@@ -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&apos;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,
};
}

View File

@@ -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(
{

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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(),
});

View 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:');
});
});

View 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');
});
});

View 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',
]);
});
});

View File

@@ -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,
});
});
});