506 lines
19 KiB
TypeScript
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>
|
|
);
|
|
});
|