mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
commit
940d7128fd
27 changed files with 10447 additions and 1134 deletions
1857
package-lock.json
generated
1857
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
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 |
281
src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts
Normal file
281
src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 it’s 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 };
|
||||
|
|
|
|||
|
|
@ -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 collaborator’s 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 you’re 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function Beta() {
|
||||
return <div>You do not have access to this application currently</div>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
35
src/app/db/migrations/0117_colossal_bastion.sql
Normal file
35
src/app/db/migrations/0117_colossal_bastion.sql
Normal 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");
|
||||
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;
|
||||
3587
src/app/db/migrations/meta/0117_snapshot.json
Normal file
3587
src/app/db/migrations/meta/0117_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
"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_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">;
|
||||
|
|
|
|||
98
src/app/email_templates/magic_link.ts
Normal file
98
src/app/email_templates/magic_link.ts
Normal 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, "​.");
|
||||
|
||||
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 didn’t 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;">
|
||||
© ${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`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
© {new Date().getFullYear()} Domna. All rights reserved. Domna
|
||||
proprietary IP.
|
||||
|
|
|
|||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
611
src/app/onboarding/page.tsx
Normal file
611
src/app/onboarding/page.tsx
Normal 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",
|
||||
"2–5",
|
||||
"6–20",
|
||||
"21+",
|
||||
"1–50",
|
||||
"51–100",
|
||||
"101–300",
|
||||
"301–1000",
|
||||
"1000+",
|
||||
],
|
||||
{
|
||||
required_error:
|
||||
"Please tell us how many homes you’re 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 you’re 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 you’re 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">
|
||||
I’m a private landlord
|
||||
</SelectItem>
|
||||
<SelectItem value="private_tenant">
|
||||
I’m 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="2–5">2–5</SelectItem>
|
||||
<SelectItem value="6–20">6–20</SelectItem>
|
||||
<SelectItem value="21+">21+</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="1–50">1–50</SelectItem>
|
||||
<SelectItem value="51–100">51–100</SelectItem>
|
||||
<SelectItem value="101–300">101–300</SelectItem>
|
||||
<SelectItem value="301–1000">301–1000</SelectItem>
|
||||
<SelectItem value="1000+">1000+</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
I’d 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 app’s 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";
|
||||
|
|
|
|||
1
src/types/next-auth.d.ts
vendored
1
src/types/next-auth.d.ts
vendored
|
|
@ -10,5 +10,6 @@ declare module "next-auth" {
|
|||
}
|
||||
interface User {
|
||||
dbId: string;
|
||||
onboarded: boolean;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue