integratedhub/islands/LandlordInviteTenant.tsx
2026-01-20 09:02:26 -06:00

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"
? ""
: window.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");
}
}
window.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>
);
}