2026-01-20 09:02:26 -06:00

506 lines
19 KiB
TypeScript

// routes/onboarding/index.tsx
import { define } from "../../utils.ts";
import { page } from "fresh";
import type { SessionCookie } from "../auth/callback.ts";
import type { Property } from "../../db/interfaces.ts";
import OnboardingHashGate from "../../islands/OnboardingHashGate.tsx";
import LoginPurposeButton from "../../islands/LoginPurposeButton.tsx";
import {
getOnboardingStatus,
markRoleOnboarded,
startGuestOnboarding,
} from "../../services/onboardingService.ts";
import {
getAllProperties,
getPropertyById,
} from "../../services/propertyService.ts";
import { getTenantInviteById } from "../../services/tenantInviteService.ts";
import {
createTenantApplication,
hasUserAppliedForProperty,
} from "../../services/tenantApplicationService.ts";
interface OnboardingPageData {
stateData: SessionCookie;
properties: Property[];
onboardingStatus: Awaited<ReturnType<typeof getOnboardingStatus>>;
error?: string;
selectedPropertyId?: string;
inviteId?: string;
}
async function buildOnboardingPage(
ctx: { state: { data: SessionCookie } },
options: { error?: string; selectedPropertyId?: string; inviteId?: string } =
{},
) {
const stateData = ctx.state.data as SessionCookie;
const userId = stateData?.user_id;
let properties: Property[] = [];
if (userId) {
properties = await getAllProperties();
} else if (options.selectedPropertyId) {
const selectedProperty = await getPropertyById(
options.selectedPropertyId,
);
if (selectedProperty) {
properties = [selectedProperty];
}
}
const onboardingStatus = userId ? await getOnboardingStatus(userId) : null;
return page({
stateData,
properties,
onboardingStatus,
error: options.error,
selectedPropertyId: options.selectedPropertyId,
inviteId: options.inviteId,
});
}
export const handler = define.handlers({
async GET(ctx) {
const url = new URL(ctx.req.url);
const inviteId = url.searchParams.get("invite_id") || undefined;
let selectedPropertyId = url.searchParams.get("property_id") || undefined;
let error: string | undefined;
if (inviteId) {
const invite = await getTenantInviteById(inviteId);
if (!invite) {
error =
"We could not find that invite. Please ask your landlord to resend it.";
} else if (invite.property_id) {
selectedPropertyId = invite.property_id;
}
}
return await buildOnboardingPage(ctx, {
selectedPropertyId,
error,
inviteId,
});
},
async POST(ctx) {
const stateData = ctx.state.data as SessionCookie;
const { user_id } = stateData;
if (!user_id) {
return new Response("", {
status: 302,
headers: { Location: "/auth/login" },
});
}
const formData = await ctx.req.formData();
const sender = (formData.get("sender") || "").toString();
if (sender === "landlord_start") {
const status = await getOnboardingStatus(user_id);
if (status?.onboarding_step && status.onboarding_step >= 2) {
return new Response("", {
status: 302,
headers: { Location: "/landlord/onboarding/property-setup" },
});
}
if (status?.onboarding_step && status.onboarding_step > 0) {
return new Response("", {
status: 302,
headers: { Location: "/landlord/onboarding/profile" },
});
}
if (status?.onboarded_landlord) {
return new Response("", {
status: 302,
headers: { Location: "/landlord/properties/add" },
});
}
const result = await startGuestOnboarding(user_id, "landlord");
return new Response("", {
status: 302,
headers: { Location: result.nextUrl },
});
}
if (sender === "tenant_start") {
const propertyId = (formData.get("property_id") || "").toString();
if (!propertyId) {
return await buildOnboardingPage(ctx, {
error: "Select a property to continue your tenant onboarding.",
});
}
await startGuestOnboarding(user_id, "tenant");
const alreadyApplied = await hasUserAppliedForProperty(
propertyId,
user_id,
);
if (!alreadyApplied) {
const payload = JSON.stringify({
source: "onboarding",
requested_at: Date.now(),
});
await createTenantApplication(propertyId, user_id, payload);
}
await markRoleOnboarded(user_id, "tenant");
return new Response("", {
status: 302,
headers: { Location: "/tenant/dashboard" },
});
}
return new Response("", { status: 302, headers: { Location: "/" } });
},
});
export default define.page(function OnboardingPage({ data }) {
const {
stateData,
properties,
onboardingStatus,
error,
selectedPropertyId,
inviteId,
} = data as OnboardingPageData;
const isLoggedIn = Boolean(stateData?.user_id);
const isInviteLink = Boolean(inviteId);
const tenantOnboarded = Boolean(onboardingStatus?.onboarded_tenant);
const landlordStep = onboardingStatus?.onboarding_step ?? 0;
const landlordReady = Boolean(onboardingStatus?.onboarded_landlord) &&
landlordStep === 0;
const landlordInProgress = landlordStep > 0;
const selectedProperty = selectedPropertyId
? properties.find((property) => property.id === selectedPropertyId)
: undefined;
const showPropertySelect = isLoggedIn && !isInviteLink;
const availableProperties = properties.filter((property) =>
property.rental_status === "available"
);
const inviteRoute = inviteId
? `/onboarding?invite_id=${inviteId}#tenant`
: null;
let tenantOptions = showPropertySelect
? (availableProperties.length > 0 ? availableProperties : properties)
: [];
if (
showPropertySelect &&
selectedProperty &&
!tenantOptions.some((property) => property.id === selectedProperty.id)
) {
tenantOptions = [selectedProperty, ...tenantOptions];
}
const selectedPropertyLabel = selectedProperty
? (selectedProperty.title || selectedProperty.address_1 || "Property")
: "";
const selectedPropertyAddress = selectedProperty
? [
selectedProperty.address_1,
selectedProperty.address_2,
selectedProperty.city,
selectedProperty.state,
selectedProperty.zip,
].filter(Boolean).join(", ")
: "";
return (
<div class="min-h-screen bg-slate-950 text-white">
<OnboardingHashGate />
<div class="mx-auto max-w-6xl px-6 py-16">
<header class="mb-12">
<p class="text-sm uppercase tracking-[0.4em] text-cyan-200">
Real Estate Onboarding
</p>
<h1 class="mt-4 text-4xl md:text-5xl font-semibold text-transparent bg-clip-text bg-linear-to-r from-emerald-200 via-cyan-200 to-sky-200">
Choose your property path.
</h1>
<p class="mt-4 text-base md:text-lg text-slate-300 max-w-2xl">
Start a new listing as a landlord, or join a property as a tenant.
You can switch between roles anytime and manage multiple properties
from the same account.
</p>
{!isLoggedIn && (
<div class="mt-6 rounded-xl border border-slate-700/60 bg-slate-900/60 p-4 text-sm text-slate-200">
<p>
Sign in to begin onboarding. Use the home page to log in and
return here with your role pre-selected.
</p>
</div>
)}
</header>
{error && (
<div class="mb-8 rounded-xl border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
)}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<section
id="landlord"
class="relative overflow-hidden rounded-3xl border border-emerald-500/30 bg-linear-to-br from-emerald-950/60 via-slate-950/80 to-cyan-950/60 p-8"
>
<div class="absolute -top-16 -right-12 h-48 w-48 rounded-full bg-emerald-400/10 blur-3xl">
</div>
<div class="relative z-10">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold text-emerald-100">
Landlord Setup
</h2>
<p class="mt-2 text-sm text-slate-300">
Launch a new property, define rent schedules, and invite
tenants.
</p>
</div>
<span
class={`text-xs uppercase tracking-[0.3em] px-3 py-1 rounded-full border ${
landlordReady
? "border-emerald-400/40 text-emerald-100 bg-emerald-500/10"
: landlordInProgress
? "border-amber-400/40 text-amber-100 bg-amber-500/10"
: "border-slate-500/40 text-slate-200 bg-slate-700/20"
}`}
>
{landlordReady
? "Ready"
: landlordInProgress
? "In Progress"
: "New"}
</span>
</div>
<ul class="mt-6 space-y-2 text-sm text-slate-200">
<li>- Capture property details and lease terms.</li>
<li>- Track applicants, maintenance, and payments.</li>
<li>- Add as many properties as you manage.</li>
</ul>
<div class="mt-8 flex flex-col sm:flex-row gap-3">
{isLoggedIn
? (
<form method="POST">
<input
type="hidden"
name="sender"
value="landlord_start"
/>
<button
type="submit"
class="w-full sm:w-auto rounded-full bg-emerald-500/80 px-6 py-2 text-sm font-semibold text-white hover:bg-emerald-400/90 transition"
>
{landlordReady
? "Add Another Property"
: landlordInProgress
? "Continue Landlord Setup"
: "Start Landlord Setup"}
</button>
</form>
)
: (
<LoginPurposeButton
purpose="landlord"
className="inline-flex items-center justify-center rounded-full bg-emerald-500/70 px-6 py-2 text-sm font-semibold text-white hover:bg-emerald-400/90 transition"
>
Sign In to Begin
</LoginPurposeButton>
)}
{landlordReady && (
<a
href="/landlord/dashboard"
class="inline-flex items-center justify-center rounded-full border border-emerald-400/40 px-6 py-2 text-sm font-semibold text-emerald-100 hover:border-emerald-300/80 hover:text-white transition"
>
Open Landlord Dashboard
</a>
)}
</div>
</div>
</section>
<section
id="tenant"
class="relative overflow-hidden rounded-3xl border border-cyan-500/30 bg-linear-to-br from-cyan-950/60 via-slate-950/80 to-sky-950/60 p-8"
>
<div class="absolute -bottom-20 -left-10 h-48 w-48 rounded-full bg-cyan-400/10 blur-3xl">
</div>
<div class="relative z-10">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold text-cyan-100">
Tenant Setup
</h2>
<p class="mt-2 text-sm text-slate-300">
Apply to a property and unlock rent payments, messaging, and
service requests.
</p>
</div>
<span
class={`text-xs uppercase tracking-[0.3em] px-3 py-1 rounded-full border ${
tenantOnboarded
? "border-cyan-400/40 text-cyan-100 bg-cyan-500/10"
: "border-slate-500/40 text-slate-200 bg-slate-700/20"
}`}
>
{tenantOnboarded ? "Active" : "New"}
</span>
</div>
<ul class="mt-6 space-y-2 text-sm text-slate-200">
<li>- Choose a property to apply in seconds.</li>
<li>- Keep rent, docs, and maintenance in one place.</li>
<li>- Track your application status in the tenant portal.</li>
</ul>
{selectedProperty && (
<div class="mt-6 rounded-2xl border border-cyan-400/30 bg-slate-950/70 p-4 text-sm text-slate-200">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200">
Invite selected rental
</p>
<p class="mt-2 text-base font-semibold text-white">
{selectedPropertyLabel}
</p>
{selectedPropertyAddress && (
<p class="mt-1 text-xs text-slate-400">
{selectedPropertyAddress}
</p>
)}
</div>
)}
{selectedPropertyId && !selectedProperty && (
<div class="mt-6 rounded-2xl border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
We could not find that property. Ask your landlord to resend
the invite link.
</div>
)}
{isLoggedIn
? (
<form method="POST" class="mt-6 space-y-4">
<input type="hidden" name="sender" value="tenant_start" />
{isInviteLink
? (
<div class="rounded-2xl border border-cyan-400/30 bg-slate-950/70 p-4 text-sm text-slate-200">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200">
Invited property
</p>
{selectedProperty
? (
<>
<input
type="hidden"
name="property_id"
value={selectedPropertyId}
/>
<p class="mt-2 text-base font-semibold text-white">
{selectedPropertyLabel}
</p>
{selectedPropertyAddress && (
<p class="mt-1 text-xs text-slate-400">
{selectedPropertyAddress}
</p>
)}
</>
)
: (
<p class="mt-2 text-sm text-rose-100">
We could not find the property on this invite.
</p>
)}
</div>
)
: (
<div>
<label
for="property_id"
class="block text-xs uppercase tracking-[0.3em] text-cyan-200"
>
Select a property
</label>
<select
id="property_id"
name="property_id"
required={tenantOptions.length > 0}
class="mt-2 w-full rounded-xl border border-cyan-400/30 bg-slate-950/70 px-4 py-3 text-sm text-white focus:outline-none focus:ring-2 focus:ring-cyan-400/60"
>
<option
value=""
selected={!selectedPropertyId}
disabled={tenantOptions.length > 0}
>
Choose a property
</option>
{tenantOptions.map((property) => (
<option
key={property.id}
value={property.id}
selected={selectedPropertyId === property.id}
>
{property.title || property.address_1 ||
"Property"}
{property.city ? `, ${property.city}` : ""}
{property.state ? `, ${property.state}` : ""}
{property.rental_status
? ` (${property.rental_status})`
: ""}
</option>
))}
</select>
{tenantOptions.length === 0 && (
<p class="mt-2 text-xs text-slate-400">
No properties are listed yet. Check back soon or
ask your landlord to add one.
</p>
)}
</div>
)}
<button
type="submit"
disabled={isInviteLink && !selectedPropertyId}
class="w-full rounded-full bg-cyan-500/80 px-6 py-2 text-sm font-semibold text-white hover:bg-cyan-400/90 transition"
>
{tenantOnboarded
? "Apply to Another Property"
: "Start Tenant Setup"}
</button>
{tenantOnboarded && (
<a
href="/tenant/dashboard"
class="inline-flex w-full items-center justify-center rounded-full border border-cyan-400/40 px-6 py-2 text-sm font-semibold text-cyan-100 hover:border-cyan-300/80 hover:text-white transition"
>
Open Tenant Dashboard
</a>
)}
</form>
)
: (
<div class="mt-6">
<LoginPurposeButton
purpose="tenant"
lockRoute={inviteRoute}
className="inline-flex items-center justify-center rounded-full bg-cyan-500/70 px-6 py-2 text-sm font-semibold text-white hover:bg-cyan-400/90 transition"
>
Sign In to Apply
</LoginPurposeButton>
</div>
)}
</div>
</section>
</div>
</div>
</div>
);
});