From cf28e5d9fc20fd9b5b9723a001ad654134579fc1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Oct 2025 20:27:17 +0000 Subject: [PATCH] onboarding wip --- src/app/db/schema/users.ts | 105 ++++++++++ src/app/onboarding/page.tsx | 253 +++++++++++++++++++++--- src/app/shadcn_components/ui/select.tsx | 38 ++-- 3 files changed, 350 insertions(+), 46 deletions(-) diff --git a/src/app/db/schema/users.ts b/src/app/db/schema/users.ts index ec57ceca..0232d77b 100644 --- a/src/app/db/schema/users.ts +++ b/src/app/db/schema/users.ts @@ -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; export type Account = InferModel; export type Session = InferModel; export type VerificationToken = InferModel; + +export type UserProfile = InferModel; +export type NewUserProfile = InferModel; diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 48f8c8ac..cb255c7b 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -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; 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({ + 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 ( -
-
-

Welcome!

-

- Let's complete your profile to get started. -

+ const { register, handleSubmit: submit, watch, setValue } = form; + const userType = watch("userType"); + const referralSource = watch("referralSource"); - setName(e.target.value)} - placeholder="Your full name" - required - /> - + return ( +
+ +

+ Step {step} of 3 +

+ + {step === 1 && ( +
+ + + {(userType === "private_landlord" || + userType === "social_landlord") && ( + + )} +
+ )} + + {step === 2 && ( +
+

+ What are your main goals? +

+ {[ + "access_funding", + "net_zero", + "improve_condition", + "save_money", + "other", + ].map((goal) => ( + + ))} + + + + {referralSource === "NRLA" && ( + + )} +
+ )} + + {step === 3 && ( +
+ + + + + + +
+ )} + +
+ {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
); diff --git a/src/app/shadcn_components/ui/select.tsx b/src/app/shadcn_components/ui/select.tsx index 16429d66..e25172ff 100644 --- a/src/app/shadcn_components/ui/select.tsx +++ b/src/app/shadcn_components/ui/select.tsx @@ -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, @@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef< -)) -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectContent = React.forwardRef< React.ElementRef, @@ -59,8 +59,8 @@ const SelectContent = React.forwardRef< -)) -SelectContent.displayName = SelectPrimitive.Content.displayName +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< React.ElementRef, @@ -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, @@ -94,8 +94,8 @@ const SelectItem = React.forwardRef< {children} -)) -SelectItem.displayName = SelectPrimitive.Item.displayName +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< React.ElementRef, @@ -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, -} +};