Compare commits
2 Commits
cutover/we
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b2692839f1 | |||
| caaebd77fa |
@@ -141,9 +141,17 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'inquiry_contact_email',
|
key: 'inquiry_contact_email',
|
||||||
label: 'Inquiry Contact Email',
|
label: 'Berth & residence reply-to email',
|
||||||
description:
|
description:
|
||||||
'Reply-to email shown in client confirmation emails when a new interest is registered',
|
'Public "reach out to us at …" address shown to clients in berth + residence inquiry confirmation emails. Defaults to sales@portnimara.com when blank.',
|
||||||
|
type: 'string',
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact_form_contact_email',
|
||||||
|
label: 'Contact-form reply-to email',
|
||||||
|
description:
|
||||||
|
'Public "reach out to us at …" address shown to clients in contact-form confirmation emails. Defaults to hello@portnimara.com when blank.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
// password here. Toggle off to set one manually.
|
// password here. Toggle off to set one manually.
|
||||||
const [sendSetupEmail, setSendSetupEmail] = useState(true);
|
const [sendSetupEmail, setSendSetupEmail] = useState(true);
|
||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
|
// New users: optional sign-in username (they can also sign in with their
|
||||||
|
// email). Lowercased on the way out; the API validates shape + uniqueness.
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
|
||||||
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
user?.phone ? { e164: user.phone, country: 'US' } : null,
|
||||||
);
|
);
|
||||||
@@ -153,6 +156,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
password: sendSetupEmail ? undefined : password,
|
password: sendSetupEmail ? undefined : password,
|
||||||
sendSetupEmail,
|
sendSetupEmail,
|
||||||
displayName,
|
displayName,
|
||||||
|
username: username.trim() ? username.trim().toLowerCase() : undefined,
|
||||||
phone: phoneE164 ?? undefined,
|
phone: phoneE164 ?? undefined,
|
||||||
roleId,
|
roleId,
|
||||||
residentialAccess,
|
residentialAccess,
|
||||||
@@ -236,6 +240,26 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-username">Username (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="user-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="e.g. abbie"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Lets them sign in with a short username instead of their email. 2–30 lowercase
|
||||||
|
letters, digits, dot, underscore, or hyphen. Leave blank to sign in by email
|
||||||
|
only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="user-email">Email</Label>
|
<Label htmlFor="user-email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -303,10 +303,10 @@ export const userProfiles = pgTable(
|
|||||||
displayName: text('display_name').notNull(),
|
displayName: text('display_name').notNull(),
|
||||||
/**
|
/**
|
||||||
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
|
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
|
||||||
* 3–30 chars (shape pinned by `chk_user_profiles_username_shape`).
|
* 2–30 chars (shape enforced in-app by `USERNAME_REGEX` in
|
||||||
* Case-insensitive uniqueness is enforced by a partial unique index on
|
* `@/lib/validators/username`). Case-insensitive uniqueness is enforced
|
||||||
* LOWER(username); NULL allows the column to coexist with users who
|
* by a partial unique index on LOWER(username); NULL allows the column to
|
||||||
* still sign in by email. See migration 0054.
|
* coexist with users who still sign in by email. See migration 0054.
|
||||||
*/
|
*/
|
||||||
username: text('username'),
|
username: text('username'),
|
||||||
avatarUrl: text('avatar_url'),
|
avatarUrl: text('avatar_url'),
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ export async function sendEmail(
|
|||||||
// the safety net.
|
// the safety net.
|
||||||
cc?: string | string[],
|
cc?: string | string[],
|
||||||
bcc?: string | string[],
|
bcc?: string | string[],
|
||||||
|
// Optional per-message Reply-To. Overrides the port's `email_reply_to`
|
||||||
|
// setting (`cfg.replyTo`) when provided — used so client inquiry
|
||||||
|
// confirmations reply to the public sales@/hello@ inbox, not the noreply From.
|
||||||
|
replyTo?: string,
|
||||||
): Promise<nodemailer.SentMessageInfo> {
|
): Promise<nodemailer.SentMessageInfo> {
|
||||||
const cfg = portId ? await getPortEmailConfig(portId) : null;
|
const cfg = portId ? await getPortEmailConfig(portId) : null;
|
||||||
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
|
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
|
||||||
@@ -150,13 +154,14 @@ export async function sendEmail(
|
|||||||
`Port Nimara CRM <noreply@${env.SMTP_HOST}>`;
|
`Port Nimara CRM <noreply@${env.SMTP_HOST}>`;
|
||||||
|
|
||||||
const resolvedAttachments = await resolveAttachments(attachments, portId);
|
const resolvedAttachments = await resolveAttachments(attachments, portId);
|
||||||
|
const effectiveReplyTo = replyTo ?? cfg?.replyTo ?? undefined;
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: fromHeader,
|
from: fromHeader,
|
||||||
to: effectiveTo,
|
to: effectiveTo,
|
||||||
subject: effectiveSubject,
|
subject: effectiveSubject,
|
||||||
html,
|
html,
|
||||||
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
|
...(effectiveReplyTo ? { replyTo: effectiveReplyTo } : {}),
|
||||||
...(text ? { text } : {}),
|
...(text ? { text } : {}),
|
||||||
...(effectiveCc ? { cc: effectiveCc } : {}),
|
...(effectiveCc ? { cc: effectiveCc } : {}),
|
||||||
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
|
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
|
||||||
|
|||||||
@@ -27,18 +27,13 @@ function ClientConfirmationBody({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||||
Thank you for getting in touch
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||||
|
Thank you for contacting {portName}. We have received your message and a member of our team
|
||||||
|
will be in touch with you shortly.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||||
Dear {firstName},
|
If you have any questions in the meantime, please feel free to reach out to us at{' '}
|
||||||
</Text>
|
|
||||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
|
||||||
Thank you for reaching out to {portName}. We have received your message and a member of our
|
|
||||||
team will be in touch with you shortly.
|
|
||||||
</Text>
|
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
|
||||||
If anything else comes to mind in the meantime, please write to us at{' '}
|
|
||||||
<Link
|
<Link
|
||||||
href={safeUrl(`mailto:${contactEmail}`)}
|
href={safeUrl(`mailto:${contactEmail}`)}
|
||||||
style={{ color: accent, textDecoration: 'underline' }}
|
style={{ color: accent, textDecoration: 'underline' }}
|
||||||
@@ -47,10 +42,10 @@ function ClientConfirmationBody({
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
<Text style={{ fontSize: '16px' }}>
|
||||||
With warm regards,
|
Best regards,
|
||||||
<br />
|
<br />
|
||||||
<strong>The {portName} Team</strong>
|
The {portName} Team
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -60,10 +55,10 @@ export async function contactFormClientConfirmation(
|
|||||||
data: ContactFormClientConfirmationData,
|
data: ContactFormClientConfirmationData,
|
||||||
overrides?: RenderOpts,
|
overrides?: RenderOpts,
|
||||||
) {
|
) {
|
||||||
const portName = data.portName ?? 'our team';
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
const subject = overrides?.subject?.trim()
|
const subject = overrides?.subject?.trim()
|
||||||
? overrides.subject
|
? overrides.subject
|
||||||
: `Thank you for contacting ${portName}`;
|
: `${portName} — Thank You for Contacting Us`;
|
||||||
const accent = brandingPrimaryColor(overrides?.branding);
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
const body = await render(
|
const body = await render(
|
||||||
<ClientConfirmationBody
|
<ClientConfirmationBody
|
||||||
@@ -74,8 +69,19 @@ export async function contactFormClientConfirmation(
|
|||||||
/>,
|
/>,
|
||||||
{ pretty: false },
|
{ pretty: false },
|
||||||
);
|
);
|
||||||
|
const text = [
|
||||||
|
`Dear ${data.firstName},`,
|
||||||
|
'',
|
||||||
|
`Thank you for contacting ${portName}. We have received your message and a member of our team will be in touch with you shortly.`,
|
||||||
|
'',
|
||||||
|
`If you have any questions in the meantime, please feel free to reach out to us at ${data.contactEmail}.`,
|
||||||
|
'',
|
||||||
|
'Best regards,',
|
||||||
|
`The ${portName} Team`,
|
||||||
|
].join('\n');
|
||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ function ClientConfirmationBody({
|
|||||||
<>
|
<>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||||
Thank you for your interest in {berthText}. We've noted your enquiry, and a member of
|
Thank you for expressing interest in {berthText}. Our team has registered your interest, and
|
||||||
our team will be in touch shortly through your preferred channel with the details
|
we will reach out to you very shortly by your preferred method of contact with more
|
||||||
you've requested.
|
information.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||||
Should anything come to mind in the meantime, please don't hesitate to write to us at{' '}
|
If you have any questions, please feel free to reach out to us at{' '}
|
||||||
<Link
|
<Link
|
||||||
href={safeUrl(`mailto:${contactEmail}`)}
|
href={safeUrl(`mailto:${contactEmail}`)}
|
||||||
style={{ color: accent, textDecoration: 'underline' }}
|
style={{ color: accent, textDecoration: 'underline' }}
|
||||||
@@ -47,7 +47,7 @@ function ClientConfirmationBody({
|
|||||||
.
|
.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: '16px' }}>
|
<Text style={{ fontSize: '16px' }}>
|
||||||
With warm regards,
|
Best regards,
|
||||||
<br />
|
<br />
|
||||||
The {portName} Sales Team
|
The {portName} Sales Team
|
||||||
</Text>
|
</Text>
|
||||||
@@ -61,12 +61,10 @@ export async function inquiryClientConfirmation(
|
|||||||
) {
|
) {
|
||||||
const { firstName, mooringNumber, contactEmail } = data;
|
const { firstName, mooringNumber, contactEmail } = data;
|
||||||
const portName = data.portName ?? 'Port Nimara';
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
|
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Berth';
|
||||||
const subject = overrides?.subject?.trim()
|
const subject = overrides?.subject?.trim()
|
||||||
? overrides.subject
|
? overrides.subject
|
||||||
: mooringNumber
|
: `${portName} — Thank You for Your Interest`;
|
||||||
? `Thank you for your interest in Berth ${mooringNumber}`
|
|
||||||
: `Thank you for your interest in ${portName}`;
|
|
||||||
const accent = brandingPrimaryColor(overrides?.branding);
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
const body = await render(
|
const body = await render(
|
||||||
@@ -83,11 +81,11 @@ export async function inquiryClientConfirmation(
|
|||||||
const text = [
|
const text = [
|
||||||
`Dear ${firstName},`,
|
`Dear ${firstName},`,
|
||||||
'',
|
'',
|
||||||
`Thank you for your interest in ${berthText}. We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel with the details you've requested.`,
|
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||||
'',
|
'',
|
||||||
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${contactEmail}.`,
|
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||||
'',
|
'',
|
||||||
'With warm regards,',
|
'Best regards,',
|
||||||
`The ${portName} Sales Team`,
|
`The ${portName} Sales Team`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ export interface ResidentialClientConfirmationData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable list of the residence types a lead selected, e.g.
|
* Human-readable phrase for the residence types a lead selected, e.g.
|
||||||
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
|
* "the Two Bedroom Marina Villa and the Four Bedroom Oceanfront Villa".
|
||||||
* Falls back to a generic phrase when nothing was selected so the copy
|
* Mirrors the website's phrasing, including its generic fallback.
|
||||||
* always reads naturally.
|
|
||||||
*/
|
*/
|
||||||
function residencePhrase(portName: string, types: string[] | undefined): string {
|
function residencePhrase(portName: string, types: string[] | undefined): string {
|
||||||
const list = (types ?? []).filter(Boolean);
|
const list = (types ?? []).filter(Boolean);
|
||||||
if (list.length === 0) return `the residences at ${portName}`;
|
if (list.length === 0) return `a ${portName} Residence`;
|
||||||
if (list.length === 1) return `the ${list[0]}`;
|
if (list.length === 1) return `the ${list[0]}`;
|
||||||
if (list.length === 2) return `the ${list[0]} and the ${list[1]}`;
|
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]}`;
|
return `the ${list.slice(0, -1).join(', the ')}, and the ${list[list.length - 1]}`;
|
||||||
@@ -44,19 +43,14 @@ function ClientConfirmationBody({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||||
Welcome to {portName}
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||||
|
Thank you for expressing interest in {residencePhraseText}. Our team has registered your
|
||||||
|
interest, and we will reach out to you very shortly by your preferred method of contact with
|
||||||
|
more information.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||||
Dear {firstName},
|
If you have any questions, please feel free to reach out to us at{' '}
|
||||||
</Text>
|
|
||||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
|
||||||
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>
|
|
||||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
|
||||||
Should anything come to mind in the meantime, please don't hesitate to write to us at{' '}
|
|
||||||
<Link
|
<Link
|
||||||
href={safeUrl(`mailto:${contactEmail}`)}
|
href={safeUrl(`mailto:${contactEmail}`)}
|
||||||
style={{ color: accent, textDecoration: 'underline' }}
|
style={{ color: accent, textDecoration: 'underline' }}
|
||||||
@@ -65,10 +59,10 @@ function ClientConfirmationBody({
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
<Text style={{ fontSize: '16px' }}>
|
||||||
With warm regards,
|
Best regards,
|
||||||
<br />
|
<br />
|
||||||
<strong>The {portName} Residential Team</strong>
|
The {portName} Residences Team
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -78,10 +72,10 @@ export async function residentialClientConfirmation(
|
|||||||
data: ResidentialClientConfirmationData,
|
data: ResidentialClientConfirmationData,
|
||||||
overrides?: RenderOpts,
|
overrides?: RenderOpts,
|
||||||
) {
|
) {
|
||||||
const portName = data.portName ?? 'our team';
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
const subject = overrides?.subject?.trim()
|
const subject = overrides?.subject?.trim()
|
||||||
? overrides.subject
|
? overrides.subject
|
||||||
: `Thank you for your interest in ${portName} Residences`;
|
: `${portName} — Thank You for Your Interest`;
|
||||||
const accent = brandingPrimaryColor(overrides?.branding);
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
|
const residencePhraseText = residencePhrase(portName, data.residenceTypes);
|
||||||
const body = await render(
|
const body = await render(
|
||||||
@@ -97,12 +91,12 @@ export async function residentialClientConfirmation(
|
|||||||
const text = [
|
const text = [
|
||||||
`Dear ${data.firstName},`,
|
`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.`,
|
`Thank you for expressing interest in ${residencePhraseText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||||
'',
|
'',
|
||||||
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${data.contactEmail}.`,
|
`If you have any questions, please feel free to reach out to us at ${data.contactEmail}.`,
|
||||||
'',
|
'',
|
||||||
'With warm regards,',
|
'Best regards,',
|
||||||
`The ${portName} Residential Team`,
|
`The ${portName} Residences Team`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
@@ -120,6 +114,11 @@ export interface ResidentialSalesAlertData {
|
|||||||
preferredContactMethod?: 'email' | 'phone';
|
preferredContactMethod?: 'email' | 'phone';
|
||||||
notes?: string;
|
notes?: string;
|
||||||
preferences?: string;
|
preferences?: string;
|
||||||
|
/**
|
||||||
|
* Accepted for backwards-compat with the legacy `/api/public/residential-inquiries`
|
||||||
|
* route, but intentionally NOT rendered: residential alerts go to external
|
||||||
|
* recipients and must never mention the CRM.
|
||||||
|
*/
|
||||||
crmDeepLink?: string;
|
crmDeepLink?: string;
|
||||||
portName?: string;
|
portName?: string;
|
||||||
}
|
}
|
||||||
@@ -130,15 +129,7 @@ function formatPreferredContact(method: 'email' | 'phone' | undefined): string |
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SalesAlertBody({
|
function SalesAlertBody({ portName, data }: { portName: string; data: ResidentialSalesAlertData }) {
|
||||||
portName,
|
|
||||||
data,
|
|
||||||
accent,
|
|
||||||
}: {
|
|
||||||
portName: string;
|
|
||||||
data: ResidentialSalesAlertData;
|
|
||||||
accent: string;
|
|
||||||
}) {
|
|
||||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||||
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
|
const residenceTypes = (data.residenceTypes ?? []).filter(Boolean);
|
||||||
const preferredContact = formatPreferredContact(data.preferredContactMethod);
|
const preferredContact = formatPreferredContact(data.preferredContactMethod);
|
||||||
@@ -185,19 +176,7 @@ function SalesAlertBody({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{data.crmDeepLink ? (
|
<Text style={{ fontSize: '16px' }}>- {portName} Residences</Text>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,12 +185,11 @@ export async function residentialSalesAlert(
|
|||||||
data: ResidentialSalesAlertData,
|
data: ResidentialSalesAlertData,
|
||||||
overrides?: RenderOpts,
|
overrides?: RenderOpts,
|
||||||
) {
|
) {
|
||||||
const portName = data.portName ?? 'our team';
|
const portName = data.portName ?? 'Port Nimara';
|
||||||
const subject = overrides?.subject?.trim()
|
const subject = overrides?.subject?.trim()
|
||||||
? overrides.subject
|
? overrides.subject
|
||||||
: `New residential enquiry - ${data.fullName}`;
|
: `New residential enquiry - ${data.fullName}`;
|
||||||
const accent = brandingPrimaryColor(overrides?.branding);
|
const body = await render(<SalesAlertBody portName={portName} data={data} />, {
|
||||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
|
||||||
pretty: false,
|
pretty: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,10 +209,7 @@ export async function residentialSalesAlert(
|
|||||||
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
|
...(data.preferences ? [`Preferences: ${data.preferences}`] : []),
|
||||||
...(data.notes ? [`Comments: ${data.notes}`] : []),
|
...(data.notes ? [`Comments: ${data.notes}`] : []),
|
||||||
'',
|
'',
|
||||||
...(data.crmDeepLink
|
`- ${portName} Residences`,
|
||||||
? [`Open the ${portName} CRM (${data.crmDeepLink}) to follow up.`, '']
|
|
||||||
: []),
|
|
||||||
`- ${portName} CRM`,
|
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
|
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
|
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
|
||||||
@@ -153,6 +154,31 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
|||||||
});
|
});
|
||||||
if (!role) throw new ValidationError('Invalid role ID');
|
if (!role) throw new ValidationError('Invalid role ID');
|
||||||
|
|
||||||
|
// Optional sign-in username. Validated up front (before the auth user is
|
||||||
|
// minted) so an invalid/taken username can't leave an orphaned account.
|
||||||
|
// Mirrors the self-service /api/v1/me checks: shape + reserved + unique.
|
||||||
|
let username: string | null = null;
|
||||||
|
if (data.username && data.username.trim()) {
|
||||||
|
const candidate = data.username.trim().toLowerCase();
|
||||||
|
if (!USERNAME_REGEX.test(candidate)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'Username must be 2–30 lowercase letters, digits, dot, underscore, or hyphen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isReservedUsername(candidate)) {
|
||||||
|
throw new ValidationError('That username is reserved. Please pick another.');
|
||||||
|
}
|
||||||
|
const taken = await db
|
||||||
|
.select({ userId: userProfiles.userId })
|
||||||
|
.from(userProfiles)
|
||||||
|
.where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
|
||||||
|
.limit(1);
|
||||||
|
if (taken.length > 0) {
|
||||||
|
throw new ConflictError('That username is already taken.');
|
||||||
|
}
|
||||||
|
username = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
// Two onboarding modes:
|
// Two onboarding modes:
|
||||||
// - setup-email (default when no password is supplied): provision the
|
// - setup-email (default when no password is supplied): provision the
|
||||||
// account with a throwaway random password the admin never sees, then
|
// account with a throwaway random password the admin never sees, then
|
||||||
@@ -179,6 +205,7 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
|
|||||||
await db.insert(userProfiles).values({
|
await db.insert(userProfiles).values({
|
||||||
userId: newUserId,
|
userId: newUserId,
|
||||||
displayName: data.displayName,
|
displayName: data.displayName,
|
||||||
|
username,
|
||||||
firstName: data.firstName ?? null,
|
firstName: data.firstName ?? null,
|
||||||
lastName: data.lastName ?? null,
|
lastName: data.lastName ?? null,
|
||||||
phone: data.phone ?? null,
|
phone: data.phone ?? null,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
@@ -28,7 +29,6 @@ import {
|
|||||||
} from '@/lib/email/templates/residential-inquiry';
|
} from '@/lib/email/templates/residential-inquiry';
|
||||||
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
|
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
|
||||||
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
|
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
|
||||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
|
||||||
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
||||||
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
|
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
|
||||||
import { createNotification } from '@/lib/services/notifications.service';
|
import { createNotification } from '@/lib/services/notifications.service';
|
||||||
@@ -77,13 +77,36 @@ export async function sendWebsiteSubmissionEmails(
|
|||||||
const { portId, portSlug, kind, payload } = input;
|
const { portId, portSlug, kind, payload } = input;
|
||||||
const fields = extractInquiryFields(payload);
|
const fields = extractInquiryFields(payload);
|
||||||
|
|
||||||
const [branding, portBrand, emailCfg] = await Promise.all([
|
const [branding, portRow] = await Promise.all([
|
||||||
getBrandingShell(portId),
|
getBrandingShell(portId),
|
||||||
getPortBrandingConfig(portId).catch(() => null),
|
db.select({ name: ports.name }).from(ports).where(eq(ports.id, portId)).limit(1),
|
||||||
getPortEmailConfig(portId).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
const portName = portBrand?.appName ?? 'Port Nimara';
|
// Client-facing copy uses the PUBLIC port name ("Port Nimara"), never the CRM
|
||||||
const contactEmail = emailCfg?.fromAddress ?? 'sales@portnimara.com';
|
// appName ("Port Nimara CRM") which is reserved for internal/staff surfaces.
|
||||||
|
const portName = portRow[0]?.name ?? 'Port Nimara';
|
||||||
|
|
||||||
|
// Public reply-to shown to clients in confirmation emails ("reach out to us
|
||||||
|
// at ..."). Admin-configurable per category via system_settings; contact-form
|
||||||
|
// enquiries default to the hello@ general inbox, berth + residence to sales@.
|
||||||
|
// Never the noreply From address.
|
||||||
|
const contactEmailKey =
|
||||||
|
kind === 'contact_form' ? 'contact_form_contact_email' : 'inquiry_contact_email';
|
||||||
|
const contactEmailDefault =
|
||||||
|
kind === 'contact_form' ? 'hello@portnimara.com' : 'sales@portnimara.com';
|
||||||
|
const [contactRow] = await db
|
||||||
|
.select({ value: systemSettings.value })
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(systemSettings.key, contactEmailKey),
|
||||||
|
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const contactEmail =
|
||||||
|
typeof contactRow?.value === 'string' && contactRow.value.trim()
|
||||||
|
? contactRow.value.trim()
|
||||||
|
: contactEmailDefault;
|
||||||
// No interest/client row exists for a raw submission, so link to the
|
// No interest/client row exists for a raw submission, so link to the
|
||||||
// dashboard rather than a (nonexistent) entity detail page.
|
// dashboard rather than a (nonexistent) entity detail page.
|
||||||
const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`;
|
const crmUrl = `${process.env.APP_URL ?? ''}/${portSlug}`;
|
||||||
@@ -116,6 +139,10 @@ export async function sendWebsiteSubmissionEmails(
|
|||||||
undefined,
|
undefined,
|
||||||
confirmation.text,
|
confirmation.text,
|
||||||
portId,
|
portId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
contactEmail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +199,10 @@ export async function sendWebsiteSubmissionEmails(
|
|||||||
undefined,
|
undefined,
|
||||||
confirmation.text,
|
confirmation.text,
|
||||||
portId,
|
portId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
contactEmail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +217,6 @@ export async function sendWebsiteSubmissionEmails(
|
|||||||
preferredContactMethod: fields.preferredContact ?? undefined,
|
preferredContactMethod: fields.preferredContact ?? undefined,
|
||||||
placeOfResidence: fields.placeOfResidence ?? undefined,
|
placeOfResidence: fields.placeOfResidence ?? undefined,
|
||||||
notes: fields.comments ?? undefined,
|
notes: fields.comments ?? undefined,
|
||||||
crmDeepLink: crmUrl,
|
|
||||||
portName,
|
portName,
|
||||||
},
|
},
|
||||||
{ branding },
|
{ branding },
|
||||||
@@ -217,7 +247,18 @@ export async function sendWebsiteSubmissionEmails(
|
|||||||
fallback: confirmation.subject,
|
fallback: confirmation.subject,
|
||||||
tokens: { portName, recipientName: fields.firstName },
|
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,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
contactEmail,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contact-form alerts go to their own recipient list (the website routed
|
// Contact-form alerts go to their own recipient list (the website routed
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const createUserSchema = z
|
|||||||
* password. When false, `password` must be supplied inline. */
|
* password. When false, `password` must be supplied inline. */
|
||||||
sendSetupEmail: z.boolean().optional(),
|
sendSetupEmail: z.boolean().optional(),
|
||||||
displayName: z.string().min(1).max(200),
|
displayName: z.string().min(1).max(200),
|
||||||
|
/** Optional sign-in username. Shape/uniqueness/reserved checks run in the
|
||||||
|
* service (mirrors the self-service /api/v1/me path). Omit for email-only. */
|
||||||
|
username: z.string().optional(),
|
||||||
firstName: z.string().min(1).max(200).nullable().optional(),
|
firstName: z.string().min(1).max(200).nullable().optional(),
|
||||||
lastName: z.string().min(1).max(200).nullable().optional(),
|
lastName: z.string().min(1).max(200).nullable().optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
|
|||||||
52
tests/unit/email/client-confirmations.test.ts
Normal file
52
tests/unit/email/client-confirmations.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
|
||||||
|
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
|
||||||
|
|
||||||
|
// Note: assert prose that spans an interpolation on the plain-text part — the
|
||||||
|
// React-Email HTML renderer inserts `<!-- -->` markers at value boundaries.
|
||||||
|
|
||||||
|
describe('inquiryClientConfirmation (berth)', () => {
|
||||||
|
it('mirrors the website copy for a specific berth + signs off as Sales', async () => {
|
||||||
|
const { subject, html, text } = await inquiryClientConfirmation({
|
||||||
|
firstName: 'Jane',
|
||||||
|
mooringNumber: 'D13',
|
||||||
|
contactEmail: 'sales@portnimara.com',
|
||||||
|
portName: 'Port Nimara',
|
||||||
|
});
|
||||||
|
expect(subject).toBe('Port Nimara — Thank You for Your Interest');
|
||||||
|
expect(text).toContain('Thank you for expressing interest in Berth D13');
|
||||||
|
expect(text).toContain('Our team has registered your interest');
|
||||||
|
expect(text).toContain('reach out to us at sales@portnimara.com');
|
||||||
|
expect(text).toContain('The Port Nimara Sales Team');
|
||||||
|
expect(html).toContain('sales@portnimara.com');
|
||||||
|
expect(html).not.toContain('Port Nimara CRM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "a Berth" when no mooring is given', async () => {
|
||||||
|
const { text, html } = await inquiryClientConfirmation({
|
||||||
|
firstName: 'Jane',
|
||||||
|
mooringNumber: null,
|
||||||
|
contactEmail: 'sales@portnimara.com',
|
||||||
|
portName: 'Port Nimara',
|
||||||
|
});
|
||||||
|
expect(text).toContain('Thank you for expressing interest in a Berth');
|
||||||
|
expect(html).not.toContain('Port Nimara CRM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('contactFormClientConfirmation', () => {
|
||||||
|
it('mirrors the website copy + signs off as the Port Nimara Team', async () => {
|
||||||
|
const { subject, html, text } = await contactFormClientConfirmation({
|
||||||
|
firstName: 'Bob',
|
||||||
|
contactEmail: 'hello@portnimara.com',
|
||||||
|
portName: 'Port Nimara',
|
||||||
|
});
|
||||||
|
expect(subject).toBe('Port Nimara — Thank You for Contacting Us');
|
||||||
|
expect(text).toContain('Thank you for contacting Port Nimara');
|
||||||
|
expect(text).toContain('We have received your message');
|
||||||
|
expect(text).toContain('reach out to us at hello@portnimara.com');
|
||||||
|
expect(text).toContain('The Port Nimara Team');
|
||||||
|
expect(html).not.toContain('Port Nimara CRM');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,30 +6,36 @@ import {
|
|||||||
} from '@/lib/email/templates/residential-inquiry';
|
} from '@/lib/email/templates/residential-inquiry';
|
||||||
|
|
||||||
describe('residentialClientConfirmation', () => {
|
describe('residentialClientConfirmation', () => {
|
||||||
it('reflects the chosen residence types in the thank-you copy', async () => {
|
it('mirrors the website copy + reflects the chosen residence types', async () => {
|
||||||
const { html, text } = await residentialClientConfirmation({
|
const { subject, html, text } = await residentialClientConfirmation({
|
||||||
firstName: 'Mia',
|
firstName: 'Mia',
|
||||||
contactEmail: 'sales@portnimara.com',
|
contactEmail: 'sales@portnimara.com',
|
||||||
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
|
residenceTypes: ['Two Bedroom Marina Villa', 'Five Bedroom Oceanfront Villa'],
|
||||||
portName: 'Port Nimara',
|
portName: 'Port Nimara',
|
||||||
});
|
});
|
||||||
expect(html).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
|
expect(subject).toBe('Port Nimara — Thank You for Your Interest');
|
||||||
expect(text).toContain('the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa');
|
expect(text).toContain(
|
||||||
expect(html).toContain('Mia');
|
'Thank you for expressing interest in the Two Bedroom Marina Villa and the Five Bedroom Oceanfront Villa',
|
||||||
|
);
|
||||||
|
expect(text).toContain('Our team has registered your interest');
|
||||||
|
expect(text).toContain('The Port Nimara Residences Team');
|
||||||
|
// Never leak the CRM brand name to a client.
|
||||||
|
expect(html).not.toContain('Port Nimara CRM');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a generic phrase when no types are selected', async () => {
|
it('falls back to the website generic phrase when no types are selected', async () => {
|
||||||
const { html } = await residentialClientConfirmation({
|
const { html } = await residentialClientConfirmation({
|
||||||
firstName: 'Sam',
|
firstName: 'Sam',
|
||||||
contactEmail: 'sales@portnimara.com',
|
contactEmail: 'sales@portnimara.com',
|
||||||
portName: 'Port Nimara',
|
portName: 'Port Nimara',
|
||||||
});
|
});
|
||||||
expect(html).toContain('the residences at Port Nimara');
|
expect(html).toContain('a Port Nimara Residence');
|
||||||
|
expect(html).not.toContain('Port Nimara CRM');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('residentialSalesAlert', () => {
|
describe('residentialSalesAlert', () => {
|
||||||
it('renders residence type(s) + preferred contact + comments in the detail-line format', async () => {
|
it('renders residence type(s) + preferred contact + comments, with NO CRM mention', async () => {
|
||||||
const { html, text } = await residentialSalesAlert({
|
const { html, text } = await residentialSalesAlert({
|
||||||
fullName: 'Mia Ng',
|
fullName: 'Mia Ng',
|
||||||
email: 'mia@example.com',
|
email: 'mia@example.com',
|
||||||
@@ -37,21 +43,22 @@ describe('residentialSalesAlert', () => {
|
|||||||
residenceTypes: ['Two Bedroom Marina Villa'],
|
residenceTypes: ['Two Bedroom Marina Villa'],
|
||||||
preferredContactMethod: 'phone',
|
preferredContactMethod: 'phone',
|
||||||
notes: 'Looking for a winter completion.',
|
notes: 'Looking for a winter completion.',
|
||||||
crmDeepLink: 'https://crm.portnimara.com/port-nimara',
|
|
||||||
portName: '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('A new residential enquiry has come in');
|
||||||
expect(html).toContain('Residence type(s):');
|
expect(html).toContain('Residence type(s):');
|
||||||
expect(html).toContain('Two Bedroom Marina Villa');
|
expect(html).toContain('Two Bedroom Marina Villa');
|
||||||
expect(html).toContain('Preferred contact:');
|
expect(html).toContain('Preferred contact:');
|
||||||
expect(html).toContain('Phone call back');
|
expect(html).toContain('Phone call back');
|
||||||
expect(html).toContain('Looking for a winter completion.');
|
expect(html).toContain('Looking for a winter completion.');
|
||||||
expect(html).toContain('to follow up');
|
expect(text).toContain('- Port Nimara Residences');
|
||||||
// Plain-text part mirrors the other alerts.
|
// Residential internal alerts must not mention the CRM (recipient is external).
|
||||||
|
expect(html).not.toContain('CRM');
|
||||||
|
expect(html).not.toContain('to follow up');
|
||||||
expect(text).toContain('Residence type(s): Two Bedroom Marina Villa');
|
expect(text).toContain('Residence type(s): Two Bedroom Marina Villa');
|
||||||
expect(text).toContain('Preferred contact: Phone call back');
|
expect(text).toContain('Preferred contact: Phone call back');
|
||||||
expect(text).toContain('Comments: Looking for a winter completion.');
|
expect(text).toContain('Comments: Looking for a winter completion.');
|
||||||
|
expect(text).not.toContain('CRM');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits optional rows cleanly when absent', async () => {
|
it('omits optional rows cleanly when absent', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user