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:
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