Merge pull request #98 from Hestia-Homes/sign-up

Sign up
This commit is contained in:
KhalimCK 2025-10-17 15:18:53 +01:00 committed by GitHub
commit 940d7128fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 10447 additions and 1134 deletions

1857
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
@ -44,8 +44,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.3",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
"lucide-react": "^0.233.0",
@ -70,10 +69,12 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/cypress": "^10.0.3",
"@types/nodemailer": "^7.0.2",
"@types/pg": "^8.10.2",
"cypress": "^14.5.3",
"cypress-social-logins": "^1.14.1",
"dotenv": "^16.3.1",
"drizzle-kit": "^0.31.5",
"eslint": "^8.57.1",
"prettier": "^3.6.2",
"start-server-and-test": "^2.0.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

View file

@ -0,0 +1,281 @@
import { eq, and } from "drizzle-orm";
import type {
Adapter,
AdapterUser,
AdapterAccount,
VerificationToken,
} from "next-auth/adapters";
import {
user as userTable,
accounts as accountsTable,
sessions as sessionsTable,
verificationTokens as verificationTokensTable,
} from "@/app/db/schema/users";
/**
* Custom Drizzle adapter for NextAuth v4
* ---------------------------------------
* Works with bigint user IDs (no need for UUID migration)
* Compatible with existing users table
* Adds optional Database Session support
*
* By default, NextAuth uses JWT-based sessions (stateless).
* The session functions here are only used if you enable:
*
* session: { strategy: "database" }
*
* Benefits of database sessions:
* - Revocable sessions (logout from all devices)
* - View active sessions per user
* - Force re-login after role/permission changes
* - Audit trail of login activity
*/
// We extend
export default function DrizzleEmailAdapter(
db: any,
tables: {
user: typeof userTable;
accounts: typeof accountsTable;
verificationTokens: typeof verificationTokensTable;
sessions?: typeof sessionsTable;
}
): Adapter {
const { user, accounts, verificationTokens, sessions } = tables;
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
const normaliseEmail = (email: string) => email.trim().toLowerCase();
const toAdapterUser = (u: any): AdapterUser => ({
id: String(u.id),
dbId: String(u.id),
email: u.email,
name: u.firstName ?? null,
image: u.image ?? null,
emailVerified: u.emailVerified ?? null,
onboarded: u.onboarded ?? false,
});
//----------------------------------------------------------------------
// Adapter methods
//----------------------------------------------------------------------
return {
//------------------------------------------------------------------
// USERS
//------------------------------------------------------------------
async createUser(
newUser: Omit<AdapterUser, "id"> & { id?: string }
): Promise<AdapterUser> {
const [created] = await db
.insert(user) // <-- now clearly the table
.values({
email: normaliseEmail(newUser.email!),
firstName: newUser.name ?? null,
image: newUser.image ?? null,
emailVerified: newUser.emailVerified ?? null,
})
.returning();
return toAdapterUser(created);
},
async getUser(id: string): Promise<AdapterUser | null> {
const [found] = await db
.select()
.from(user)
.where(eq(user.id, BigInt(id)));
return found ? toAdapterUser(found) : null;
},
async getUserByEmail(email: string): Promise<AdapterUser | null> {
const [found] = await db
.select()
.from(user)
.where(eq(user.email, normaliseEmail(email)));
return found ? toAdapterUser(found) : null;
},
async updateUser(
u: Partial<AdapterUser> & Pick<AdapterUser, "id">
): Promise<AdapterUser> {
const [updated] = await db
.update(user)
.set({
firstName: u.name ?? null,
image: u.image ?? null,
emailVerified: u.emailVerified ?? null,
})
.where(eq(user.id, BigInt(u.id)))
.returning();
return toAdapterUser(updated);
},
async deleteUser(id: string): Promise<void> {
await db.delete(user).where(eq(user.id, BigInt(id)));
},
//------------------------------------------------------------------
// ACCOUNTS (OAuth)
//------------------------------------------------------------------
async linkAccount(account: AdapterAccount): Promise<void> {
try {
await db.insert(accounts).values({
userId: BigInt(account.userId),
provider: account.provider,
providerAccountId: account.providerAccountId,
type: account.type,
refresh_token: account.refresh_token ?? null,
access_token: account.access_token ?? null,
expires_at: account.expires_at ?? null,
token_type: account.token_type ?? null,
scope: account.scope ?? null,
id_token: account.id_token ?? null,
session_state: account.session_state ?? null,
});
} catch (err: any) {
console.error("Error linking account:", err);
throw err;
}
},
async unlinkAccount(params: {
provider: string;
providerAccountId: string;
}): Promise<void> {
await db
.delete(accounts)
.where(
and(
eq(accounts.provider, params.provider),
eq(accounts.providerAccountId, params.providerAccountId)
)
);
},
async getUserByAccount(params: {
provider: string;
providerAccountId: string;
}): Promise<AdapterUser | null> {
const [acc] = await db
.select()
.from(accounts)
.where(
and(
eq(accounts.provider, params.provider),
eq(accounts.providerAccountId, params.providerAccountId)
)
);
if (!acc) return null;
const [usr] = await db
.select()
.from(user)
.where(eq(user.id, BigInt(acc.userId)));
return usr ? toAdapterUser(usr) : null;
},
//------------------------------------------------------------------
// EMAIL VERIFICATION TOKENS
//------------------------------------------------------------------
async createVerificationToken(
token: VerificationToken
): Promise<VerificationToken> {
console.log("Creating verification token for:", token.identifier);
const [created] = await db
.insert(verificationTokens)
.values({
...token,
expires: new Date(token.expires), // keep as Date
})
.returning();
return created as VerificationToken;
},
async useVerificationToken(params: {
identifier: string;
token: string;
}): Promise<VerificationToken | null> {
const [found] = await db
.select()
.from(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, params.identifier),
eq(verificationTokens.token, params.token)
)
);
if (!found) return null;
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, params.identifier),
eq(verificationTokens.token, params.token)
)
);
return found as VerificationToken;
},
//------------------------------------------------------------------
// SESSIONS (Optional only used if session.strategy = "database")
//------------------------------------------------------------------
async createSession(session) {
if (!sessions) return null;
const [created] = await db
.insert(sessions)
.values({
sessionToken: session.sessionToken,
userId: BigInt(session.userId),
expires: session.expires,
})
.returning();
return created;
},
async getSessionAndUser(sessionToken) {
if (!sessions) return null;
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionToken, sessionToken));
if (!session) return null;
const [u] = await db
.select()
.from(user)
.where(eq(user.id, BigInt(session.userId)));
if (!u) return null;
return {
session,
user: toAdapterUser(u),
};
},
async updateSession(session) {
if (!sessions) return null;
const [updated] = await db
.update(sessions)
.set({ expires: session.expires })
.where(eq(sessions.sessionToken, session.sessionToken))
.returning();
return updated ?? null;
},
async deleteSession(sessionToken) {
if (!sessions) return;
await db.delete(sessions).where(eq(sessions.sessionToken, sessionToken));
},
};
}

View file

@ -1,29 +1,49 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
import { db } from "@/app/db/db";
import { user as userTable, User } from "@/app/db/schema/users";
import {
user as users,
accounts,
verificationTokens,
} from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
const { GOOGLE_CLIENT_ID = "", GOOGLE_CLIENT_SECRET = "" } = process.env;
// ------------------------------------------------------------------
// Environment variables
// ------------------------------------------------------------------
const {
GOOGLE_CLIENT_ID = "",
GOOGLE_CLIENT_SECRET = "",
AZURE_AD_B2C_TENANT_NAME = "",
AZURE_AD_B2C_CLIENT_ID = "",
AZURE_AD_B2C_CLIENT_SECRET = "",
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
EMAIL_SERVER_HOST = "",
EMAIL_SERVER_PORT = "",
EMAIL_SERVER_USER = "",
EMAIL_SERVER_PASSWORD = "",
EMAIL_FROM = "",
} = process.env;
type OauthProvider = "google";
// TODO: handle token expiration
// https://next-auth.js.org/v3/tutorials/refresh-token-rotation
// propertly set options too
// https://next-auth.js.org/configuration/options
type OauthProvider = "google" | "azure-ad-b2c";
// ------------------------------------------------------------------
// NextAuth configuration
// ------------------------------------------------------------------
export const AuthOptions: NextAuthOptions = {
adapter: DrizzleEmailAdapter(db, {
user: users,
accounts,
verificationTokens,
}),
providers: [
// ------------------ Google ------------------
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
@ -35,6 +55,8 @@ export const AuthOptions: NextAuthOptions = {
},
},
}),
// ------------------ Azure AD B2C ------------------
AzureADB2CProvider({
tenantId: AZURE_AD_B2C_TENANT_NAME,
clientId: AZURE_AD_B2C_CLIENT_ID,
@ -47,109 +69,136 @@ export const AuthOptions: NextAuthOptions = {
},
},
}),
CredentialsProvider({
name: "Email Login",
credentials: {
email: {
label: "Email",
type: "email",
// ------------------ Email (SES Magic Link) ------------------
EmailProvider({
server: {
host: EMAIL_SERVER_HOST,
port: Number(EMAIL_SERVER_PORT),
auth: {
user: EMAIL_SERVER_USER,
pass: EMAIL_SERVER_PASSWORD,
},
},
async authorize(credentials, req) {
if (!credentials || !credentials.email) {
throw new Error("Email is required");
}
const { email } = credentials;
// Query the database to find the user by email
const dbUser = await db
.select()
.from(userTable)
.where(eq(userTable.email, email));
// If the email exists, return the user object (no password check)
if (dbUser.length === 1) {
return {
id: dbUser[0].id.toString(), // Convert bigint to string to avoid serialization issues
email: dbUser[0].email,
dbId: dbUser[0].id.toString(), // Ensure dbId is added and is a string
};
}
return null;
},
from: EMAIL_FROM,
maxAge: 60 * 60, // magic link valid for 1 hour
sendVerificationRequest: MagicLinksEmail,
}),
],
// ------------------------------------------------------------------
// Pages
// ------------------------------------------------------------------
pages: {
signIn: "/",
signIn: "/", // your landing/login page
verifyRequest: "/auth/verify-request",
error: "/auth/error",
},
// ------------------------------------------------------------------
// Callbacks
// ------------------------------------------------------------------
callbacks: {
/**
* Sign in callback ensures user exists and links OAuth provider if needed
*/
async signIn({ user, account }) {
try {
if (user === null || user.email === null) {
return "/beta";
}
const dbUser: User[] = await db
if (!user?.email) return false;
const normalisedEmail = user.email.toLowerCase();
// Fetch the user (NextAuth will have created them already if new)
const [dbUser] = await db
.select()
.from(userTable)
.where(eq(userTable.email, String(user.email)));
.from(users)
.where(eq(users.email, normalisedEmail));
if (dbUser.length > 1) {
console.error(`Multiple users found with email ${user.email}`);
return false;
if (!dbUser) {
console.log("New user sign up for email:", normalisedEmail);
return true;
}
if (dbUser.length === 0 || account === null) {
return "/beta";
}
if (!dbUser[0].oauthId) {
// We make a second query to populate the oauthId and oauthProvider
console.log("Updating user with oauthId and oauthProvider");
// Link OAuth ID if missing (helps for older accounts)
if (account && !dbUser.oauthId) {
const provider = account.provider as OauthProvider;
await db
.update(userTable)
.update(users)
.set({ oauthId: user.id, oauthProvider: provider })
.where(eq(userTable.email, String(user.email)));
console.log("Updated oauthId and oauthProvider");
.where(eq(users.email, normalisedEmail));
}
// Set the user's ID from your database
// Because bigint isn't serializable, we need to convert it to a string
user.dbId = dbUser[0].id.toString();
// Always update last login timestamp
await db
.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();
user.onboarded = dbUser.onboarded ?? false;
return true;
} catch (error) {
console.error("Error during sign-in: ", error);
console.error("Error during sign-in:", error);
return false;
}
},
async jwt({ token, user }) {
// This is executed whenever a JWT is created or refreshed.
// `user` is the object returned from `signIn` callback and
// is only available during sign in, which is why we need to
// store the id in the token and then read it back into the session.
if (user?.dbId) {
token.dbId = user.dbId;
/**
* Persist dbId in the JWT so its available in sessions
*/
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;
}
}
return token;
},
/**
* Attach dbId to session.user
*/
async session({ session, token }) {
if (session?.user) {
if (session.user && token.dbId) {
session.user.dbId = token.dbId;
}
return session;
},
/**
* Redirect users after login
*/
async redirect({ baseUrl }) {
const redirectUrl = baseUrl + "/home";
return redirectUrl;
// If the user has not onboarded, send them to onboarding
return `${baseUrl}/home`;
},
},
// ------------------------------------------------------------------
// General options
// ------------------------------------------------------------------
session: {
strategy: "jwt", // lightweight, avoids DB session writes
},
jwt: {
maxAge: 60 * 60 * 24 * 30, // 30 days
},
debug: false,
};
const handler = NextAuth(AuthOptions);
export { handler as GET, handler as POST };

View file

@ -1,6 +1,6 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
@ -18,7 +18,6 @@ import { eq, inArray, Name } from "drizzle-orm";
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
// Get colloborators (users) that have access to the portfolio
export async function GET(
_req: NextRequest,
@ -41,7 +40,7 @@ export async function GET(
// Explicitly normalize BigInts to strings
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString(): null,
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
@ -58,7 +57,6 @@ export async function GET(
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
@ -110,7 +108,7 @@ export async function POST(
const bodySchema = z.object({
email: z.string().email(),
role: z.enum(ROLE_OPTIONS),
name: z.string()
name: z.string(),
});
let body: z.infer<typeof bodySchema>;
@ -138,10 +136,14 @@ export async function POST(
// If youre on Postgres, this is ideal:
const inserted = await db
.insert(user)
.values({ email: body.email, firstName: body.name, oauthProvider: "credentials" })
.values({
email: body.email,
firstName: body.name,
oauthProvider: "credentials",
})
.onConflictDoNothing() // relies on a UNIQUE(email) constraint
.returning({ id: user.id });
if (inserted.length > 0) {
createdUserId = inserted[0].id;
} else {
@ -202,4 +204,4 @@ export async function POST(
{ status: 500 }
);
}
}
}

View 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,
});
}
}

View 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",
"25",
"620",
"21+",
"150",
"51100",
"101300",
"3011000",
"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,
});
}
}

View file

@ -1,3 +0,0 @@
export default function Beta() {
return <div>You do not have access to this application currently</div>;
}

View file

@ -13,17 +13,25 @@ export default function EmailSignInButton({
}) {
const [email, setEmail] = useState("");
const [error, setError] = useState(initialError);
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
const res = await signIn("credentials", {
email,
});
setStatus("sending");
console.log("BEFOERE SIGN IN");
console.log("window.location.origin:", window.location.origin);
const res = await signIn("email", { email, redirect: false });
console.log("AFTER SIGN IN");
if (res?.error) {
setError("You are not a valid user.");
setStatus("idle");
console.log("Error signing in:", res.error);
} else {
console.log("Login successful");
console.log("Sign-in link sent to:", email);
setError(undefined);
setStatus("sent");
}
};
@ -31,40 +39,42 @@ export default function EmailSignInButton({
target: { value: SetStateAction<string> };
}) => {
setEmail(e.target.value);
if (error) {
setError(undefined); // Clear the error when the user starts typing
}
if (error) setError(undefined);
};
// Sync initial error state with server-side error prop
useEffect(() => {
setError(initialError);
}, [initialError]);
// Keep server-side error synced
useEffect(() => setError(initialError), [initialError]);
return (
<form onSubmit={handleSubmit} className="w-full">
{/* Wrapper to control width and layout */}
<div className="flex items-center w-full space-x-1">
{/* Email input field using shadcn input */}
<Input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Enter your email"
required
className="flex-1 h-10 rounded-lg border-gray-300" // Full width input
className="flex-1 h-10 rounded-lg border-gray-300"
/>
<Button
type="submit"
className="h-10 w-10 bg-brandblue text-white hover:bg-hoverblue rounded-lg flex items-center justify-center" // Fixed size button
disabled={status === "sending"}
className="h-10 w-10 bg-brandblue text-white hover:bg-hoverblue rounded-lg flex items-center justify-center"
>
<ChevronRightIcon className="h-5 w-5" />
</Button>
</div>
{/* Reserve space for the error message */}
<div className="min-h-[3rem] text-center">
{error && <p className="text-red-500">You are not a valid user.</p>}
{error && <p className="text-red-500">{error}</p>}
{status === "sent" && (
<p className="text-green-600">
A login link has been sent to your email.
</p>
)}
{status === "sending" && (
<p className="text-gray-500">Sending login link...</p>
)}
</div>
</form>
);

View file

@ -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, {

View file

@ -0,0 +1,35 @@
CREATE TABLE "account" (
"userId" bigint NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"providerAccountId" text NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text,
CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE "session" (
"sessionToken" text PRIMARY KEY NOT NULL,
"userId" bigint NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verificationToken" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "emailVerified" timestamp;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "onboarded" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "last_login" timestamp;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");

View file

@ -0,0 +1,22 @@
CREATE TYPE "public"."user_profiles_property_count" AS ENUM('1', '25', '620', '21+', '150', '51100', '101300', '3011000', '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;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -820,6 +820,20 @@
"when": 1759069966418,
"tag": "0116_spotty_leech",
"breakpoints": true
},
{
"idx": 117,
"version": "7",
"when": 1760191704756,
"tag": "0117_colossal_bastion",
"breakpoints": true
},
{
"idx": 118,
"version": "7",
"when": 1760552402393,
"tag": "0118_lazy_gabe_jones",
"breakpoints": true
}
]
}

View file

@ -1,14 +1,174 @@
import { bigserial, text, timestamp, pgTable } from "drizzle-orm/pg-core";
import {
bigint,
bigserial,
text,
timestamp,
pgTable,
primaryKey,
integer,
boolean,
json,
pgEnum,
varchar,
} from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
// -------------------------
// USERS
// -------------------------
export const user = pgTable("user", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
firstName: text("firstName"),
// At the moment, Drizzle doesn't support unique constraints
email: text("email").notNull(),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
oauthId: text("oauth_id"),
oauthProvider: text("oauth_provider").$type<"google" | "credentials">(),
// role: text("role").$type<"admin" | "write" | "read">(),
oauthProvider: text("oauth_provider").$type<
"google" | "credentials" | "azure-ad-b2c"
>(),
image: text("image"),
onboarded: boolean("onboarded").default(false).notNull(),
lastLogin: timestamp("last_login", { mode: "date" }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
// -------------------------
// ACCOUNTS (OAuth providers)
// -------------------------
export const accounts = pgTable(
"account",
{
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: text("type").$type<"oauth" | "email" | "credentials">().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
primaryKey({ columns: [account.provider, account.providerAccountId] }),
]
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(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_profiles_user_type", UserType);
export const propertyCountEnum = pgEnum(
"user_profiles_property_count",
PropertyCount
);
export const referralSourceEnum = pgEnum(
"user_profiles_referral_source",
ReferralSource
);
// ----------------------------
// 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,
@ -23,5 +183,15 @@ export const user = pgTable("user", {
.notNull(),
});
// -------------------------
// Types
// -------------------------
export type User = InferModel<typeof user, "select">;
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

@ -0,0 +1,98 @@
// 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
import { createTransport } from "nodemailer";
export async function MagicLinksEmail({
identifier,
url,
provider,
}: {
identifier: string;
url: string;
provider: { server: any; from: string };
}) {
const { host } = new URL(url);
const transport = createTransport(provider.server);
const brandColor = "#14163d"; // brand blue
const accentColor = "#2d348f"; // deep blue
const brown = "#c4a47c"; // brand brown
const background = "#F9F9F9";
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: "Your secure Domna IQ sign-in link",
text: plainText({ url, host }),
html: domnaHtml({ url, host, brandColor, accentColor, brown, background }),
});
const failed = result.rejected.filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
}
function domnaHtml({
url,
host,
brandColor,
accentColor,
brown,
background,
}: {
url: string;
host: string;
brandColor: string;
accentColor: string;
brown: string;
background: string;
}) {
const escapedHost = host.replace(/\./g, "&#8203;.");
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"
style="max-width: 600px; margin: 40px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img
src="https://145275138.fs1.hubspotusercontent-eu1.net/hubfs/145275138/base_logo_transparent_background.png"
alt="Domna Logo"
width="120"
height="auto"
style="margin-bottom: 4px;"
/>
</td>
</tr>
<tr>
<td align="center" style="padding: 10px 10px 10px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin-bottom: 16px;">Welcome back to Domna IQ</h2>
<p style="font-size: 16px; line-height: 1.6; color: #444; margin-bottom: 32px;">
Click below to securely sign in to your account and continue your retrofit journey.
</p>
<a href="${url}" target="_blank"
style="display: inline-block; padding: 14px 28px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">
Sign in to Domna IQ
</a>
<p style="margin-top: 36px; font-size: 13px; color: #777;">
If you didnt request this email, you can safely ignore it.
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px; font-size: 12px; color: #999; border-top: 1px solid #eee;">
&copy; ${new Date().getFullYear()} Domna Homes <span style="color: ${accentColor};">${escapedHost}</span>
</td>
</tr>
</table>
</body>
`;
}
function plainText({ url, host }: { url: string; host: string }) {
return `Sign in to Domna IQ\n${url}\n\nIf you did not request this email, you can safely ignore it.\n`;
}

View file

@ -27,7 +27,7 @@ const getSession = cache(async () => {
export function Footer() {
return (
<footer className="bg-brandblue text-white p-4 text-center border-t border-gray-300 mt-8">
<footer className="bg-brandblue text-white p-4 text-center border-t border-gray-300">
<p>
&copy; {new Date().getFullYear()} Domna. All rights reserved. Domna
proprietary IP.

View 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");
},
});
}

611
src/app/onboarding/page.tsx Normal file
View file

@ -0,0 +1,611 @@
"use client";
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,
SelectContent,
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)",
social_media: "Social media",
NRLA: "NRLA (National Residential Landlords Association)",
partner: "Through a partner organisation",
word_of_mouth: "Word of mouth",
other: "Other",
};
const OnboardingSchema = z
.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
userType: z.enum(
[
"private_landlord",
"private_tenant",
"social_landlord",
"social_tenant",
"homeowner",
"other",
],
{ required_error: "Please tell us who you are" }
),
propertyCount: z
.enum(
[
"1",
"25",
"620",
"21+",
"150",
"51100",
"101300",
"3011000",
"1000+",
],
{
required_error:
"Please tell us how many homes youre responsible for",
}
)
.optional(),
goals: z
.array(
z.enum([
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
])
)
.min(1, "Please select at least one goal"),
referralSource: z.enum(
["search", "social_media", "NRLA", "partner", "word_of_mouth", "other"],
{ required_error: "Please tell us how you heard about Domna" }
),
nrlaMembershipId: z.string().optional(),
acceptedPrivacy: z.boolean().refine((v) => v === true, {
message: "You must accept our privacy policy to continue",
}),
marketingOptIn: z.boolean().optional(), // only optional field
})
.refine(
(data) => {
const isLandlord =
data.userType === "private_landlord" ||
data.userType === "social_landlord";
if (isLandlord && !data.propertyCount) return false;
return true;
},
{
message: "Please tell us how many homes youre responsible for",
path: ["propertyCount"],
}
);
type OnboardingData = z.infer<typeof OnboardingSchema>;
export default function OnboardingPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const form = useForm<OnboardingData>({
resolver: zodResolver(OnboardingSchema),
defaultValues: {
goals: [],
marketingOptIn: false,
acceptedPrivacy: false,
},
mode: "onChange",
});
const {
register,
handleSubmit,
control,
watch,
setValue,
trigger,
formState: { errors, isValid },
} = form;
const userType = watch("userType");
const referralSource = watch("referralSource");
const onboardMutation = useOnboardUser();
async function onSubmit(data: OnboardingData) {
onboardMutation.mutate(data);
}
async function nextStep() {
let valid = false;
if (step === 1) {
// Validate step 1 fields only
const ok = await trigger(["userType", "propertyCount"], {
shouldFocus: true,
});
const values = form.getValues();
const isLandlord =
values.userType === "private_landlord" ||
values.userType === "social_landlord";
if (isLandlord && !values.propertyCount) {
form.setError("propertyCount", {
type: "manual",
message: "Please tell us how many homes youre responsible for",
});
valid = false;
} else {
valid = ok;
}
} else if (step === 2) {
valid = await trigger(["goals", "referralSource"], { shouldFocus: true });
} else {
valid = true;
}
if (valid) setStep((s) => Math.min(s + 1, 3));
}
function prevStep() {
setStep((s) => Math.max(s - 1, 1));
}
return (
<div className="flex bg-gray-50">
{/* Left image panel */}
<div className="hidden md:flex w-1/2 relative overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: "url('/images/Alexandra-Road-Park.webp')" }}
></div>
<div className="relative z-10 flex items-start justify-start w-full h-full">
<div className="mt-20 p-20 text-gray-100 shadow-xl w-[75%] rounded-br-[8rem] bg-gradient-to-r from-brandblue to-midblue">
<h2 className="text-5xl font-bold mb-4">Welcome to Domna IQ</h2>
<p className="text-xl leading-relaxed text-brandbrown">
Help us get to know you so we can tailor your experience.
</p>
</div>
</div>
</div>
{/* Right section with journey and form */}
<div className="flex flex-col flex-1 items-center justify-start p-10 relative">
{/* Domna Journey Progress */}
<div className="w-full max-w-3xl mt-8 mb-8">
<h3 className="text-center text-brandblue text-xl font-semibold mb-5">
Your retrofit journey with Domna
</h3>
{/* Row 1: Nodes + Connectors */}
<div className="relative flex items-center justify-center w-full">
{[
{ label: "Remote Portfolio Assessment", icon: "🧠", step: 1 },
{ label: "Survey & Design", icon: "📐", step: 2 },
{ label: "Installation & Funding Claim", icon: "🏡", step: 3 },
].map((stage, i, arr) => {
const active = step >= stage.step;
const showConnector = i < arr.length - 1;
const segmentFilled = step > stage.step;
return (
<Fragment key={stage.step}>
<div className="flex-none w-[180px] flex flex-col items-center text-center">
<div
className={`flex items-center justify-center w-12 h-12 rounded-full border-2 text-lg font-semibold mb-2 transition-all duration-500 ${
active
? "bg-brandblue text-white border-brandblue shadow-md"
: "bg-white text-gray-400 border-gray-300"
}`}
>
<span>{stage.icon}</span>
</div>
<p
className={`text-sm font-medium max-w-[8rem] ${
active ? "text-brandblue" : "text-gray-400"
}`}
>
{stage.label}
</p>
</div>
{showConnector && (
<div className="flex-none w-[60px] md:w-[80px] relative">
<div className="absolute left-2 right-2 top-[1.5rem] h-[2px] bg-gray-300" />
<div
className={`absolute left-2 top-[1.5rem] h-[2px] transition-all duration-500 ${
segmentFilled
? "right-2 bg-brandblue"
: "w-0 bg-brandblue"
}`}
/>
</div>
)}
</Fragment>
);
})}
</div>
{/* Row 2: Captions aligned under each node */}
<div className="mt-4 grid grid-cols-3 gap-2 text-gray-600 justify-items-center">
{[
{
step: 1,
caption:
"IQ models home energy improvements, costs, and funding availability at the click of a button.",
},
{
step: 2,
caption:
"Schedule a survey. Our team surveys properties and produces a tailored retrofit design.",
},
{
step: 3,
caption:
"We guide installation and help you claim any available funding to complete your retrofit journey.",
},
].map(({ step: s, caption }) => (
<div
key={s}
className={`text-xs leading-snug text-center w-[200px] transition-opacity duration-300 ${
step === s ? "opacity-100" : "opacity-70"
}`}
style={{ minHeight: "3rem" }}
>
{caption}
</div>
))}
</div>
</div>
{/* Onboarding Form */}
<form
onSubmit={handleSubmit(onSubmit, (errors) => {
console.log("Validation errors:", errors);
})}
className="w-full max-w-lg bg-white rounded-xl shadow-md p-8 flex flex-col min-h-[450px]"
>
{/* Progress indicator */}
<div className="flex items-center pb-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className={`flex-1 h-1 mx-1 rounded ${
i <= step ? "bg-brandbrown" : "bg-gray-200"
}`}
/>
))}
</div>
<div className="flex-grow space-y-6">
{step === 1 && (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-brandblue">
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.`}
</p>
<Controller
control={control}
name="userType"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
setValue("propertyCount", undefined); // ✅ clear previous selection
}}
>
<SelectTrigger>
{field.value
? field.value
.replace("_", " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
: "Tell us who you are"}
</SelectTrigger>
<SelectContent>
<SelectItem value="private_landlord">
Im a private landlord
</SelectItem>
<SelectItem value="private_tenant">
Im a private tenant
</SelectItem>
<SelectItem value="social_landlord">
I manage social housing
</SelectItem>
<SelectItem value="social_tenant">
I live in social housing
</SelectItem>
<SelectItem value="homeowner">
I own my own home
</SelectItem>
<SelectItem value="other">Something else</SelectItem>
</SelectContent>
</Select>
)}
/>
{errors.userType && (
<p className="text-sm text-red-500">
{errors.userType.message}
</p>
)}
{(userType === "private_landlord" ||
userType === "social_landlord") && (
<Controller
control={control}
name="propertyCount"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
{field.value ??
"Please tell us how many properties you're responsible for"}
</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>
)}
{errors.propertyCount && (
<p className="text-sm text-red-500">
{errors.propertyCount.message}
</p>
)}
{step === 2 && (
<div className="space-y-4">
<h1 className="text-2xl font-semibold text-brandblue">
Step 2 of 3
</h1>
{/* Goals section */}
<p className="font-medium text-gray-700">
What would you like to achieve with Domna?
<span className="text-red-500">*</span>
</p>
<div className="grid grid-cols-2 gap-2">
{(
[
"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"
>
<Checkbox
checked={watch("goals")?.includes(g)}
onCheckedChange={(checked) => {
const goals = (watch("goals") ??
[]) as OnboardingData["goals"];
setValue(
"goals",
checked
? [...goals, g]
: goals.filter((gg) => gg !== g),
{ shouldValidate: true } // ✅ triggers live validation feedback
);
}}
/>
<span className="capitalize text-sm">
{g.replace("_", " ")}
</span>
</label>
))}
</div>
{errors.goals && (
<p className="text-sm text-red-500">{errors.goals.message}</p>
)}
{/* Referral source section */}
<Controller
control={control}
name="referralSource"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
trigger("referralSource"); // update validation instantly
}}
>
<SelectTrigger>
{field.value
? referralLabels[field.value]
: "How did you hear about us?"}
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
Search engine (e.g. Google)
</SelectItem>
<SelectItem value="social_media">
Social media
</SelectItem>
<SelectItem value="NRLA">
NRLA (National Residential Landlords Association)
</SelectItem>
<SelectItem value="partner">
Through a partner organisation
</SelectItem>
<SelectItem value="word_of_mouth">
Word of mouth
</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
)}
/>
{errors.referralSource && (
<p className="text-sm text-red-500">
{errors.referralSource.message}
</p>
)}
{referralSource === "NRLA" && (
<div>
<label className="flex items-center space-x-2 text-xs text-gray-700 mb-1">
{`You don't need to enter your NRLA membership ID but you may be eligible for additional discounts`}
</label>
<Input
{...register("nrlaMembershipId")}
placeholder="Enter your NRLA membership ID"
/>
</div>
)}
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-brandblue">
Step 3 of 3
</h1>
<Input
{...register("firstName")}
placeholder="First name"
required
/>
<Input
{...register("lastName")}
placeholder="Last name"
required
/>
<label className="flex items-center space-x-2">
<Controller
name="acceptedPrivacy"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(checked === true)
}
/>
)}
/>
<span className="text-sm">
I agree to the{" "}
<a
href="https://www.domna.homes/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-brandblue underline"
>
Privacy Policy
</a>
</span>
</label>
{errors.acceptedPrivacy && (
<p className="text-sm text-red-500">
{errors.acceptedPrivacy.message}
</p>
)}
<label className="flex items-center space-x-2">
<Controller
name="marketingOptIn"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(checked === true)
}
/>
)}
/>
<span className="text-sm text-gray-700">
Id like to hear more about funding opportunities, retrofit
schemes, and energy upgrades.
</span>
</label>
</div>
)}
</div>
{/* Buttons fixed at bottom */}
<div className="flex justify-end pt-4">
{step > 1 && (
<Button
className="bg-brandlightblue mr-2"
variant="outline"
type="button"
onClick={prevStep}
>
Back
</Button>
)}
{step < 3 ? (
<Button
type="button"
className="bg-brandbrown hover:bg-hoverblue"
onClick={nextStep}
>
Next
</Button>
) : (
<Button
type="submit"
className="bg-brandbrown hover:bg-hoverblue flex items-center justify-center"
disabled={onboardMutation.isPending}
>
{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>
</div>
);
}

View file

@ -2,15 +2,13 @@ import { getServerSession } from "next-auth/next";
import { AuthOptions } from "./api/auth/[...nextauth]/route";
import GoogleSignInButton from "./components/signin/GoogleSignInButton";
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
import EmailSignInButton from "./components/signin/CredentialsButton";
import EmailSignInButton from "./components/signin/EmailSignInButton";
import { redirect } from "next/navigation";
import Image from "next/image";
export default async function Home(
props: {
searchParams: Promise<{ error?: string }>;
}
) {
export default async function Home(props: {
searchParams: Promise<{ error?: string }>;
}) {
const searchParams = await props.searchParams;
const session = await getServerSession(AuthOptions);

View file

@ -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]);

View file

@ -1,16 +1,13 @@
"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, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -18,36 +15,74 @@ const SelectTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
{...props}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
{...props}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
{...props}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
position?: "popper" | "item-aligned";
}
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
{...props}
position={position}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
@ -57,10 +92,11 @@ const SelectContent = React.forwardRef<
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@ -68,11 +104,11 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@ -80,22 +116,21 @@ const SelectItem = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
{...props}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<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>,
@ -103,11 +138,11 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@ -118,4 +153,6 @@ export {
SelectLabel,
SelectItem,
SelectSeparator,
}
SelectScrollUpButton,
SelectScrollDownButton,
};

View file

@ -1,12 +1,52 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const token = await getToken({ req });
const { pathname } = req.nextUrl;
console.log("token", token);
console.log("onboarded", token?.onboarded);
// If no session, send user to sign-in page
if (!token) {
return NextResponse.redirect(new URL("/", req.url));
}
const userEmail = token.email || "";
// Internal users (bypass onboarding)
const isInternal = userEmail.endsWith("@domna.homes");
// Not onboarded and not internal
if (token.onboarded === false && pathname !== "/onboarding" && !isInternal) {
return NextResponse.redirect(new URL("/onboarding", req.url));
}
// Already onboarded but tries to go back to onboarding page
if (token.onboarded === true && pathname === "/onboarding") {
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();
}
export const config = {
matcher: [
// Protect only your apps authenticated areas
"/home/:path*",
"/portfolio/:path*",
"/search/:path*",
"/addresses/:path",
"/due-considerations/:path",
"/eco-spreadsheet/:path",
"/addresses/:path*",
"/due-considerations/:path*",
"/eco-spreadsheet/:path*",
"/onboarding", // add onboarding itself
],
};
export { default } from "next-auth/middleware";

View file

@ -10,5 +10,6 @@ declare module "next-auth" {
}
interface User {
dbId: string;
onboarded: boolean;
}
}

View file

@ -12,227 +12,230 @@ module.exports = {
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
theme: {
transparent: 'transparent',
current: 'currentColor',
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
},
colors: {
tremor: {
brand: {
faint: 'colors.blue[50]',
muted: 'colors.blue[200]',
subtle: 'colors.blue[400]',
DEFAULT: 'colors.blue[500]',
emphasis: 'colors.blue[700]',
inverted: 'colors.white'
},
background: {
muted: 'colors.gray[50]',
subtle: 'colors.gray[100]',
DEFAULT: 'colors.white',
emphasis: 'colors.gray[700]'
},
border: {
DEFAULT: 'colors.gray[200]'
},
ring: {
DEFAULT: 'colors.gray[200]'
},
content: {
subtle: 'colors.gray[400]',
DEFAULT: 'colors.gray[500]',
emphasis: 'colors.gray[700]',
strong: 'colors.gray[900]',
inverted: 'colors.white'
}
},
'dark-tremor': {
brand: {
faint: '#0B1229',
muted: 'colors.blue[950]',
subtle: 'colors.blue[800]',
DEFAULT: 'colors.blue[500]',
emphasis: 'colors.blue[400]',
inverted: 'colors.blue[950]'
},
background: {
muted: '#131A2B',
subtle: 'colors.gray[800]',
DEFAULT: 'colors.gray[900]',
emphasis: 'colors.gray[300]'
},
border: {
DEFAULT: 'colors.gray[800]'
},
ring: {
DEFAULT: 'colors.gray[800]'
},
content: {
subtle: 'colors.gray[600]',
DEFAULT: 'colors.gray[500]',
emphasis: 'colors.gray[200]',
strong: 'colors.gray[50]',
inverted: 'colors.gray[950]'
}
},
epc_a: '#117d58',
epc_b: '#2da55c',
epc_c: '#8dbd40',
epc_d: '#f7cd14',
epc_e: '#f3a96a',
epc_f: '#ef8026',
epc_g: '#e41e3b',
brandblue: '#14163d',
hoverblue: '#3e4073',
brandtan: '#d3b488',
hovertan: '#947750',
brandgold: '#f1bb06',
hovergold: '#c79d12',
brandbrown: '#c4a47c',
brandmidblue: '#3943b7',
brandlightblue: '#00a9f4',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
textColor: {
brandblue: '#14163d',
hoverblue: '#3e4073',
brandtan: '#d3b488',
hovertan: '#947750',
brandbrown: '#c4a47c',
brandmidblue: '#3943b7',
brandlightblue: '#00a9f4'
},
borderRadius: {
'tremor-small': '0.375rem',
'tremor-default': '0.5rem',
'tremor-full': '9999px'
},
fontFamily: {
sans: [
'var(--font-sans)',
...fontFamily.sans
]
},
keyframes: {
'accordion-down': {
from: {
height: 0
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: 0
}
},
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
},
maxWidth: {
'8xl': '90rem'
},
boxShadow: {
'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'dark-tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'dark-tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
},
fontSize: {
'tremor-label': [
'0.75rem',
{
lineHeight: '1rem'
}
],
'tremor-default': [
'0.875rem',
{
lineHeight: '1.25rem'
}
],
'tremor-title': [
'1.125rem',
{
lineHeight: '1.75rem'
}
],
'tremor-metric': [
'1.875rem',
{
lineHeight: '2.25rem'
}
]
}
}
transparent: "transparent",
current: "currentColor",
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
colors: {
tremor: {
brand: {
faint: "colors.blue[50]",
muted: "colors.blue[200]",
subtle: "colors.blue[400]",
DEFAULT: "colors.blue[500]",
emphasis: "colors.blue[700]",
inverted: "colors.white",
},
background: {
muted: "colors.gray[50]",
subtle: "colors.gray[100]",
DEFAULT: "colors.white",
emphasis: "colors.gray[700]",
},
border: {
DEFAULT: "colors.gray[200]",
},
ring: {
DEFAULT: "colors.gray[200]",
},
content: {
subtle: "colors.gray[400]",
DEFAULT: "colors.gray[500]",
emphasis: "colors.gray[700]",
strong: "colors.gray[900]",
inverted: "colors.white",
},
},
"dark-tremor": {
brand: {
faint: "#0B1229",
muted: "colors.blue[950]",
subtle: "colors.blue[800]",
DEFAULT: "colors.blue[500]",
emphasis: "colors.blue[400]",
inverted: "colors.blue[950]",
},
background: {
muted: "#131A2B",
subtle: "colors.gray[800]",
DEFAULT: "colors.gray[900]",
emphasis: "colors.gray[300]",
},
border: {
DEFAULT: "colors.gray[800]",
},
ring: {
DEFAULT: "colors.gray[800]",
},
content: {
subtle: "colors.gray[600]",
DEFAULT: "colors.gray[500]",
emphasis: "colors.gray[200]",
strong: "colors.gray[50]",
inverted: "colors.gray[950]",
},
},
epc_a: "#117d58",
epc_b: "#2da55c",
epc_c: "#8dbd40",
epc_d: "#f7cd14",
epc_e: "#f3a96a",
epc_f: "#ef8026",
epc_g: "#e41e3b",
brandblue: "#14163d",
hoverblue: "#3e4073",
midblue: "#2d348f",
brandtan: "#d3b488",
hovertan: "#947750",
brandgold: "#f1bb06",
hovergold: "#c79d12",
brandbrown: "#c4a47c",
brandmidblue: "#3943b7",
brandlightblue: "#eff6fc",
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
textColor: {
brandblue: "#14163d",
hoverblue: "#3e4073",
brandtan: "#d3b488",
hovertan: "#947750",
brandbrown: "#c4a47c",
brandmidblue: "#3943b7",
brandlightblue: "#00a9f4",
},
borderRadius: {
"tremor-small": "0.375rem",
"tremor-default": "0.5rem",
"tremor-full": "9999px",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: {
height: 0,
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: 0,
},
},
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
maxWidth: {
"8xl": "90rem",
},
boxShadow: {
"tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
"dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"dark-tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"dark-tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
fontSize: {
"tremor-label": [
"0.75rem",
{
lineHeight: "1rem",
},
],
"tremor-default": [
"0.875rem",
{
lineHeight: "1.25rem",
},
],
"tremor-title": [
"1.125rem",
{
lineHeight: "1.75rem",
},
],
"tremor-metric": [
"1.875rem",
{
lineHeight: "2.25rem",
},
],
},
},
},
variants: {
extend: {