From 866930c94358756eff34914c65afbc4db16eaf94 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Jun 2026 20:58:53 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X --- .../admin/settings/settings-manager.tsx | 12 +- src/components/inquiries/inquiry-detail.tsx | 26 +++ .../residential-interest-detail.tsx | 1 + .../residential/residential-interest-tabs.tsx | 12 ++ .../0099_residential_residence_type.sql | 5 + src/lib/db/schema/residential.ts | 7 + .../email/templates/contact-form-alert.tsx | 109 ++++++----- .../email/templates/residential-inquiry.tsx | 181 +++++++++++------- .../services/website-intake-email.service.ts | 26 ++- src/lib/services/website-intake-fields.ts | 20 ++ src/lib/settings/registry.ts | 2 +- src/lib/validators/residential.ts | 16 ++ tests/unit/email/contact-form-alert.test.ts | 39 ++++ tests/unit/email/residential-inquiry.test.ts | 68 +++++++ .../validators/residential-interest.test.ts | 44 +++++ tests/unit/website-intake-fields.test.ts | 27 +++ 16 files changed, 469 insertions(+), 126 deletions(-) create mode 100644 src/lib/db/migrations/0099_residential_residence_type.sql create mode 100644 tests/unit/email/contact-form-alert.test.ts create mode 100644 tests/unit/email/residential-inquiry.test.ts create mode 100644 tests/unit/validators/residential-interest.test.ts diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 55ceab96..6291b9b1 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -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: [], }, diff --git a/src/components/inquiries/inquiry-detail.tsx b/src/components/inquiries/inquiry-detail.tsx index 41edf95e..fff8ab0f 100644 --- a/src/components/inquiries/inquiry-detail.tsx +++ b/src/components/inquiries/inquiry-detail.tsx @@ -77,10 +77,29 @@ export function InquiryDetail({ id }: { id: string }) { const p = (data?.payload ?? {}) as Record; 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 }) { + {data?.kind === 'residence_inquiry' ? ( + + ) : null} {data?.kind === 'residence_inquiry' ? ( ) : null} {data?.kind === 'berth_inquiry' ? : null} + {data?.kind === 'contact_form' && contactInterest ? ( + + ) : null} + {preferredContact ? : null} {comment ? ( {comment}} /> ) : null} diff --git a/src/components/residential/residential-interest-detail.tsx b/src/components/residential/residential-interest-detail.tsx index b0760a69..560bf16f 100644 --- a/src/components/residential/residential-interest-detail.tsx +++ b/src/components/residential/residential-interest-detail.tsx @@ -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; } diff --git a/src/components/residential/residential-interest-tabs.tsx b/src/components/residential/residential-interest-tabs.tsx index 846fb306..b7e41982 100644 --- a/src/components/residential/residential-interest-tabs.tsx +++ b/src/components/residential/residential-interest-tabs.tsx @@ -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({

Details

+ + + - - New contact form submission + Hello, + + A new contact-form enquiry has come in for {portName}. {data.fullName} got + in touch via the website contact page - full details below: - - - - - - - - - - - {data.interestType ? ( - - - - - ) : null} - {data.comments ? ( - - - - - ) : null} - -
Name{data.fullName}
Email{data.email}
Interest{data.interestType}
Comments{data.comments}
- {data.crmDeepLink ? ( -
- -
+ + Name: {data.fullName} + + + Email: {data.email} + + {data.interestType ? ( + + Interest: {data.interestType} + ) : null} - - {portName} CRM + + Comments: {comments} + + {data.crmDeepLink ? ( + + Open the{' '} + + {portName} CRM + {' '} + to follow up. + + ) : null} + - {portName} CRM ); } @@ -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(, { 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, }; } diff --git a/src/lib/email/templates/residential-inquiry.tsx b/src/lib/email/templates/residential-inquiry.tsx index 6e5dec2a..553bb3ca 100644 --- a/src/lib/email/templates/residential-inquiry.tsx +++ b/src/lib/email/templates/residential-inquiry.tsx @@ -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},
- 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. @@ -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( , { 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 ( <> - - New residential inquiry + Hello, + + A new residential enquiry has come in for {portName}. {data.fullName} has + asked us to be in touch - full details below: - - - - - - - - - - - - - - - {data.placeOfResidence ? ( - - - - - ) : null} - {data.preferredContactMethod ? ( - - - - - ) : null} - {data.preferences ? ( - - - - - ) : null} - {data.notes ? ( - - - - - ) : null} - -
Name{data.fullName}
Email{data.email}
Phone{data.phone}
Residence{data.placeOfResidence}
Prefers{data.preferredContactMethod}
Preferences{data.preferences}
Notes{data.notes}
+
+ + Name: {data.fullName} + + + Email: {data.email} + + + Telephone: {data.phone} + + {residenceTypes.length > 0 ? ( + + Residence type(s): {residenceTypes.join(', ')} + + ) : null} + {preferredContact ? ( + + Preferred contact: {preferredContact} + + ) : null} + {data.placeOfResidence ? ( + + Place of residence: {data.placeOfResidence} + + ) : null} + {data.preferences ? ( + + Preferences: {data.preferences} + + ) : null} + {data.notes ? ( + + Comments: {data.notes} + + ) : null} +
{data.crmDeepLink ? ( -
- -
+ {portName} CRM + {' '} + to follow up. +
) : null} - - {portName} CRM + - {portName} CRM ); } @@ -189,8 +214,32 @@ export async function residentialSalesAlert( const body = await render(, { 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, }; } diff --git a/src/lib/services/website-intake-email.service.ts b/src/lib/services/website-intake-email.service.ts index eb35fc23..662b8a57 100644 --- a/src/lib/services/website-intake-email.service.ts +++ b/src/lib/services/website-intake-email.service.ts @@ -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( { diff --git a/src/lib/services/website-intake-fields.ts b/src/lib/services/website-intake-fields.ts index 7ad2d3db..4770bb16 100644 --- a/src/lib/services/website-intake-fields.ts +++ b/src/lib/services/website-intake-fields.ts @@ -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): 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): InquiryF placeOfResidence, comments, interestType, + residenceTypes, + preferredContact, }; } diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index 21fbefc5..9e2c97e0 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -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, diff --git a/src/lib/validators/residential.ts b/src/lib/validators/residential.ts index 48acd7b2..8a004652 100644 --- a/src/lib/validators/residential.ts +++ b/src/lib/validators/residential.ts @@ -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(), }); diff --git a/tests/unit/email/contact-form-alert.test.ts b/tests/unit/email/contact-form-alert.test.ts new file mode 100644 index 00000000..bed17e0b --- /dev/null +++ b/tests/unit/email/contact-form-alert.test.ts @@ -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:'); + }); +}); diff --git a/tests/unit/email/residential-inquiry.test.ts b/tests/unit/email/residential-inquiry.test.ts new file mode 100644 index 00000000..bf169f43 --- /dev/null +++ b/tests/unit/email/residential-inquiry.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/validators/residential-interest.test.ts b/tests/unit/validators/residential-interest.test.ts new file mode 100644 index 00000000..140896e1 --- /dev/null +++ b/tests/unit/validators/residential-interest.test.ts @@ -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', + ]); + }); +}); diff --git a/tests/unit/website-intake-fields.test.ts b/tests/unit/website-intake-fields.test.ts index 0c6d251c..cd472393 100644 --- a/tests/unit/website-intake-fields.test.ts +++ b/tests/unit/website-intake-fields.test.ts @@ -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, }); }); });