491 lines
17 KiB
TypeScript
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>
|
|
);
|
|
});
|