767 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { define } from "../../../utils.ts";
import { page } from "fresh";
import type { SessionCookie } from "../../auth/callback.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 { getLeaseById } from "../../../services/leaseService.ts";
import { createMessage } from "../../../services/messageService.ts";
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
import type {
Lease,
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;
lease?: Lease | null;
};
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() || "";
const lease = invite?.lease_id ? await getLeaseById(invite.lease_id) : null;
return page({
invite,
property,
photos,
user,
inviteeEmail,
status,
lease,
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 cookieHeader = ctx.req.headers.get("Cookie") ?? "";
const requestUrl = new URL(ctx.req.url);
const apiUrl = `${requestUrl.origin}/api/lease/request-approval`;
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": cookieHeader,
},
body: JSON.stringify({
invite_id: invite.id,
property_id: invite.property_id,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: "Failed to request lease approval",
}));
return await buildInvitePreview(ctx, {
error: errorData.error || "Failed to request lease approval",
});
}
const responseData = await response.json().catch(() => ({}));
const lease = responseData?.lease;
const leaseId = lease?.id ?? null;
if (invite.landlord_id && leaseId) {
await createMessage(
leaseId,
invite.landlord_id,
userId,
"notice",
"Lease documents – next steps",
"The landlord was notified of your lease request. A follow-up message will contain more information about your lease documents.",
null,
null,
);
}
await markRoleOnboarded(userId, "tenant");
return new Response("", {
status: 302,
headers: {
Location: `/tenant/invite/${
encodeURIComponent(invite.id)
}?status=pending`,
},
});
}
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,
lease,
error,
} = data as InvitePreviewData;
const approvalStatus = lease?.approval_status;
const isPending = approvalStatus === "pending_approval";
const isApproved = approvalStatus === "approved";
const isRejected = approvalStatus === "rejected";
const hasLease = Boolean(lease);
// Allow "Start Lease" when no lease or when previous request was rejected (re-invite same property)
const canStartLease = !hasLease || isRejected;
if (!invite || !property) {
return (
<div class="min-h-screen bg-gray-50 tenant-route 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);
const approvalSteps = [
{ label: "Request Lease", completed: hasLease && !isRejected },
{ label: "Landlord Review", completed: isApproved },
{ label: "Lease Active", completed: isApproved },
];
const getStepStatus = (stepIndex: number) => {
if (!hasLease) return stepIndex === 0 ? "current" : "pending";
if (isRejected) {
return stepIndex === 0
? "current"
: stepIndex === 1
? "error"
: "pending";
}
if (isPending) return stepIndex <= 1 ? "current" : "pending";
if (isApproved) return stepIndex <= 2 ? "current" : "completed";
return "pending";
};
return (
<div class="min-h-screen bg-gray-50 tenant-route">
<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">
Approval Status
</h2>
{hasLease && (
<div class="mt-4">
<div class="flex items-center space-x-2">
{approvalSteps.map((_step, index) => (
<div
key={index}
class={`flex items-center ${
index < approvalSteps.length - 1 ? "" : "hidden"
}`}
>
<div
class={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
getStepStatus(index) === "current"
? "bg-blue-600 text-white"
: getStepStatus(index) === "completed"
? "bg-emerald-600 text-white"
: getStepStatus(index) === "error"
? "bg-rose-600 text-white"
: "bg-gray-200 text-gray-500"
}`}
>
{index + 1}
</div>
{index < approvalSteps.length - 1 && (
<div
class={`h-1 w-12 ${
getStepStatus(index + 1) === "completed" ||
getStepStatus(index + 1) === "current"
? "bg-emerald-600"
: "bg-gray-200"
}`}
/>
)}
</div>
))}
</div>
<div class="mt-4">
{isPending && (
<div class="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
<div class="flex items-center">
<svg
class="h-5 w-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m0 0l-3 3m3 3V8m0 0l-3 3m3 3V8"
/>
</svg>
<span class="font-semibold">
Pending Landlord Approval
</span>
</div>
<p class="mt-2 text-amber-600">
Your landlord has been notified and will review your
lease request. You will receive an email once a
decision is made.
</p>
<p class="mt-3 text-amber-700 font-medium">
The landlord will send you the lease documents.
</p>
<p class="mt-2 text-sm text-amber-600">
A confirmation has been added to your{" "}
<a
href="/tenant/messages"
class="underline font-medium hover:text-amber-800"
>
Messages
</a>{" "}
– the landlord was notified and a follow-up message
will contain more information.
</p>
</div>
)}
{isApproved && (
<div class="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
<div class="flex items-center">
<svg
class="h-5 w-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span class="font-semibold">Lease Approved</span>
</div>
<p class="mt-2 text-emerald-600">
Congratulations! Your lease has been approved. You can
now access your tenant portal and view your lease
details.
</p>
</div>
)}
{isRejected && (
<div class="rounded-lg bg-rose-50 border border-rose-200 px-4 py-3 text-sm text-rose-700">
<div class="flex items-center">
<svg
class="h-5 w-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="font-semibold">Lease Rejected</span>
</div>
<p class="mt-2 text-rose-600">
Your landlord has declined this lease request. If you
have questions or would like to discuss this decision,
please contact your landlord directly.
</p>
{lease?.rejection_reason && (
<div class="mt-3 p-3 bg-white rounded border border-rose-200">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Reason for rejection
</p>
<p class="text-sm text-gray-700 mt-1">
{lease.rejection_reason}
</p>
</div>
)}
</div>
)}
</div>
</div>
)}
{canStartLease && (
<div class="mt-4">
<p class="text-sm text-gray-500">
Click "Start Lease" to request landlord approval for this
property.
</p>
</div>
)}
</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">
{canStartLease && (
<>
<li>- Click "Start Lease" to request landlord approval.</li>
<li>- Your landlord will review your request.</li>
<li>- Track your approval status on this page.</li>
</>
)}
{isPending && (
<>
<li>- Your landlord has been notified of your request.</li>
<li>- The landlord will send you the lease documents.</li>
<li>- Wait for landlord to review and approve/reject.</li>
<li>
- You will receive an email notification with a decision.
</li>
<li>
- Check your Messages for a confirmation and follow-up
details.
</li>
</>
)}
{isApproved && (
<>
<li>- Your lease has been approved and activated.</li>
<li>- Access your tenant portal to view lease details.</li>
<li>- Set up payment methods and start paying rent.</li>
</>
)}
{isRejected && (
<>
<li>- Your lease request was declined.</li>
<li>- Contact your landlord if you have questions.</li>
{canStartLease
? (
<li>
- You can submit a new request using the Start Lease
button above.
</li>
)
: (
<li>
- You can request a new invite from your landlord.
</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">
{canStartLease &&
"Start your lease request, decline the invite, or come back later."}
{isPending &&
"Your request is pending landlord review. Check back here for updates."}
{isApproved &&
"Your lease is approved! Continue to your tenant portal."}
{isRejected && !canStartLease &&
"Your lease request was declined. Contact your landlord for more information."}
</p>
</div>
<div class="flex flex-wrap gap-3">
{canStartLease && 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>
)}
{isApproved && isLoggedIn && (
<a
href="/tenant/dashboard"
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"
>
Continue to Tenant Portal
</a>
)}
{!isLoggedIn && (
<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>
);
});