mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
onboarding working
This commit is contained in:
parent
4014791306
commit
a264686552
14 changed files with 4097 additions and 42 deletions
BIN
public/images/Alexandra-Road-Park.webp
Normal file
BIN
public/images/Alexandra-Road-Park.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 475 KiB |
|
|
@ -147,14 +147,24 @@ export const AuthOptions: NextAuthOptions = {
|
|||
/**
|
||||
* Persist dbId in the JWT so it’s available in sessions
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
if (user?.dbId) {
|
||||
token.dbId = user.dbId;
|
||||
async jwt({ token, user: userFromLogin }) {
|
||||
// Initial sign-in: attach DB fields
|
||||
if (userFromLogin) {
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, userFromLogin.email!),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
} else if (token.email) {
|
||||
// On subsequent calls, keep token synced from DB
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, token.email),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch onboarding status from user
|
||||
if (user?.onboarded !== undefined) token.onboarded = user.onboarded;
|
||||
|
||||
return token;
|
||||
},
|
||||
|
||||
|
|
|
|||
57
src/app/api/user/onboarded/route.ts
Normal file
57
src/app/api/user/onboarded/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/app/db/db";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
const OnboardedSchema = z.object({
|
||||
onboarded: z.boolean(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return new NextResponse(JSON.stringify({ msg: "Unauthenticated" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { onboarded } = OnboardedSchema.parse(body);
|
||||
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, session.user.email),
|
||||
});
|
||||
if (!existingUser) {
|
||||
return new NextResponse(JSON.stringify({ msg: "User not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({ onboarded, updatedAt: new Date() })
|
||||
.where(eq(user.id, existingUser.id));
|
||||
|
||||
return new NextResponse(
|
||||
JSON.stringify({ msg: "User marked as onboarded" }),
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error updating onboarded flag:", err);
|
||||
if (err instanceof z.ZodError) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ msg: "Invalid input", errors: err.flatten() }),
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
return new NextResponse(JSON.stringify({ msg: "Server error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
143
src/app/api/user/profile/route.ts
Normal file
143
src/app/api/user/profile/route.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/app/db/db";
|
||||
import { userProfiles, user } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
const UserProfileSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
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+",
|
||||
])
|
||||
.optional(),
|
||||
goals: z
|
||||
.array(
|
||||
z.enum([
|
||||
"access_funding",
|
||||
"net_zero",
|
||||
"improve_condition",
|
||||
"save_money",
|
||||
"other",
|
||||
])
|
||||
)
|
||||
.min(1),
|
||||
referralSource: z.enum([
|
||||
"search",
|
||||
"social_media",
|
||||
"NRLA",
|
||||
"partner",
|
||||
"word_of_mouth",
|
||||
"other",
|
||||
]),
|
||||
nrlaMembershipId: z.string().optional(),
|
||||
marketingOptIn: z.boolean().optional(),
|
||||
acceptedPrivacy: z.boolean().refine((v) => v === true, {
|
||||
message: "You must accept our privacy policy to continue",
|
||||
}),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return new NextResponse(JSON.stringify({ msg: "Unauthenticated" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = UserProfileSchema.parse(body);
|
||||
|
||||
// 1️⃣ Get user from DB
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, session.user.email),
|
||||
});
|
||||
if (!existingUser) {
|
||||
return new NextResponse(JSON.stringify({ msg: "User not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// 2️⃣ Check if profile already exists
|
||||
const existingProfile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, existingUser.id),
|
||||
});
|
||||
|
||||
// Timestamps for policy and marketing
|
||||
const now = new Date();
|
||||
const acceptedPrivacyAt = parsed.acceptedPrivacy ? now : null;
|
||||
const marketingOptInAt = parsed.marketingOptIn ? now : null;
|
||||
|
||||
if (existingProfile) {
|
||||
await db
|
||||
.update(userProfiles)
|
||||
.set({
|
||||
firstName: parsed.firstName,
|
||||
lastName: parsed.lastName,
|
||||
userType: parsed.userType,
|
||||
propertyCount: parsed.propertyCount,
|
||||
goals: parsed.goals,
|
||||
referralSource: parsed.referralSource,
|
||||
nrlaMembershipId: parsed.nrlaMembershipId,
|
||||
marketingOptIn: parsed.marketingOptIn ?? false,
|
||||
acceptedPrivacy: parsed.acceptedPrivacy,
|
||||
acceptedPrivacyAt,
|
||||
marketingOptInAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userProfiles.userId, existingUser.id));
|
||||
} else {
|
||||
await db.insert(userProfiles).values({
|
||||
userId: existingUser.id,
|
||||
firstName: parsed.firstName,
|
||||
lastName: parsed.lastName,
|
||||
userType: parsed.userType,
|
||||
propertyCount: parsed.propertyCount,
|
||||
goals: parsed.goals,
|
||||
referralSource: parsed.referralSource,
|
||||
nrlaMembershipId: parsed.nrlaMembershipId,
|
||||
marketingOptIn: parsed.marketingOptIn ?? false,
|
||||
acceptedPrivacy: parsed.acceptedPrivacy,
|
||||
acceptedPrivacyAt,
|
||||
marketingOptInAt,
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(JSON.stringify({ msg: "Profile saved" }), {
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error saving user profile:", err);
|
||||
if (err instanceof z.ZodError) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ msg: "Invalid input", errors: err.flatten() }),
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
return new NextResponse(JSON.stringify({ msg: "Server error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import * as solarSchema from "@/app/db/schema/solar";
|
|||
import * as EnergyAssessmentsSchema from "@/app/db/schema/energy_assessments";
|
||||
import * as FundingSchema from "@/app/db/schema/funding";
|
||||
import * as Relations from "@/app/db/schema/relations";
|
||||
import * as Users from "@/app/db/schema/users";
|
||||
|
||||
export const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
|
|
@ -29,6 +30,7 @@ const schema = {
|
|||
...Relations,
|
||||
...EnergyAssessmentsSchema,
|
||||
...FundingSchema,
|
||||
...Users,
|
||||
};
|
||||
|
||||
export const db = drizzle(pool, {
|
||||
|
|
|
|||
22
src/app/db/migrations/0118_lazy_gabe_jones.sql
Normal file
22
src/app/db/migrations/0118_lazy_gabe_jones.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
CREATE TYPE "public"."user_profiles_property_count" AS ENUM('1', '2–5', '6–20', '21+', '1–50', '51–100', '101–300', '301–1000', '1000+');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_profiles_referral_source" AS ENUM('search', 'social_media', 'NRLA', 'partner', 'word_of_mouth', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_profiles_user_type" AS ENUM('private_landlord', 'private_tenant', 'social_landlord', 'social_tenant', 'homeowner', 'other');--> statement-breakpoint
|
||||
CREATE TABLE "user_profiles" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"user_type" "user_profiles_user_type" NOT NULL,
|
||||
"property_count" "user_profiles_property_count",
|
||||
"goals" json,
|
||||
"referral_source" "user_profiles_referral_source",
|
||||
"nrla_membership_id" varchar(255),
|
||||
"accepted_privacy" boolean DEFAULT false NOT NULL,
|
||||
"accepted_privacy_at" timestamp (6) with time zone,
|
||||
"marketing_opt_in" boolean DEFAULT false,
|
||||
"marketing_opt_in_at" timestamp (6) with time zone,
|
||||
"first_name" text,
|
||||
"last_name" text,
|
||||
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
3750
src/app/db/migrations/meta/0118_snapshot.json
Normal file
3750
src/app/db/migrations/meta/0118_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -827,6 +827,13 @@
|
|||
"when": 1760191704756,
|
||||
"tag": "0117_colossal_bastion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 118,
|
||||
"version": "7",
|
||||
"when": 1760552402393,
|
||||
"tag": "0118_lazy_gabe_jones",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -119,10 +119,15 @@ export const Goal: [string, ...string[]] = [
|
|||
"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);
|
||||
export const userTypeEnum = pgEnum("user_profiles_user_type", UserType);
|
||||
export const propertyCountEnum = pgEnum(
|
||||
"user_profiles_property_count",
|
||||
PropertyCount
|
||||
);
|
||||
export const referralSourceEnum = pgEnum(
|
||||
"user_profiles_referral_source",
|
||||
ReferralSource
|
||||
);
|
||||
|
||||
// ----------------------------
|
||||
// MAIN TABLE
|
||||
|
|
|
|||
7
src/app/email_templates/magic_link.ts
Normal file
7
src/app/email_templates/magic_link.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Contains the email template for user sign in via magic links. A user will be asked to
|
||||
// click a verification email to sign in to the app, should they choose to sign in with magic
|
||||
// links
|
||||
|
||||
export async function MagicLinksEmail() {
|
||||
return null;
|
||||
}
|
||||
45
src/app/onboarding/OnboardUserHook.ts
Normal file
45
src/app/onboarding/OnboardUserHook.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface OnboardData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
userType: string;
|
||||
propertyCount?: string;
|
||||
goals: string[];
|
||||
referralSource: string;
|
||||
nrlaMembershipId?: string;
|
||||
marketingOptIn?: boolean;
|
||||
}
|
||||
|
||||
export function useOnboardUser() {
|
||||
const router = useRouter();
|
||||
const { update } = useSession();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: OnboardData) => {
|
||||
// 1️) Create user profile
|
||||
const res1 = await fetch("/api/user/profile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res1.ok) throw new Error("Failed to create user profile");
|
||||
|
||||
// 2️) Mark user as onboarded
|
||||
const res2 = await fetch("/api/user/onboarded", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ onboarded: true }),
|
||||
});
|
||||
if (!res2.ok) throw new Error("Failed to update onboarding status");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// 3️) Redirect once both requests succeed
|
||||
// Refresh the token with the next-auth session update
|
||||
await update();
|
||||
router.push("/home");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
SelectItem,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import { Fragment } from "react";
|
||||
import { useOnboardUser } from "./OnboardUserHook";
|
||||
|
||||
const referralLabels: Record<string, string> = {
|
||||
search: "Search engine (e.g. Google)",
|
||||
|
|
@ -122,15 +123,10 @@ export default function OnboardingPage() {
|
|||
|
||||
const userType = watch("userType");
|
||||
const referralSource = watch("referralSource");
|
||||
const onboardMutation = useOnboardUser();
|
||||
|
||||
async function onSubmit(data: OnboardingData) {
|
||||
console.log("Onboarding data submitted:");
|
||||
await fetch("/api/user/onboard", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
router.push("/");
|
||||
onboardMutation.mutate(data);
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
|
|
@ -302,8 +298,7 @@ export default function OnboardingPage() {
|
|||
Step 1 of 3
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
By telling us a bit about yourself, we'll know how to best
|
||||
support your journey.
|
||||
{`By telling us a bit about yourself, we'll know how to best support your journey.`}
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
@ -408,13 +403,15 @@ export default function OnboardingPage() {
|
|||
<span className="text-red-500">*</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
"access_funding",
|
||||
"net_zero",
|
||||
"improve_condition",
|
||||
"save_money",
|
||||
"other",
|
||||
].map((g) => (
|
||||
{(
|
||||
[
|
||||
"access_funding",
|
||||
"net_zero",
|
||||
"improve_condition",
|
||||
"save_money",
|
||||
"other",
|
||||
] as OnboardingData["goals"]
|
||||
).map((g) => (
|
||||
<label
|
||||
key={g}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
|
|
@ -422,7 +419,8 @@ export default function OnboardingPage() {
|
|||
<Checkbox
|
||||
checked={watch("goals")?.includes(g)}
|
||||
onCheckedChange={(checked) => {
|
||||
const goals = watch("goals") ?? [];
|
||||
const goals = (watch("goals") ??
|
||||
[]) as OnboardingData["goals"];
|
||||
setValue(
|
||||
"goals",
|
||||
checked
|
||||
|
|
@ -451,9 +449,8 @@ export default function OnboardingPage() {
|
|||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
trigger("referralSource"); // ✅ update validation instantly
|
||||
trigger("referralSource"); // update validation instantly
|
||||
}}
|
||||
placeholder="How did you hear about us?"
|
||||
>
|
||||
<SelectTrigger>
|
||||
{field.value
|
||||
|
|
@ -596,11 +593,17 @@ export default function OnboardingPage() {
|
|||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-brandbrown hover:bg-hoverblue"
|
||||
className="bg-brandbrown hover:bg-hoverblue flex items-center justify-center"
|
||||
disabled={onboardMutation.isPending}
|
||||
>
|
||||
Finish
|
||||
{onboardMutation.isPending ? "Finishing..." : "Finish"}
|
||||
</Button>
|
||||
)}
|
||||
{onboardMutation.isError && (
|
||||
<p className="text-sm text-red-500 mt-2">
|
||||
{(onboardMutation.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect, use } from "react";
|
||||
import { useState, useEffect, use, useCallback } from "react";
|
||||
|
||||
export default function LoadingPage(props: { params: Promise<{ slug: string }> }) {
|
||||
export default function LoadingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const portfolioId = params.slug;
|
||||
const router = useRouter();
|
||||
const [countdown, setCountdown] = useState(10); // Initialize countdown state to 10 seconds
|
||||
|
||||
const handleBackToPortfolio = () => {
|
||||
const handleBackToPortfolio = useCallback(() => {
|
||||
if (portfolioId) {
|
||||
router.push(`/portfolio/${portfolioId}`);
|
||||
} else {
|
||||
router.push(`/home`);
|
||||
}
|
||||
};
|
||||
}, [portfolioId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
// If countdown reaches zero, redirect the user
|
||||
if (countdown === 0) {
|
||||
handleBackToPortfolio();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up interval to decrease countdown by 1 every second
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prevCountdown) => prevCountdown - 1);
|
||||
setCountdown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
// Clean up the interval when the component unmounts
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown, handleBackToPortfolio]);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ export async function middleware(req: NextRequest) {
|
|||
const userEmail = token.email || "";
|
||||
|
||||
// Internal users (bypass onboarding)
|
||||
// const isInternal = userEmail.endsWith("@domna.homes");
|
||||
const isInternal = userEmail.endsWith("@domna.homes");
|
||||
|
||||
// Not onboarded and not internal
|
||||
if (token.onboarded === false && pathname !== "/onboarding") {
|
||||
if (token.onboarded === false && pathname !== "/onboarding" && !isInternal) {
|
||||
return NextResponse.redirect(new URL("/onboarding", req.url));
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +26,11 @@ export async function middleware(req: NextRequest) {
|
|||
return NextResponse.redirect(new URL("/home", req.url));
|
||||
}
|
||||
|
||||
// If internal, allow access to everything
|
||||
if (isInternal) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Everything else allowed
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue