integratedhub/routes/tenant/invite/[invite_id].tsx

688 lines
26 KiB
TypeScript

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 LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
import type {
Property,
PropertyMedia,
TenantInvite,
User,
Lease,
} 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 response = await fetch(`http://${ctx.req.headers.get("host")}/api/lease/request-approval`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": ctx.req.headers.get("cookie") || "",
},
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",
});
}
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);
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);
const approvalSteps = [
{ label: "Request Lease", completed: hasLease },
{ label: "Landlord Review", completed: isApproved },
{ label: "Lease Active", completed: isApproved },
];
const getStepStatus = (stepIndex: number) => {
if (!hasLease) return stepIndex === 0 ? "current" : "pending";
if (isPending) return stepIndex <= 1 ? "current" : "pending";
if (isApproved) return stepIndex <= 2 ? "current" : "completed";
if (isRejected) return stepIndex === 1 ? "error" : "completed";
return "pending";
};
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">
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>
</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>
)}
{!hasLease && (
<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">
{!hasLease && (
<>
<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>- Wait for landlord to review and approve/reject.</li>
<li>- You will receive an email notification with a decision.</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>
<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">
{!hasLease && "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 && "Your lease request was declined. Contact your landlord for more information."}
</p>
</div>
<div class="flex flex-wrap gap-3">
{!hasLease && 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>
);
});