onboarding wip

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-13 20:27:17 +00:00
parent 2d4a11b94d
commit cf28e5d9fc
3 changed files with 350 additions and 46 deletions

View file

@ -7,6 +7,9 @@ import {
primaryKey,
integer,
boolean,
json,
pgEnum,
varchar,
} from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
@ -76,6 +79,105 @@ export const verificationTokens = pgTable(
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
export const UserType: [string, ...string[]] = [
"private_landlord",
"private_tenant",
"social_landlord",
"social_tenant",
"homeowner",
"other",
];
export const PropertyCount: [string, ...string[]] = [
// Private landlord options
"1",
"25",
"620",
"21+",
// Social landlord options
"150",
"51100",
"101300",
"3011000",
"1000+",
];
export const ReferralSource: [string, ...string[]] = [
"search",
"social_media",
"NRLA",
"partner",
"word_of_mouth",
"other",
];
export const Goal: [string, ...string[]] = [
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
];
export const userTypeEnum = pgEnum("user_type", UserType);
export const propertyCountEnum = pgEnum("property_count", PropertyCount);
export const referralSourceEnum = pgEnum("referral_source", ReferralSource);
export const goalEnum = pgEnum("goal", Goal);
// ----------------------------
// MAIN TABLE
// ----------------------------
export const userProfiles = pgTable("user_profiles", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
userId: bigint("user_id", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Profile
userType: userTypeEnum("user_type").notNull(),
propertyCount: propertyCountEnum("property_count"), // Nullable for homeowners / tenants
// Goals (multi-select)
goals: json("goals").$type<(typeof Goal)[number][]>(),
// Referral
referralSource: referralSourceEnum("referral_source"),
nrlaMembershipId: varchar("nrla_membership_id", { length: 255 }),
// Compliance
acceptedPrivacy: boolean("accepted_privacy").notNull().default(false),
acceptedPrivacyAt: timestamp("accepted_privacy_at", {
withTimezone: true,
precision: 6,
}),
// Marketing
marketingOptIn: boolean("marketing_opt_in").default(false),
marketingOptInAt: timestamp("marketing_opt_in_at", {
withTimezone: true,
precision: 6,
}),
// Basic user identity
firstName: text("first_name"),
lastName: text("last_name"),
// Metadata
createdAt: timestamp("created_at", {
precision: 6,
withTimezone: true,
})
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", {
precision: 6,
withTimezone: true,
})
.defaultNow()
.notNull(),
});
// -------------------------
// Types
// -------------------------
@ -85,3 +187,6 @@ export type NewUser = InferModel<typeof user, "insert">;
export type Account = InferModel<typeof accounts, "select">;
export type Session = InferModel<typeof sessions, "select">;
export type VerificationToken = InferModel<typeof verificationTokens, "select">;
export type UserProfile = InferModel<typeof userProfiles, "select">;
export type NewUserProfile = InferModel<typeof userProfiles, "insert">;

View file

@ -2,48 +2,247 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import {
Select,
SelectTrigger,
SelectItem,
SelectContent,
} from "@/app/shadcn_components/ui/select";
const OnboardingSchema = z.object({
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
userType: z.enum([
"private_landlord",
"private_tenant",
"social_landlord",
"social_tenant",
"homeowner",
"other",
]),
propertyCount: z
.enum([
"1",
"25",
"620",
"21+",
"150",
"51100",
"101300",
"3011000",
"1000+",
])
.nullable()
.optional(),
goals: z.array(
z.enum([
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
])
),
referralSource: z.enum([
"search",
"social_media",
"NRLA",
"partner",
"word_of_mouth",
"other",
]),
nrlaMembershipId: z.string().optional(),
acceptedPrivacy: z.boolean().refine((v) => v === true, {
message: "You must accept the privacy policy",
}),
marketingOptIn: z.boolean().optional(),
});
type OnboardingData = z.infer<typeof OnboardingSchema>;
export default function OnboardingPage() {
const router = useRouter();
const [name, setName] = useState("");
const [step, setStep] = useState(1);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const form = useForm<OnboardingData>({
resolver: zodResolver(OnboardingSchema),
defaultValues: {
goals: [],
marketingOptIn: false,
acceptedPrivacy: false,
},
});
async function handleSubmit(data: OnboardingData) {
await fetch("/api/user/onboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify(data),
});
router.push("/home");
}
return (
<div className="flex h-screen items-center justify-center bg-gray-50">
<form
onSubmit={handleSubmit}
className="p-8 bg-white rounded-xl shadow-md w-full max-w-md space-y-4"
>
<h1 className="text-xl font-semibold text-brandblue">Welcome!</h1>
<p className="text-gray-600">
Let's complete your profile to get started.
</p>
const { register, handleSubmit: submit, watch, setValue } = form;
const userType = watch("userType");
const referralSource = watch("referralSource");
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your full name"
required
/>
<Button
type="submit"
className="w-full bg-brandblue hover:bg-hoverblue"
>
Continue
</Button>
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<form
onSubmit={submit(handleSubmit)}
className="bg-white rounded-xl shadow-md p-8 w-full max-w-lg space-y-6"
>
<h1 className="text-2xl font-semibold text-brandblue">
Step {step} of 3
</h1>
{step === 1 && (
<div className="space-y-4">
<Select {...register("userType")}>
<SelectTrigger>Select your role</SelectTrigger>
<SelectContent>
<SelectItem value="private_landlord">
Private landlord
</SelectItem>
<SelectItem value="private_tenant">Private tenant</SelectItem>
<SelectItem value="social_landlord">Social landlord</SelectItem>
<SelectItem value="social_tenant">Social tenant</SelectItem>
<SelectItem value="homeowner">Homeowner</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
{(userType === "private_landlord" ||
userType === "social_landlord") && (
<Select {...register("propertyCount")}>
<SelectTrigger>
How many properties do you manage?
</SelectTrigger>
<SelectContent>
{userType === "private_landlord" ? (
<>
<SelectItem value="1">1</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="620">620</SelectItem>
<SelectItem value="21+">21+</SelectItem>
</>
) : (
<>
<SelectItem value="150">150</SelectItem>
<SelectItem value="51100">51100</SelectItem>
<SelectItem value="101300">101300</SelectItem>
<SelectItem value="3011000">3011000</SelectItem>
<SelectItem value="1000+">1000+</SelectItem>
</>
)}
</SelectContent>
</Select>
)}
</div>
)}
{step === 2 && (
<div className="space-y-4">
<p className="font-medium text-gray-700">
What are your main goals?
</p>
{[
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
].map((goal) => (
<label key={goal} className="flex items-center space-x-2">
<Checkbox
checked={watch("goals")?.includes(goal)}
onCheckedChange={(checked) => {
const goals = watch("goals") ?? [];
setValue(
"goals",
checked
? [...goals, goal]
: goals.filter((g) => g !== goal)
);
}}
/>
<span className="capitalize">{goal.replace("_", " ")}</span>
</label>
))}
<Select {...register("referralSource")}>
<SelectTrigger>How did you hear about us?</SelectTrigger>
<SelectContent>
<SelectItem value="search">Search</SelectItem>
<SelectItem value="social_media">Social Media</SelectItem>
<SelectItem value="NRLA">NRLA</SelectItem>
<SelectItem value="partner">Partner</SelectItem>
<SelectItem value="word_of_mouth">Word of mouth</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
{referralSource === "NRLA" && (
<Input
{...register("nrlaMembershipId")}
placeholder="Enter your NRLA Membership ID"
/>
)}
</div>
)}
{step === 3 && (
<div className="space-y-4">
<Input
{...register("firstName")}
placeholder="First name"
required
/>
<Input {...register("lastName")} placeholder="Last name" required />
<label className="flex items-center space-x-2">
<Checkbox {...register("acceptedPrivacy")} />
<span>
I agree to the{" "}
<a href="/privacy" className="text-brandblue underline">
Privacy Policy
</a>
</span>
</label>
<label className="flex items-center space-x-2">
<Checkbox {...register("marketingOptIn")} />
<span>I'd like to receive marketing updates</span>
</label>
</div>
)}
<div className="flex justify-between pt-4">
{step > 1 && (
<Button
type="button"
variant="outline"
onClick={() => setStep(step - 1)}
>
Back
</Button>
)}
{step < 3 ? (
<Button type="button" onClick={() => setStep(step + 1)}>
Next
</Button>
) : (
<Button type="submit" className="bg-brandblue hover:bg-hoverblue">
Finish
</Button>
)}
</div>
</form>
</div>
);

View file

@ -1,16 +1,16 @@
"use client"
"use client";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@ -59,8 +59,8 @@ const SelectContent = React.forwardRef<
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@ -71,8 +71,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@ -94,8 +94,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@ -106,8 +106,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@ -118,4 +118,4 @@ export {
SelectLabel,
SelectItem,
SelectSeparator,
}
};