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, ) { 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(null); const [isSaving, setIsSaving] = useState(false); const templateRef = useRef(null); const nameInputRef = useRef(null); const emailInputRef = useRef(null); const propertySelectRef = useRef(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 (

Invite Details

Enter the tenant info and choose which rental to connect.

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 && (

Tenant name is required.

)}
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 && (

At least one tenant email is required.

)}
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" />
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" />
{selectedProperty && (

Selected: {buildPropertyLabel(selectedProperty)} {selectedProperty.rental_status ? ` (${selectedProperty.rental_status})` : ""}

)} {showErrors && missingProperty && (

Choose a property to link to the invite.

)}

{canGenerate ? "Click generate to lock in the invite email." : "Fill out the tenant and property details first."}

{saveError && (

{saveError}

)}

Copyable Email Template

Paste this directly into your email client.

Open Email App