integratedhub/routes/tenant/invite/[invite_id].tsx
2026-01-20 09:02:26 -06:00

491 lines
17 KiB
TypeScript

import { define } from "../../../utils.ts";
import { page } from "fresh";
import type { SessionCookie } from "../../auth/callback.ts";
import {
createTenantApplication,
hasUserAppliedForProperty,
} from "../../../services/tenantApplicationService.ts";
import {
markRoleOnboarded,
startGuestOnboarding,
} from "../../../services/onboardingService.ts";
import { getPropertyById } from "../../../services/propertyService.ts";
import { getPhotosByProperty } from "../../../services/propertyMediaService.ts";
import {
getTenantInviteById,
removeTenantInviteEmail,
} from "../../../services/tenantInviteService.ts";
import { getUserById } from "../../../services/userService.ts";
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
import type {
Property,
PropertyMedia,
TenantInvite,
User,
} from "../../../db/interfaces.ts";
type InvitePreviewData = {
invite: TenantInvite | null;
property: Property | null;
photos: PropertyMedia[];
user: User | null;
inviteeEmail: string;
status?: string;
error?: string;
};
function buildPropertyLabel(property: Property | null) {
return property?.title?.trim() || property?.address_1?.trim() ||
"Rental Property";
}
function buildPropertyAddress(property: Property | null) {
if (!property) return "";
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 "N/A";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatNumber(value?: number | null) {
if (value === null || value === undefined) return "N/A";
return new Intl.NumberFormat("en-US").format(value);
}
async function buildInvitePreview(
ctx: {
params: Record<string, string>;
req: Request;
state: { data: SessionCookie };
},
options: { error?: string } = {},
) {
const inviteId = decodeURIComponent(ctx.params.invite_id || "");
const url = new URL(ctx.req.url);
const status = url.searchParams.get("status") || undefined;
const stateData = ctx.state.data as SessionCookie;
const userId = stateData?.user_id;
const user = userId ? await getUserById(userId) : null;
const invite = inviteId ? await getTenantInviteById(inviteId) : null;
const property = invite?.property_id
? await getPropertyById(invite.property_id)
: null;
const photos = property?.id ? await getPhotosByProperty(property.id) : [];
const inviteeEmail = user?.email?.trim() || "";
return page({
invite,
property,
photos,
user,
inviteeEmail,
status,
error: options.error,
});
}
export const handler = define.handlers({
async GET(ctx) {
return await buildInvitePreview(ctx);
},
async POST(ctx) {
const inviteId = decodeURIComponent(ctx.params.invite_id || "");
const formData = await ctx.req.formData();
const intent = (formData.get("intent") || "").toString();
const stateData = ctx.state.data as SessionCookie;
const userId = stateData?.user_id;
const user = userId ? await getUserById(userId) : null;
const invite = inviteId ? await getTenantInviteById(inviteId) : null;
if (!invite) {
return await buildInvitePreview(ctx, {
error: "We could not find that invite.",
});
}
if (intent === "start") {
if (!userId) {
return new Response("", {
status: 302,
headers: { Location: "/auth/login" },
});
}
if (!invite.property_id) {
return await buildInvitePreview(ctx, {
error: "This invite is missing the property details.",
});
}
await startGuestOnboarding(userId, "tenant");
const alreadyApplied = await hasUserAppliedForProperty(
invite.property_id,
userId,
);
if (!alreadyApplied) {
const payload = JSON.stringify({
source: "invite",
invite_id: invite.id,
requested_at: Date.now(),
});
await createTenantApplication(invite.property_id, userId, payload);
}
await markRoleOnboarded(userId, "tenant");
return new Response("", {
status: 302,
headers: { Location: "/tenant/dashboard" },
});
}
if (intent === "decline") {
const inviteeEmail = (user?.email || formData.get("invitee_email") || "")
.toString()
.trim();
if (!inviteeEmail) {
return await buildInvitePreview(ctx, {
error: "Enter the email tied to the invite to decline it.",
});
}
const removal = await removeTenantInviteEmail(invite.id, inviteeEmail);
if (!removal.removed) {
return await buildInvitePreview(ctx, {
error: "We could not match that email to this invite.",
});
}
return new Response("", {
status: 302,
headers: {
Location: `/tenant/invite/${
encodeURIComponent(invite.id)
}?status=declined`,
},
});
}
return new Response("", {
status: 302,
headers: { Location: `/tenant/invite/${encodeURIComponent(inviteId)}` },
});
},
});
export default define.page(function TenantInvitePreview({ data }) {
const {
invite,
property,
photos,
user,
inviteeEmail,
status,
error,
} = data as InvitePreviewData;
if (!invite || !property) {
return (
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-6">
<div class="max-w-lg text-center bg-white shadow rounded-lg p-8">
<p class="text-sm uppercase tracking-[0.3em] text-gray-400">
Invite Preview
</p>
<h1 class="mt-4 text-2xl font-semibold text-gray-900">
{error || "We could not load this invite."}
</h1>
<p class="mt-3 text-sm text-gray-500">
Ask your landlord to resend the invite or verify the link.
</p>
<a
href="/"
class="mt-6 inline-flex items-center justify-center rounded-full bg-gray-900 px-4 py-2 text-sm font-semibold text-white hover:bg-gray-800"
>
Back to Home
</a>
</div>
</div>
);
}
const propertyLabel = buildPropertyLabel(property);
const propertyAddress = buildPropertyAddress(property);
const photoUrls = photos
.map((photo) => photo.file_url)
.filter((url): url is string => Boolean(url && url.trim().length > 0));
const fallbackPhoto = property.image_url?.trim() || "";
const displayedPhotos = photoUrls.length > 0
? photoUrls
: fallbackPhoto
? [fallbackPhoto]
: [];
const availableOn = invite.available_on
? new Date(invite.available_on).toLocaleDateString()
: null;
const cancelHref = user?.id ? "/tenant/dashboard" : "/";
const isLoggedIn = Boolean(user?.id);
return (
<div class="min-h-screen bg-gray-50">
<div class="max-w-6xl mx-auto px-6 py-10">
<header class="mb-8">
<p class="text-sm uppercase tracking-[0.4em] text-gray-400">
Tenant Invitation
</p>
<h1 class="mt-3 text-3xl font-semibold text-gray-900">
{propertyLabel}
</h1>
{propertyAddress && (
<p class="mt-2 text-sm text-gray-500">
{propertyAddress}
</p>
)}
{inviteeEmail && (
<p class="mt-4 inline-flex items-center rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
Invite sent to {inviteeEmail}
</p>
)}
{status === "declined" && (
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Invite declined. If this was a mistake, reach out to your landlord
for a fresh link.
</div>
)}
{error && (
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
</header>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<div class="bg-white shadow rounded-lg p-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">
Property Photos
</h2>
<span class="text-xs text-gray-500">
{displayedPhotos.length} photo
{displayedPhotos.length === 1 ? "" : "s"}
</span>
</div>
{displayedPhotos.length > 0
? (
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<img
src={displayedPhotos[0]}
alt={`${propertyLabel} photo`}
class="h-64 w-full rounded-lg object-cover sm:col-span-2"
/>
{displayedPhotos.slice(1, 3).map((photo, index) => (
<img
key={`${photo}-${index}`}
src={photo}
alt={`${propertyLabel} secondary photo ${index + 1}`}
class="h-40 w-full rounded-lg object-cover"
/>
))}
</div>
)
: (
<div class="mt-4 rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-12 text-center text-sm text-gray-500">
No photos yet. Your landlord can add them later.
</div>
)}
</div>
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">
Property Overview
</h2>
<p class="mt-2 text-sm text-gray-500">
{property.description || "No description has been added yet."}
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-xs uppercase tracking-[0.3em] text-gray-400">
Bedrooms
</p>
<p class="mt-2 text-lg font-semibold text-gray-900">
{formatNumber(property.bedrooms)}
</p>
</div>
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-xs uppercase tracking-[0.3em] text-gray-400">
Bathrooms
</p>
<p class="mt-2 text-lg font-semibold text-gray-900">
{formatNumber(property.bathrooms)}
</p>
</div>
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-xs uppercase tracking-[0.3em] text-gray-400">
Square Feet
</p>
<p class="mt-2 text-lg font-semibold text-gray-900">
{formatNumber(property.square_footage)}
</p>
</div>
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-xs uppercase tracking-[0.3em] text-gray-400">
Property Type
</p>
<p class="mt-2 text-lg font-semibold text-gray-900 capitalize">
{property.property_type || "N/A"}
</p>
</div>
</div>
{property.amenities && (
<div class="rounded-lg border border-gray-200 p-4">
<p class="text-xs uppercase tracking-[0.3em] text-gray-400">
Amenities
</p>
<p class="mt-2 text-sm text-gray-600">
{property.amenities}
</p>
</div>
)}
</div>
</div>
<aside class="space-y-6">
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900">
Lease Snapshot
</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<dt>Monthly rent</dt>
<dd class="font-semibold text-gray-900">
{formatCurrency(property.monthly_rent)}
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Security deposit</dt>
<dd class="font-semibold text-gray-900">
{formatCurrency(property.security_deposit)}
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Lease length</dt>
<dd class="font-semibold text-gray-900">
{property.lease_duration_months
? `${property.lease_duration_months} months`
: "N/A"}
</dd>
</div>
{availableOn && (
<div class="flex items-center justify-between">
<dt>Available on</dt>
<dd class="font-semibold text-gray-900">
{availableOn}
</dd>
</div>
)}
<div class="flex items-center justify-between">
<dt>Status</dt>
<dd class="font-semibold text-gray-900 capitalize">
{property.rental_status || "N/A"}
</dd>
</div>
</dl>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900">
What happens next
</h2>
<ul class="mt-4 space-y-2 text-sm text-gray-500">
<li>- Start the lease to submit your tenant application.</li>
<li>- Your landlord will review and respond.</li>
<li>- Track your status in the tenant portal.</li>
</ul>
</div>
</aside>
</div>
<div class="mt-8 bg-white shadow rounded-lg p-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">
Ready to proceed?
</h2>
<p class="text-sm text-gray-500">
Start your lease, decline the invite, or come back later.
</p>
</div>
<div class="flex flex-wrap gap-3">
{isLoggedIn
? (
<form method="POST">
<input type="hidden" name="intent" value="start" />
<button
type="submit"
class="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Start Lease
</button>
</form>
)
: (
<LoginPurposeButton
purpose="tenant"
lockRoute={`/tenant/invite/${
encodeURIComponent(invite.id)
}`}
className="inline-flex items-center justify-center rounded-full bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Sign In to Start Lease
</LoginPurposeButton>
)}
<form method="POST" class="flex items-center gap-2">
<input type="hidden" name="intent" value="decline" />
{!inviteeEmail && (
<input
type="email"
name="invitee_email"
aria-label="Invitee email"
placeholder="your@email.com"
class="w-48 rounded-full border border-gray-300 px-3 py-2 text-xs text-gray-700 focus:border-gray-400 focus:outline-none"
/>
)}
<button
type="submit"
class="inline-flex items-center justify-center rounded-full border border-rose-200 px-5 py-2 text-sm font-semibold text-rose-600 hover:border-rose-300 hover:text-rose-700"
>
Decline
</button>
</form>
<a
href={cancelHref}
class="inline-flex items-center justify-center rounded-full border border-gray-200 px-5 py-2 text-sm font-semibold text-gray-600 hover:border-gray-300 hover:text-gray-800"
>
Cancel
</a>
</div>
</div>
{!isLoggedIn && (
<p class="mt-3 text-xs text-gray-400">
Sign in to save your decision and view the full tenant portal.
</p>
)}
</div>
</div>
</div>
);
});