688 lines
26 KiB
TypeScript
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>
|
|
);
|
|
});
|