mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
onboarding wip
This commit is contained in:
parent
2d4a11b94d
commit
cf28e5d9fc
3 changed files with 350 additions and 46 deletions
|
|
@ -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",
|
||||
"2–5",
|
||||
"6–20",
|
||||
"21+",
|
||||
// Social landlord options
|
||||
"1–50",
|
||||
"51–100",
|
||||
"101–300",
|
||||
"301–1000",
|
||||
"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">;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"2–5",
|
||||
"6–20",
|
||||
"21+",
|
||||
"1–50",
|
||||
"51–100",
|
||||
"101–300",
|
||||
"301–1000",
|
||||
"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="2–5">2–5</SelectItem>
|
||||
<SelectItem value="6–20">6–20</SelectItem>
|
||||
<SelectItem value="21+">21+</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="1–50">1–50</SelectItem>
|
||||
<SelectItem value="51–100">51–100</SelectItem>
|
||||
<SelectItem value="101–300">101–300</SelectItem>
|
||||
<SelectItem value="301–1000">301–1000</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue