564 lines
17 KiB
TypeScript
564 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from "preact/hooks";
|
|
|
|
type InviteProperty = {
|
|
id: string;
|
|
title?: string | null;
|
|
address_1?: string | null;
|
|
address_2?: string | null;
|
|
city?: string | null;
|
|
state?: string | null;
|
|
zip?: string | null;
|
|
rental_status?: string | null;
|
|
monthly_rent?: number | null;
|
|
};
|
|
|
|
interface LandlordInviteTenantProps {
|
|
properties: InviteProperty[];
|
|
landlordName?: string | null;
|
|
inviteId?: string;
|
|
invitePropertyId?: string;
|
|
inviteAvailableOn?: number | null;
|
|
inviteEmails?: string[];
|
|
}
|
|
|
|
type GeneratedInvite = {
|
|
template: string;
|
|
recipient: string;
|
|
subject: string;
|
|
body: string;
|
|
inviteUrl: string;
|
|
};
|
|
|
|
const DEFAULT_SUBJECT = "You're invited to join your rental portal";
|
|
|
|
const EMAIL_TEMPLATE_LINES = [
|
|
"To: {{tenantEmail}}",
|
|
"Subject: {{subject}}",
|
|
"",
|
|
"Hi {{tenantName}},",
|
|
"",
|
|
"{{landlordName}} invited you to join the tenant portal for {{propertyLabel}}.",
|
|
"{{propertyAddressLine}}",
|
|
"{{rentLine}}",
|
|
"{{availableOnLine}}",
|
|
"",
|
|
"Review the property and start your lease:",
|
|
"{{inviteUrl}}",
|
|
"",
|
|
"{{phoneLine}}",
|
|
"",
|
|
"If you have any questions, reply to this email.",
|
|
];
|
|
|
|
const DEFAULT_TEMPLATE_PREVIEW = `To: tenant@example.com
|
|
Subject: ${DEFAULT_SUBJECT}
|
|
|
|
Hi Tenant Name,
|
|
|
|
Your landlord invited you to join the tenant portal for Property Name.
|
|
Rental: 123 Main St, City, ST
|
|
Monthly rent: $2,000
|
|
Available on: 2024-04-01
|
|
|
|
Review the property and start your lease:
|
|
https://your-domain.com/tenant/invite/INVITE_ID
|
|
|
|
We have this phone number on file for you: (555) 555-1234
|
|
|
|
If you have any questions, reply to this email.`;
|
|
|
|
function buildPropertyLabel(property: InviteProperty) {
|
|
const name = property.title?.trim() || property.address_1?.trim() ||
|
|
"Property";
|
|
const location = [property.city, property.state].filter(Boolean).join(", ");
|
|
return location ? `${name} (${location})` : name;
|
|
}
|
|
|
|
function buildPropertyAddress(property: InviteProperty) {
|
|
return [
|
|
property.address_1,
|
|
property.address_2,
|
|
property.city,
|
|
property.state,
|
|
property.zip,
|
|
].filter(Boolean).join(", ");
|
|
}
|
|
|
|
function formatCurrency(amount?: number | null) {
|
|
if (amount === null || amount === undefined) return "";
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
maximumFractionDigits: 0,
|
|
}).format(amount);
|
|
}
|
|
|
|
function parseEmailList(value: string) {
|
|
return value.split(/[\n,]+/)
|
|
.map((email) => email.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function formatDateInput(value?: number | null) {
|
|
if (!value) return "";
|
|
return new Date(value).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function parseDateInput(value: string) {
|
|
const [year, month, day] = value.split("-").map(Number);
|
|
if (!year || !month || !day) return null;
|
|
return Date.UTC(year, month - 1, day);
|
|
}
|
|
|
|
function fillTemplate(
|
|
lines: string[],
|
|
replacements: Record<string, string>,
|
|
) {
|
|
const filled = lines.map((line) =>
|
|
line.replace(
|
|
/\{\{(\w+)\}\}/g,
|
|
(_match, key: string) => replacements[key] ?? "",
|
|
)
|
|
);
|
|
const normalized: string[] = [];
|
|
let emptyCount = 0;
|
|
for (const line of filled) {
|
|
if (line.trim() === "") {
|
|
emptyCount += 1;
|
|
if (emptyCount <= 2) {
|
|
normalized.push("");
|
|
}
|
|
} else {
|
|
emptyCount = 0;
|
|
normalized.push(line);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export default function LandlordInviteTenant(
|
|
{
|
|
properties,
|
|
landlordName,
|
|
inviteId,
|
|
invitePropertyId,
|
|
inviteAvailableOn,
|
|
inviteEmails,
|
|
}: LandlordInviteTenantProps,
|
|
) {
|
|
const [inviteIdState, setInviteIdState] = useState(inviteId?.trim() || "");
|
|
const [invitePropertyIdState, setInvitePropertyIdState] = useState(
|
|
invitePropertyId?.trim() || "",
|
|
);
|
|
const [tenantName, setTenantName] = useState("");
|
|
const [tenantEmail, setTenantEmail] = useState(
|
|
inviteEmails?.length ? inviteEmails.join(", ") : "",
|
|
);
|
|
const [tenantPhone, setTenantPhone] = useState("");
|
|
const [availableOn, setAvailableOn] = useState(
|
|
formatDateInput(inviteAvailableOn),
|
|
);
|
|
const [selectedPropertyId, setSelectedPropertyId] = useState(
|
|
invitePropertyId?.trim()
|
|
? invitePropertyId
|
|
: properties.length === 1
|
|
? properties[0].id
|
|
: "",
|
|
);
|
|
const [generatedInvite, setGeneratedInvite] = useState<
|
|
GeneratedInvite | null
|
|
>(
|
|
null,
|
|
);
|
|
const [showErrors, setShowErrors] = useState(false);
|
|
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">(
|
|
"idle",
|
|
);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const templateRef = useRef<HTMLTextAreaElement>(null);
|
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
const emailInputRef = useRef<HTMLInputElement>(null);
|
|
const propertySelectRef = useRef<HTMLSelectElement>(null);
|
|
|
|
useEffect(() => {
|
|
setGeneratedInvite(null);
|
|
setCopyStatus("idle");
|
|
}, [tenantName, tenantEmail, tenantPhone, availableOn, selectedPropertyId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
invitePropertyIdState &&
|
|
selectedPropertyId &&
|
|
invitePropertyIdState !== selectedPropertyId
|
|
) {
|
|
setInviteIdState("");
|
|
setInvitePropertyIdState("");
|
|
}
|
|
}, [invitePropertyIdState, selectedPropertyId]);
|
|
|
|
const selectedProperty = properties.find((property) =>
|
|
property.id === selectedPropertyId
|
|
);
|
|
|
|
const parsedEmails = parseEmailList(tenantEmail);
|
|
const missingName = tenantName.trim().length === 0;
|
|
const missingEmail = parsedEmails.length === 0;
|
|
const missingProperty = selectedPropertyId.trim().length === 0 ||
|
|
!selectedProperty;
|
|
const canGenerate = Boolean(
|
|
!missingName && !missingEmail && !missingProperty && !isSaving,
|
|
);
|
|
|
|
const handleGenerate = async () => {
|
|
setShowErrors(true);
|
|
setSaveError(null);
|
|
if (missingName) {
|
|
nameInputRef.current?.focus();
|
|
return;
|
|
}
|
|
if (missingEmail) {
|
|
emailInputRef.current?.focus();
|
|
return;
|
|
}
|
|
if (missingProperty) {
|
|
propertySelectRef.current?.focus();
|
|
return;
|
|
}
|
|
if (!canGenerate || !selectedProperty) return;
|
|
setIsSaving(true);
|
|
const availableOnTimestamp = availableOn
|
|
? parseDateInput(availableOn)
|
|
: null;
|
|
let inviteIdForUrl = inviteIdState;
|
|
try {
|
|
const response = await fetch("/api/update/tenant_invite", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
invite_id: inviteIdState || undefined,
|
|
property_id: selectedPropertyId,
|
|
available_on: availableOnTimestamp,
|
|
invited_emails: parsedEmails,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Invite update failed");
|
|
}
|
|
const payload = await response.json().catch(() => null);
|
|
const returnedId = payload?.invite?.id;
|
|
if (returnedId) {
|
|
inviteIdForUrl = returnedId;
|
|
setInviteIdState(returnedId);
|
|
setInvitePropertyIdState(selectedPropertyId);
|
|
}
|
|
if (!inviteIdForUrl) {
|
|
throw new Error("Invite missing");
|
|
}
|
|
} catch (_error) {
|
|
setSaveError(
|
|
"Unable to save the invite details. Please try again.",
|
|
);
|
|
setIsSaving(false);
|
|
return;
|
|
}
|
|
setIsSaving(false);
|
|
const resolvedBaseUrl = typeof window === "undefined"
|
|
? ""
|
|
: globalThis.location.origin;
|
|
const inviteUrl = `${resolvedBaseUrl}/tenant/invite/${
|
|
encodeURIComponent(inviteIdForUrl)
|
|
}`;
|
|
const displayName = tenantName.trim();
|
|
const email = parsedEmails.join(", ");
|
|
const phone = tenantPhone.trim();
|
|
const senderName = landlordName?.trim() || "Your landlord";
|
|
const propertyLabel = buildPropertyLabel(selectedProperty);
|
|
const propertyAddress = buildPropertyAddress(selectedProperty);
|
|
const rent = formatCurrency(selectedProperty.monthly_rent);
|
|
const availableOnLine = availableOn ? `Available on: ${availableOn}` : "";
|
|
const subject = DEFAULT_SUBJECT;
|
|
|
|
const templateLines = fillTemplate(EMAIL_TEMPLATE_LINES, {
|
|
tenantEmail: email,
|
|
subject,
|
|
tenantName: displayName,
|
|
landlordName: senderName,
|
|
propertyLabel,
|
|
propertyAddressLine: propertyAddress ? `Rental: ${propertyAddress}` : "",
|
|
rentLine: rent ? `Monthly rent: ${rent}` : "",
|
|
availableOnLine,
|
|
inviteUrl,
|
|
phoneLine: phone
|
|
? `We have this phone number on file for you: ${phone}`
|
|
: "",
|
|
});
|
|
|
|
const template = templateLines.join("\n");
|
|
const body = templateLines.slice(3).join("\n");
|
|
|
|
setGeneratedInvite({
|
|
template,
|
|
recipient: email,
|
|
subject,
|
|
body,
|
|
inviteUrl,
|
|
});
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (!generatedInvite?.template) return;
|
|
try {
|
|
await navigator.clipboard.writeText(generatedInvite.template);
|
|
setCopyStatus("copied");
|
|
} catch (_error) {
|
|
const textArea = templateRef.current;
|
|
if (textArea) {
|
|
textArea.focus();
|
|
textArea.select();
|
|
const success = document.execCommand("copy");
|
|
setCopyStatus(success ? "copied" : "error");
|
|
} else {
|
|
setCopyStatus("error");
|
|
}
|
|
}
|
|
|
|
globalThis.setTimeout(() => setCopyStatus("idle"), 2000);
|
|
};
|
|
|
|
const mailtoHref = generatedInvite
|
|
? `mailto:${encodeURIComponent(generatedInvite.recipient)}?subject=${
|
|
encodeURIComponent(generatedInvite.subject)
|
|
}&body=${encodeURIComponent(generatedInvite.body)}`
|
|
: "";
|
|
|
|
const emptyTemplateMessage = DEFAULT_TEMPLATE_PREVIEW;
|
|
const hasTemplate = Boolean(generatedInvite?.template);
|
|
|
|
return (
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<div class="bg-white shadow rounded-lg p-6 space-y-6">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">
|
|
Invite Details
|
|
</h2>
|
|
<p class="text-sm text-gray-500">
|
|
Enter the tenant info and choose which rental to connect.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label
|
|
for="tenant_name"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Tenant name
|
|
</label>
|
|
<input
|
|
id="tenant_name"
|
|
ref={nameInputRef}
|
|
type="text"
|
|
value={tenantName}
|
|
onInput={(event) =>
|
|
setTenantName((event.currentTarget as HTMLInputElement).value)}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
placeholder="Avery Johnson"
|
|
autoComplete="name"
|
|
/>
|
|
{showErrors && missingName && (
|
|
<p class="mt-2 text-xs text-rose-600">
|
|
Tenant name is required.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="tenant_email"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Tenant email addresses
|
|
</label>
|
|
<input
|
|
id="tenant_email"
|
|
ref={emailInputRef}
|
|
type="email"
|
|
multiple
|
|
value={tenantEmail}
|
|
onInput={(event) =>
|
|
setTenantEmail((event.currentTarget as HTMLInputElement).value)}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
placeholder="tenant@example.com, roommate@example.com"
|
|
autoComplete="email"
|
|
/>
|
|
{showErrors && missingEmail && (
|
|
<p class="mt-2 text-xs text-rose-600">
|
|
At least one tenant email is required.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="available_on"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Available on date (optional)
|
|
</label>
|
|
<input
|
|
id="available_on"
|
|
type="date"
|
|
value={availableOn}
|
|
onInput={(event) =>
|
|
setAvailableOn(
|
|
(event.currentTarget as HTMLInputElement).value,
|
|
)}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="tenant_phone"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Tenant phone (optional)
|
|
</label>
|
|
<input
|
|
id="tenant_phone"
|
|
type="tel"
|
|
value={tenantPhone}
|
|
onInput={(event) =>
|
|
setTenantPhone((event.currentTarget as HTMLInputElement).value)}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
placeholder="(555) 555-1234"
|
|
autoComplete="tel"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="property_id"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>
|
|
Select a property
|
|
</label>
|
|
<select
|
|
id="property_id"
|
|
ref={propertySelectRef}
|
|
value={selectedPropertyId}
|
|
onChange={(event) =>
|
|
setSelectedPropertyId(
|
|
(event.currentTarget as HTMLSelectElement).value,
|
|
)}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
>
|
|
<option value="" disabled>
|
|
Choose a rental property
|
|
</option>
|
|
{properties.map((property) => (
|
|
<option key={property.id} value={property.id}>
|
|
{buildPropertyLabel(property)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{selectedProperty && (
|
|
<p class="mt-2 text-xs text-gray-500">
|
|
Selected: {buildPropertyLabel(selectedProperty)}
|
|
{selectedProperty.rental_status
|
|
? ` (${selectedProperty.rental_status})`
|
|
: ""}
|
|
</p>
|
|
)}
|
|
{showErrors && missingProperty && (
|
|
<p class="mt-2 text-xs text-rose-600">
|
|
Choose a property to link to the invite.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<button
|
|
type="button"
|
|
onClick={handleGenerate}
|
|
aria-disabled={!canGenerate}
|
|
disabled={isSaving}
|
|
class={`inline-flex items-center justify-center px-4 py-2 rounded-md text-sm font-medium transition ${
|
|
canGenerate
|
|
? "bg-emerald-600 text-white hover:bg-emerald-700"
|
|
: "bg-white border border-amber-300 text-amber-700 hover:bg-amber-50"
|
|
}`}
|
|
>
|
|
{isSaving ? "Saving Invite..." : "Generate Email Template"}
|
|
</button>
|
|
<p class="text-xs text-gray-500">
|
|
{canGenerate
|
|
? "Click generate to lock in the invite email."
|
|
: "Fill out the tenant and property details first."}
|
|
</p>
|
|
</div>
|
|
{saveError && (
|
|
<p class="text-xs text-rose-600">
|
|
{saveError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div class="bg-white shadow rounded-lg p-6 space-y-4">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">
|
|
Copyable Email Template
|
|
</h2>
|
|
<p class="text-sm text-gray-500">
|
|
Paste this directly into your email client.
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<a
|
|
href={mailtoHref}
|
|
aria-disabled={!hasTemplate}
|
|
class={`inline-flex items-center px-4 py-2 rounded-md text-sm font-medium ${
|
|
hasTemplate
|
|
? "bg-indigo-600 text-white hover:bg-indigo-700"
|
|
: "bg-gray-200 text-gray-400 pointer-events-none"
|
|
}`}
|
|
>
|
|
Open Email App
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
disabled={!hasTemplate}
|
|
class={`inline-flex items-center px-4 py-2 rounded-md text-sm font-medium ${
|
|
hasTemplate
|
|
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
: "bg-gray-200 text-gray-400 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
{copyStatus === "copied" ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<textarea
|
|
ref={templateRef}
|
|
readOnly
|
|
rows={14}
|
|
class="w-full rounded-md border border-gray-200 bg-gray-50 p-3 text-xs font-mono text-gray-700"
|
|
value={generatedInvite?.template || emptyTemplateMessage}
|
|
/>
|
|
<div class="text-xs text-gray-500">
|
|
{copyStatus === "error"
|
|
? "Unable to copy automatically. Select the text and copy manually."
|
|
: hasTemplate
|
|
? "The invite link opens the property preview so the tenant can start the lease."
|
|
: "Generate the template to personalize this draft and enable copy."}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|