mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge pull request #107 from Hestia-Homes/main
funding filtering, new data tables
This commit is contained in:
commit
adab712fc7
73 changed files with 28905 additions and 2382 deletions
1
generate_migration.sh
Normal file
1
generate_migration.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx drizzle-kit generate
|
||||
2011
package-lock.json
generated
2011
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -18,6 +18,7 @@
|
|||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hubspot/api-client": "^13.4.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
|
@ -26,7 +27,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,10 +45,10 @@
|
|||
"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",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.233.0",
|
||||
"next": "^15.4.2",
|
||||
"next-auth": "^4.22.1",
|
||||
|
|
@ -56,6 +57,7 @@
|
|||
"pg": "^8.11.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "18.3.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
|
|
@ -70,10 +72,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 |
1
push_to_db.sh
Normal file
1
push_to_db.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
npx drizzle-kit push
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
243
src/app/api/auth/[...nextauth]/authOptions.ts
Normal file
243
src/app/api/auth/[...nextauth]/authOptions.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
||||
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 users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
} from "@/app/db/schema/users";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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" | "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,
|
||||
authorization: {
|
||||
params: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
response_type: "code",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Azure AD B2C ------------------
|
||||
AzureADB2CProvider({
|
||||
tenantId: AZURE_AD_B2C_TENANT_NAME,
|
||||
clientId: AZURE_AD_B2C_CLIENT_ID,
|
||||
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
|
||||
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile offline_access",
|
||||
prompt: "login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Email (SES Magic Link) ------------------
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: EMAIL_SERVER_HOST,
|
||||
port: Number(EMAIL_SERVER_PORT),
|
||||
auth: {
|
||||
user: EMAIL_SERVER_USER,
|
||||
pass: EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: EMAIL_FROM, // noreply email
|
||||
maxAge: 60 * 60, // magic link valid for 1 hour
|
||||
sendVerificationRequest: MagicLinksEmail,
|
||||
}),
|
||||
],
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pages
|
||||
// ------------------------------------------------------------------
|
||||
pages: {
|
||||
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, profile }) {
|
||||
try {
|
||||
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(users)
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
|
||||
// New user - next auth will handle
|
||||
if (!dbUser) {
|
||||
console.log("New user sign up for email:", normalisedEmail);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-link provider if same verified email but account not linked yet
|
||||
if (account?.provider && account.type === "oauth") {
|
||||
const existingLink = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, dbUser.id),
|
||||
eq(accounts.provider, account.provider)
|
||||
)
|
||||
);
|
||||
|
||||
const emailVerified =
|
||||
(profile as any)?.email_verified ?? account.provider === "google";
|
||||
|
||||
if (existingLink.length === 0 && emailVerified) {
|
||||
// This handles the case where we had not set up accounts but
|
||||
// signed up users with oauth
|
||||
console.log(
|
||||
`Linking ${account.provider} account for user ${normalisedEmail}`
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId: dbUser.id,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
id_token: account.id_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// Link OAuth ID if missing (helps for older accounts)
|
||||
if (account && !dbUser.oauthId) {
|
||||
console.log("Linking OAuth ID for user:", normalisedEmail);
|
||||
const provider = account.provider as OauthProvider;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ oauthId: user.id, oauthProvider: provider })
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
}
|
||||
|
||||
// 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);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (userFromLogin?.dbId) {
|
||||
token.dbId = userFromLogin.dbId;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach dbId to session.user
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.dbId) {
|
||||
session.user.dbId = token.dbId;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect users after login
|
||||
*/
|
||||
async redirect({ baseUrl }) {
|
||||
// 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,
|
||||
};
|
||||
|
|
@ -1,155 +1,5 @@
|
|||
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 { db } from "@/app/db/db";
|
||||
import { user as userTable, User } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const { GOOGLE_CLIENT_ID = "", GOOGLE_CLIENT_SECRET = "" } = process.env;
|
||||
const {
|
||||
AZURE_AD_B2C_TENANT_NAME = "",
|
||||
AZURE_AD_B2C_CLIENT_ID = "",
|
||||
AZURE_AD_B2C_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
|
||||
} = 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
|
||||
|
||||
export const AuthOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
authorization: {
|
||||
params: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
response_type: "code",
|
||||
},
|
||||
},
|
||||
}),
|
||||
AzureADB2CProvider({
|
||||
tenantId: AZURE_AD_B2C_TENANT_NAME,
|
||||
clientId: AZURE_AD_B2C_CLIENT_ID,
|
||||
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
|
||||
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile offline_access",
|
||||
prompt: "login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "Email Login",
|
||||
credentials: {
|
||||
email: {
|
||||
label: "Email",
|
||||
type: "email",
|
||||
},
|
||||
},
|
||||
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;
|
||||
},
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/",
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
try {
|
||||
if (user === null || user.email === null) {
|
||||
return "/beta";
|
||||
}
|
||||
const dbUser: User[] = await db
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(eq(userTable.email, String(user.email)));
|
||||
|
||||
if (dbUser.length > 1) {
|
||||
console.error(`Multiple users found with email ${user.email}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
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");
|
||||
const provider = account.provider as OauthProvider;
|
||||
|
||||
await db
|
||||
.update(userTable)
|
||||
.set({ oauthId: user.id, oauthProvider: provider })
|
||||
.where(eq(userTable.email, String(user.email)));
|
||||
|
||||
console.log("Updated oauthId and oauthProvider");
|
||||
}
|
||||
|
||||
// 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();
|
||||
return true;
|
||||
} catch (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;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session?.user) {
|
||||
session.user.dbId = token.dbId;
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
async redirect({ baseUrl }) {
|
||||
const redirectUrl = baseUrl + "/home";
|
||||
return redirectUrl;
|
||||
},
|
||||
},
|
||||
};
|
||||
import NextAuth from "next-auth";
|
||||
import { AuthOptions } from "./authOptions";
|
||||
|
||||
const handler = NextAuth(AuthOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
|
|
|||
81
src/app/api/book-survey/route.ts
Normal file
81
src/app/api/book-survey/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// app/api/book-survey/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/app/db/db";
|
||||
import { propertyStatusTracker } from "@/app/db/schema/crm/property_status_tracker";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { dealName, pipelineId, dealStageId, propertyId, portfolioId } =
|
||||
await req.json();
|
||||
|
||||
// 1️⃣ Create HubSpot deal
|
||||
const hsRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
dealname: dealName,
|
||||
pipeline: pipelineId,
|
||||
dealstage: dealStageId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!hsRes.ok) {
|
||||
const err = await hsRes.text();
|
||||
throw new Error(`HubSpot error: ${err}`);
|
||||
}
|
||||
|
||||
const hsData = await hsRes.json();
|
||||
const hubspotDealId = hsData.id;
|
||||
|
||||
// 2️⃣ Check if record exists for property + portfolio
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(propertyStatusTracker)
|
||||
.where(
|
||||
and(
|
||||
eq(propertyStatusTracker.propertyId, propertyId),
|
||||
eq(propertyStatusTracker.portfolioId, portfolioId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 3️⃣ Update existing record
|
||||
await db
|
||||
.update(propertyStatusTracker)
|
||||
.set({
|
||||
hubspotDealId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(propertyStatusTracker.propertyId, propertyId),
|
||||
eq(propertyStatusTracker.portfolioId, portfolioId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// 4️⃣ Create new record
|
||||
await db.insert(propertyStatusTracker).values({
|
||||
hubspotDealId: hubspotDealId,
|
||||
propertyId: propertyId,
|
||||
portfolioId: portfolioId,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: existing.length > 0 ? "Updated existing tracker" : "Created new tracker",
|
||||
dealId: hubspotDealId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error creating or updating HubSpot deal:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/app/api/postcode/FormatOsResults.ts
Normal file
18
src/app/api/postcode/FormatOsResults.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Extracts simplified address list from OS Places response.
|
||||
*/
|
||||
export function formatOsResults(apiResponse: any) {
|
||||
if (!apiResponse?.results) return [];
|
||||
|
||||
return apiResponse.results
|
||||
.map((entry: any) => {
|
||||
const dpa = entry.DPA;
|
||||
if (!dpa) return null;
|
||||
|
||||
return {
|
||||
uprn: dpa.UPRN,
|
||||
address: dpa.ADDRESS,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
148
src/app/api/postcode/LookupOsPlaces.ts
Normal file
148
src/app/api/postcode/LookupOsPlaces.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { OSPlacesHeader, OSPlacesResponse } from "@/app/db/schema/addresses";
|
||||
|
||||
const OS_API_URL = "https://api.os.uk/search/places/v1/postcode";
|
||||
const OS_API_KEY = process.env.OS_API_KEY!;
|
||||
|
||||
type OSPlacesItem = { DPA?: Record<string, any>; LPI?: Record<string, any> };
|
||||
|
||||
export interface OSPlacesResult {
|
||||
status: number;
|
||||
// First page response (kept for completeness)
|
||||
data?: OSPlacesResponse;
|
||||
// All results across pages (flattened)
|
||||
results?: OSPlacesItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single page from OS Places
|
||||
*/
|
||||
async function fetchOsPlacesPage(
|
||||
postcode: string,
|
||||
offset: number,
|
||||
maxResults: number,
|
||||
retries: number,
|
||||
delay: number
|
||||
): Promise<{ status: number; data?: OSPlacesResponse; error?: string }> {
|
||||
const url = `${OS_API_URL}?postcode=${encodeURIComponent(
|
||||
postcode
|
||||
)}&key=${OS_API_KEY}&maxresults=${maxResults}&offset=${offset}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
|
||||
if (res.ok) {
|
||||
const data: OSPlacesResponse = await res.json();
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// Retry on transient issues (rate limit, server errors)
|
||||
if ([429, 500, 503].includes(res.status) && retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return fetchOsPlacesPage(
|
||||
postcode,
|
||||
offset,
|
||||
maxResults,
|
||||
retries - 1,
|
||||
delay * 2
|
||||
);
|
||||
}
|
||||
|
||||
// Map known errors
|
||||
let errorMessage = "Unexpected error";
|
||||
switch (res.status) {
|
||||
case 400:
|
||||
errorMessage = "Bad request — malformed postcode or query";
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = "Unauthorized — check OS API key";
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = "Forbidden — insufficient permissions for API access";
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = "Postcode not found";
|
||||
break;
|
||||
case 405:
|
||||
errorMessage = "Method not allowed";
|
||||
break;
|
||||
case 429:
|
||||
errorMessage = "Rate limit exceeded — too many requests";
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = "Service temporarily unavailable";
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Unhandled error (${res.status})`;
|
||||
}
|
||||
return { status: res.status, error: errorMessage };
|
||||
} catch (err: any) {
|
||||
if (retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return fetchOsPlacesPage(
|
||||
postcode,
|
||||
offset,
|
||||
maxResults,
|
||||
retries - 1,
|
||||
delay * 2
|
||||
);
|
||||
}
|
||||
return { status: 500, error: err?.message || "Network error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Ordnance Survey Places API for a given postcode,
|
||||
* with offset-based pagination, retry handling, and structured errors.
|
||||
*/
|
||||
export async function lookupOsPlaces(
|
||||
postcode: string,
|
||||
{
|
||||
maxResults = 100,
|
||||
retries = 2,
|
||||
delay = 1000,
|
||||
}: { maxResults?: number; retries?: number; delay?: number } = {}
|
||||
): Promise<OSPlacesResult> {
|
||||
const normalized = postcode.toUpperCase().trim();
|
||||
|
||||
// Page 1
|
||||
const first = await fetchOsPlacesPage(
|
||||
normalized,
|
||||
0,
|
||||
maxResults,
|
||||
retries,
|
||||
delay
|
||||
);
|
||||
if (first.error || !first.data) {
|
||||
return { status: first.status, error: first.error };
|
||||
}
|
||||
|
||||
const total =
|
||||
first.data.header?.totalresults ?? first.data.results?.length ?? 0;
|
||||
let allResults: OSPlacesItem[] = first.data.results ?? [];
|
||||
|
||||
// If more pages exist, fetch them in a loop
|
||||
for (let offset = maxResults; offset < total; offset += maxResults) {
|
||||
const page = await fetchOsPlacesPage(
|
||||
normalized,
|
||||
offset,
|
||||
maxResults,
|
||||
retries,
|
||||
delay
|
||||
);
|
||||
if (page.error || !page.data) {
|
||||
// Return what we have so far but indicate partial failure
|
||||
return {
|
||||
status: page.status,
|
||||
data: first.data,
|
||||
results: allResults,
|
||||
error: `Failed to fetch page at offset ${offset}: ${page.error ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
if (page.data.results?.length) {
|
||||
allResults = allResults.concat(page.data.results);
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200, data: first.data, results: allResults };
|
||||
}
|
||||
59
src/app/api/postcode/LookupPostcode.ts
Normal file
59
src/app/api/postcode/LookupPostcode.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// src/app/utils/postcodes.ts
|
||||
|
||||
export interface PostcodeLookupResult {
|
||||
status: number;
|
||||
result?: {
|
||||
postcode: string;
|
||||
country: string;
|
||||
region: string | null;
|
||||
admin_district: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a postcode using postcodes.io.
|
||||
* Includes automatic retry logic for transient 5xx errors.
|
||||
*/
|
||||
export async function lookupPostcode(
|
||||
postcode: string,
|
||||
retries = 2
|
||||
): Promise<PostcodeLookupResult> {
|
||||
const url = `https://api.postcodes.io/postcodes/${encodeURIComponent(
|
||||
postcode.trim()
|
||||
)}`;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
// Retry only on transient 5xx errors
|
||||
if (res.status >= 500 && attempt < retries) {
|
||||
console.warn(`Retrying postcode lookup (attempt ${attempt + 1})`);
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
if (attempt < retries) {
|
||||
console.warn(`Network error on attempt ${attempt + 1}, retrying...`);
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||
} else {
|
||||
return {
|
||||
status: 500,
|
||||
error: "Network error while contacting postcodes.io",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here
|
||||
return { status: 500, error: "Unexpected error" };
|
||||
}
|
||||
99
src/app/api/postcode/OsPlacesToFlat.ts
Normal file
99
src/app/api/postcode/OsPlacesToFlat.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
export type FlatAddress = {
|
||||
uprn: string;
|
||||
address: string;
|
||||
propertyType?: PropertyType;
|
||||
builtForm?: BuiltForm;
|
||||
};
|
||||
|
||||
export type PropertyType = "House" | "Flat" | "Maisonette" | "Bungalow";
|
||||
export type BuiltForm =
|
||||
| "Detached"
|
||||
| "Semi-Detached"
|
||||
| "Mid-Terrace"
|
||||
| "End-Terrace"
|
||||
| "Enclosed End-Terrace"
|
||||
| "Enclosed Mid-Terrace";
|
||||
|
||||
export function mapOsClassToProperty(classification?: string): {
|
||||
propertyType?: PropertyType;
|
||||
builtForm?: BuiltForm;
|
||||
} {
|
||||
if (!classification) return {};
|
||||
|
||||
const code = classification.toUpperCase();
|
||||
|
||||
if (code.startsWith("RD02")) {
|
||||
return { propertyType: "House", builtForm: "Detached" };
|
||||
}
|
||||
if (code.startsWith("RD03")) {
|
||||
return { propertyType: "House", builtForm: "Semi-Detached" };
|
||||
}
|
||||
if (code.startsWith("RD04")) {
|
||||
return { propertyType: "House", builtForm: "Mid-Terrace" };
|
||||
}
|
||||
if (code.startsWith("RD06")) {
|
||||
return { propertyType: "Flat" };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function inferPropertyFromAddress(address: string): {
|
||||
propertyType?: PropertyType;
|
||||
builtForm?: BuiltForm;
|
||||
} {
|
||||
const addr = address.toLowerCase();
|
||||
|
||||
// Detect explicit mentions
|
||||
if (addr.includes("bungalow")) return { propertyType: "Bungalow" };
|
||||
if (addr.includes("maisonette")) return { propertyType: "Maisonette" };
|
||||
if (addr.includes("flat") || addr.includes("apartment"))
|
||||
return { propertyType: "Flat" };
|
||||
|
||||
// If it says "terrace" in address, but no RD code, assume terraced house
|
||||
if (addr.includes("terrace") || addr.includes("terraced"))
|
||||
return { propertyType: "House", builtForm: "Mid-Terrace" };
|
||||
|
||||
// Minimal fallback if text only shows 'house' or street number
|
||||
if (addr.match(/\d+\s+[a-z]/)) return { propertyType: "House" };
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function mapOsPlacesToFlat(
|
||||
results?: Array<{ DPA?: any; LPI?: any }>
|
||||
): FlatAddress[] {
|
||||
if (!results?.length) return [];
|
||||
|
||||
const items = results
|
||||
.map((r) => r.DPA ?? r.LPI)
|
||||
.filter(Boolean)
|
||||
.map((rec: any) => {
|
||||
const classification = rec.CLASSIFICATION_CODE as string | undefined;
|
||||
const base = mapOsClassToProperty(classification);
|
||||
const textBased = inferPropertyFromAddress(String(rec.ADDRESS ?? ""));
|
||||
|
||||
// Merge — classification has priority, but text fills in missing gaps
|
||||
const propertyType = base.propertyType ?? textBased.propertyType;
|
||||
const builtForm = base.builtForm ?? textBased.builtForm;
|
||||
|
||||
return {
|
||||
uprn: String(rec.UPRN ?? ""),
|
||||
address: String(rec.ADDRESS ?? ""),
|
||||
propertyType,
|
||||
builtForm,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.uprn && x.address);
|
||||
|
||||
// De-duplicate by UPRN
|
||||
const seen = new Set<string>();
|
||||
const deduped: FlatAddress[] = [];
|
||||
for (const it of items) {
|
||||
if (!seen.has(it.uprn)) {
|
||||
seen.add(it.uprn);
|
||||
deduped.push(it);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
75
src/app/api/postcode/[postcode]/addresses/route.ts
Normal file
75
src/app/api/postcode/[postcode]/addresses/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { db } from "@/app/db/db";
|
||||
import { postcodeSearch } from "@/app/db/schema/addresses";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { lookupOsPlaces } from "@/app/api/postcode/LookupOsPlaces";
|
||||
import { mapOsPlacesToFlat } from "@/app/api/postcode/OsPlacesToFlat";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ postcode: string }> }
|
||||
) {
|
||||
const { postcode } = await props.params;
|
||||
if (!postcode || typeof postcode !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid postcode" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: check cache
|
||||
const cached = await db
|
||||
.select()
|
||||
.from(postcodeSearch)
|
||||
.where(eq(postcodeSearch.postcode, postcode))
|
||||
.limit(1);
|
||||
|
||||
if (cached.length > 0) {
|
||||
console.log("Using cached OS Places data for postcode:", postcode);
|
||||
const record = cached[0];
|
||||
const addresses = mapOsPlacesToFlat(record.resultData?.results);
|
||||
return NextResponse.json({
|
||||
status: 200,
|
||||
source: "cache",
|
||||
total: record.resultData?.header?.totalresults ?? addresses.length,
|
||||
results: addresses,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: if no cache, query OS Places API
|
||||
const result = await lookupOsPlaces(postcode);
|
||||
if (result.error || result.status !== 200 || !result.data) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error ?? "Failed to fetch address data",
|
||||
status: result.status,
|
||||
},
|
||||
{ status: result.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: flatten and cache the result
|
||||
const addresses = mapOsPlacesToFlat(result.results);
|
||||
const total = result.data.header?.totalresults ?? addresses.length;
|
||||
|
||||
await db.insert(postcodeSearch).values({
|
||||
postcode: postcode,
|
||||
resultData: result.data,
|
||||
});
|
||||
|
||||
// Step 4: return results
|
||||
return NextResponse.json({
|
||||
status: 200,
|
||||
source: "live",
|
||||
total,
|
||||
results: addresses,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching OS Places data:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/postcode/[postcode]/route.ts
Normal file
31
src/app/api/postcode/[postcode]/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { lookupPostcode } from "../LookupPostcode";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ postcode: string }> }
|
||||
) {
|
||||
const { postcode } = await props.params;
|
||||
|
||||
if (!postcode || typeof postcode !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid postcode" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await lookupPostcode(postcode);
|
||||
|
||||
if (data.status === 404) {
|
||||
return NextResponse.json({ error: "Invalid postcode" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (data.status !== 200 || !data.result) {
|
||||
return NextResponse.json(
|
||||
{ error: data.error || "Postcode lookup failed" },
|
||||
{ status: data.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ result: data.result });
|
||||
}
|
||||
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]/authOptions";
|
||||
|
||||
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]/authOptions";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
|
@ -8,11 +8,13 @@ import {
|
|||
HoverCardTrigger,
|
||||
} from "@/app/shadcn_components/ui/hover-card";
|
||||
|
||||
type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS";
|
||||
|
||||
export default function StatusBadge({
|
||||
status,
|
||||
isProperty = false,
|
||||
}: {
|
||||
status: (typeof PortfolioStatus)[number];
|
||||
status: ExtendedStatus;
|
||||
isProperty?: boolean;
|
||||
}) {
|
||||
const statusConfig = statusColor[status];
|
||||
|
|
@ -45,7 +47,7 @@ export default function StatusBadge({
|
|||
}
|
||||
|
||||
const statusColor: {
|
||||
[key in (typeof PortfolioStatus)[number]]: {
|
||||
[key in ExtendedStatus]: {
|
||||
class: string;
|
||||
text: string;
|
||||
hoverText: string;
|
||||
|
|
@ -59,8 +61,9 @@ const statusColor: {
|
|||
propertyHoverText: "This property is currently in scoping",
|
||||
},
|
||||
assessment: {
|
||||
class: "bg-emerald-400 hover:bg-emerald-500 truncate text-overflow: ellipsis",
|
||||
text: "Non-invasive Assessment",
|
||||
class:
|
||||
"bg-emerald-400 hover:bg-emerald-500 truncate text-overflow: ellipsis",
|
||||
text: "Remote Assessment",
|
||||
hoverText: "This portfolio is currently in the assessment stage",
|
||||
propertyHoverText: "This property is currently in the assessment stage",
|
||||
},
|
||||
|
|
@ -114,4 +117,16 @@ const statusColor: {
|
|||
hoverText: "The works in this portfolio has been completed and need review",
|
||||
propertyHoverText: "The works on this property have been completed",
|
||||
},
|
||||
ECO4: {
|
||||
class: "bg-brandblue hover:bg-hoverblue",
|
||||
text: "ECO4",
|
||||
hoverText: "This property is funded under the ECO4 scheme",
|
||||
propertyHoverText: "This property is funded under the ECO4 scheme",
|
||||
},
|
||||
GBIS: {
|
||||
class: "bg-brandmidblue hover:bg-hoverblue",
|
||||
text: "GBIS",
|
||||
hoverText: "This property is funded under the GBIS scheme",
|
||||
propertyHoverText: "This property is funded under the GBIS scheme",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
NewspaperIcon,
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
SunIcon,
|
||||
CircleStackIcon,
|
||||
HeartIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -15,12 +17,17 @@ import {
|
|||
NavigationMenuList,
|
||||
NavigationMenuLink,
|
||||
} from "@/app/shadcn_components/ui/navigation-menu";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
|
||||
import BookSurveyModal from "@/app/portfolio/[slug]/components/BookSurveyModal";
|
||||
import BookingSuccessToast from "@/app/portfolio/[slug]/components/BookingSuccessToast";
|
||||
import { PropertyMeta } from "@/app/db/schema/property";
|
||||
|
||||
interface ToolbarProps {
|
||||
propertyId: string;
|
||||
portfolioId: string;
|
||||
propertyMeta: PropertyMeta;
|
||||
decentHomes: getUploadedFile;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +57,6 @@ const navigationMenuTriggerStyle = cva(
|
|||
"disabled:opacity-50",
|
||||
"data-[active]:bg-accent/50",
|
||||
"data-[state=open]:bg-gray-200",
|
||||
//
|
||||
"text-gray-900",
|
||||
].join(" ")
|
||||
);
|
||||
|
|
@ -58,8 +64,12 @@ const navigationMenuTriggerStyle = cva(
|
|||
export function Toolbar({
|
||||
propertyId,
|
||||
portfolioId,
|
||||
propertyMeta,
|
||||
decentHomes,
|
||||
}: ToolbarProps) {
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
function handleClickSettings() {
|
||||
console.log("Settings were clicked, implement me");
|
||||
}
|
||||
|
|
@ -115,33 +125,67 @@ export function Toolbar({
|
|||
);
|
||||
|
||||
return (
|
||||
<NavigationMenu>
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
href={`/portfolio/${portfolioId}/building-passport/${propertyId}`}
|
||||
>
|
||||
<HomeModernIcon className="h-4 w-4 mr-2" />
|
||||
Summary
|
||||
</NavigationMenuLink>
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{/* Left side: navigation */}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
href={`/portfolio/${portfolioId}/building-passport/${propertyId}`}
|
||||
>
|
||||
<HomeModernIcon className="h-4 w-4 mr-2" />
|
||||
Summary
|
||||
</NavigationMenuLink>
|
||||
|
||||
<NavigationMenuList>
|
||||
{preAssessmentReportButton}
|
||||
{/* We only show decent homes button if decent homes is not an empty object */}
|
||||
{Object.keys(decentHomes).length > 0 &&
|
||||
decentHomes.uprn &&
|
||||
decentHomesButton}
|
||||
{solarAnalysisButton}
|
||||
{recommendationsButton}
|
||||
{documentsButton}
|
||||
<NavigationMenuList>
|
||||
{preAssessmentReportButton}
|
||||
{Object.keys(decentHomes).length > 0 &&
|
||||
decentHomes.uprn &&
|
||||
decentHomesButton}
|
||||
{solarAnalysisButton}
|
||||
{recommendationsButton}
|
||||
{documentsButton}
|
||||
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickSettings}
|
||||
>
|
||||
<Cog6ToothIcon className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickSettings}
|
||||
>
|
||||
<Cog6ToothIcon className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
|
||||
{/* ✅ Right side: Book a Survey button */}
|
||||
<div className="mr-3">
|
||||
<Button
|
||||
onClick={() => setOpenModal(true)}
|
||||
className="bg-brandblue text-white hover:bg-branddarkblue flex items-center"
|
||||
>
|
||||
Book a Survey
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Modal */}
|
||||
{openModal && (
|
||||
<BookSurveyModal
|
||||
open={openModal}
|
||||
onOpenChange={setOpenModal}
|
||||
propertyId={BigInt(propertyId)}
|
||||
portfolioId={portfolioId}
|
||||
address={propertyMeta.address}
|
||||
onSuccess={() => setShowToast(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ✅ Toast */}
|
||||
<BookingSuccessToast
|
||||
show={showToast}
|
||||
onClose={() => setShowToast(false)}
|
||||
message="Survey Booked Successfully!"
|
||||
subtext="Your Survey Request is with Domna and we will be in contact. 🎉"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,12 +87,11 @@ export default function ValuationImpactComponent({
|
|||
setFundingModalIsOpen(true);
|
||||
}
|
||||
|
||||
function renderCurrency(value: number | null) {
|
||||
return value ? (
|
||||
`£${formatNumber(value)}`
|
||||
) : (
|
||||
<span className="text-gray-300 italic">Not available</span>
|
||||
);
|
||||
function formatPercent(value: number | null): string {
|
||||
if (value === null || isNaN(value)) return "–";
|
||||
const percent = value * 100;
|
||||
// Show no decimals if it's a whole number, otherwise 1 decimal
|
||||
return Number.isInteger(percent) ? percent.toString() : percent.toFixed(1);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -110,7 +109,9 @@ export default function ValuationImpactComponent({
|
|||
{/* After Retrofit Valuation */}
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-1">
|
||||
<span className="text-gray-100 text-lg">After Retrofit Valuation</span>
|
||||
{lowerBoundValuation && upperBoundValuation ? (
|
||||
|
||||
{currentValuation && lowerBoundValuation && upperBoundValuation ? (
|
||||
// CASE 1: Absolute £ valuations available
|
||||
<>
|
||||
<div className="text-2xl text-brandbrown font-bold">
|
||||
£{formatNumber(lowerBoundValuation)} - £
|
||||
|
|
@ -124,7 +125,26 @@ export default function ValuationImpactComponent({
|
|||
{formatNumber(valuationIncreaseUpperBound || 0)}
|
||||
</span>
|
||||
</>
|
||||
) : valuationIncreaseLowerBound !== null &&
|
||||
valuationIncreaseUpperBound !== null ? (
|
||||
// CASE 2: No base valuation, show % improvement
|
||||
<>
|
||||
<div className="text-2xl font-bold text-brandbrown flex items-baseline justify-center">
|
||||
<span className="text-3xl font-extrabold text-brandbrown mr-1">
|
||||
+
|
||||
</span>
|
||||
<span>{formatPercent(valuationIncreaseLowerBound)}</span>
|
||||
<span className="mx-1">–</span>
|
||||
<span>{formatPercent(valuationIncreaseUpperBound)}</span>
|
||||
<span className="ml-1">%</span>
|
||||
</div>
|
||||
|
||||
<span className="text-gray-100 text-lg">
|
||||
Estimated value improvement
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// CASE 3: Nothing available
|
||||
<span className="text-lg text-gray-300 italic">Not available</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import {
|
|||
NavigationMenuLink,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/app/shadcn_components/ui/navigation-menu";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
PlusIcon,
|
||||
TableCellsIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
|
@ -41,32 +41,32 @@ const ListItem = React.forwardRef<
|
|||
ListItem.displayName = "ListItem";
|
||||
|
||||
export default function AddNewDropDown({
|
||||
portfolioId,
|
||||
isUploadCsvOpen,
|
||||
setIsUploadCsvOpen,
|
||||
isRemoteAssessmentOpen,
|
||||
setIsRemoteAssessmentOpen,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
isUploadCsvOpen: boolean;
|
||||
setIsUploadCsvOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isRemoteAssessmentOpen: boolean;
|
||||
setIsRemoteAssessmentOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
function handleCickAddUnit() {
|
||||
console.log("Add unit");
|
||||
}
|
||||
|
||||
function handleClickUploadCSV() {
|
||||
setIsUploadCsvOpen(!isUploadCsvOpen);
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleClickRemoteAssessment() {
|
||||
setIsRemoteAssessmentOpen(!isRemoteAssessmentOpen);
|
||||
router.push(`/portfolio/${portfolioId}/remote-assessment`);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="bg-gray-50 text-gray-900">
|
||||
Add New
|
||||
New Property
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="p-6 md:w-[200px] lg:w-[350px] lg:grid-cols-[.75fr_1fr] cursor-pointer">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
import AddNewDropDown from "./AddNew";
|
||||
import { cva } from "class-variance-authority";
|
||||
import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
|
||||
import RemoteAssessmentModal from "@/app/portfolio/[slug]/components/RemoteAssessmentModal";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
|
|
@ -97,18 +96,13 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
|
|||
</NavigationMenuItem>
|
||||
|
||||
<AddNewDropDown
|
||||
portfolioId={portfolioId}
|
||||
isUploadCsvOpen={modalIsOpen}
|
||||
setIsUploadCsvOpen={setModalIsOpen}
|
||||
isRemoteAssessmentOpen={isRemoteAssessmentOpen}
|
||||
setIsRemoteAssessmentOpen={setIsRemoteAssessmentOpen}
|
||||
/>
|
||||
</NavigationMenuList>
|
||||
<RemoteAssessmentModal
|
||||
isOpen={isRemoteAssessmentOpen}
|
||||
setIsOpen={setIsRemoteAssessmentOpen}
|
||||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
/>
|
||||
<UploadCsvModal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={setModalIsOpen}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
12
src/app/db/migrations/0119_marvelous_blur.sql
Normal file
12
src/app/db/migrations/0119_marvelous_blur.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE "property_status_tracker" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"hubspot_deal_id" text NOT NULL,
|
||||
"property_id" bigint NOT NULL,
|
||||
"portfolio_id" bigint NOT NULL,
|
||||
"hubspot_pipeline_id" text NOT NULL,
|
||||
"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 "property_status_tracker" ADD CONSTRAINT "property_status_tracker_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "property_status_tracker" ADD CONSTRAINT "property_status_tracker_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;
|
||||
1
src/app/db/migrations/0120_flashy_puck.sql
Normal file
1
src/app/db/migrations/0120_flashy_puck.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_status_tracker" DROP COLUMN "hubspot_pipeline_id";
|
||||
7
src/app/db/migrations/0121_chunky_tony_stark.sql
Normal file
7
src/app/db/migrations/0121_chunky_tony_stark.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE "postcode_search" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"postcode" text NOT NULL,
|
||||
"result_data" jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "postcode_search_postcode_unique" UNIQUE("postcode")
|
||||
);
|
||||
35
src/app/db/migrations/0122_yielding_morlocks.sql
Normal file
35
src/app/db/migrations/0122_yielding_morlocks.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
CREATE TYPE "public"."inspection_archetype_2" AS ENUM('detached', 'mid-terrace', 'enclosed mid-terrace', 'end-terrace', 'enclosed end-terrace', 'semi-detached');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspection_archetype" AS ENUM('Bungalow', 'Flat', 'Maisonette', 'House', 'non-domestic');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspection_borescoped" AS ENUM('yes', 'no', 'refused');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_access_issues" AS ENUM('see notes', 'damp issues', 'foliage on walls', 'bushes against wall', 'trees around/anove property', 'high rise block flats/maisonettes', 'conservatory', 'lean-to', 'garage', 'extension', 'decking', 'shed against wall');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_cladding" AS ENUM('none', 'cladded with “sufficient space to fill the wall”', 'cladded with “insufficient space to fill the wall”');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_insulation_material" AS ENUM('empty 50-90', 'empty 100+', 'empty 30-40', 'empty less than 30', 'loose fibre/wool', 'eps/celo/king', 'fibre batts - with cavity', 'fibre batts - no cavity', 'loose bead', 'glued bead', 'formaldehyde', 'bubble wrap', 'poly chunks');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_rendered" AS ENUM('no render', 'rendered with “insufficient” space between dpc and render', 'rendered with “sufficient” space between dpc and render');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_roof_orientation" AS ENUM('north', 'east', 'south', 'west', 'north-east', 'north-west', 'south-east', 'south-west', 'n/s split', 'e/w split', 'ne/sw split', 'nw/se split', 'flat roof', 'no roof', 'roof too small', 'already has solar pv');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_tile_hung" AS ENUM('yes', 'no', 'first floor flats are tile hung');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_wall_construction" AS ENUM('cavity', 'solid', 'system built', 'timber framed', 'steel framed', 're-walled cavity', 'mansard pre-fab', 'mansard ewi', 'mansard re-walled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."inspections_wall_insulation" AS ENUM('empty cavity', 'filled at build', 'partial', 'retro drilled', 'ewi', 'iwi', 'solid non-cavity', 'system built', 'timber framed', 'steel framed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."plan_type" AS ENUM('solar_eco4', 'solar_hhrsh_eco4', 'empty_cavity_eco', 'partial_cavity_eco', 'extraction_eco');--> statement-breakpoint
|
||||
CREATE TABLE "inspections" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"property_id" bigint NOT NULL,
|
||||
"archetype" "inspection_archetype",
|
||||
"archetype_2" "inspection_archetype_2",
|
||||
"wall_construction" "inspections_wall_construction",
|
||||
"insulation" "inspections_wall_insulation",
|
||||
"insulation_material" "inspections_insulation_material",
|
||||
"borescoped" "inspection_borescoped",
|
||||
"roof_orientation" "inspections_roof_orientation",
|
||||
"tile_hung" "inspections_tile_hung",
|
||||
"rendered" "inspections_rendered",
|
||||
"cladding" "inspections_cladding",
|
||||
"access_issues" "inspections_access_issues",
|
||||
"notes" text,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"surveyor_name" text,
|
||||
"uploaded_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "property" ADD COLUMN "landlord_property_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "plan" ADD COLUMN "plan_type" "plan_type";--> statement-breakpoint
|
||||
ALTER TABLE "inspections" ADD CONSTRAINT "inspections_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action 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
3835
src/app/db/migrations/meta/0119_snapshot.json
Normal file
3835
src/app/db/migrations/meta/0119_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3829
src/app/db/migrations/meta/0120_snapshot.json
Normal file
3829
src/app/db/migrations/meta/0120_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3875
src/app/db/migrations/meta/0121_snapshot.json
Normal file
3875
src/app/db/migrations/meta/0121_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4189
src/app/db/migrations/meta/0122_snapshot.json
Normal file
4189
src/app/db/migrations/meta/0122_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -820,6 +820,48 @@
|
|||
"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
|
||||
},
|
||||
{
|
||||
"idx": 119,
|
||||
"version": "7",
|
||||
"when": 1760711090309,
|
||||
"tag": "0119_marvelous_blur",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 120,
|
||||
"version": "7",
|
||||
"when": 1761146299937,
|
||||
"tag": "0120_flashy_puck",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 121,
|
||||
"version": "7",
|
||||
"when": 1761218186670,
|
||||
"tag": "0121_chunky_tony_stark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 122,
|
||||
"version": "7",
|
||||
"when": 1761587998488,
|
||||
"tag": "0122_yielding_morlocks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
35
src/app/db/schema/addresses.ts
Normal file
35
src/app/db/schema/addresses.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { pgTable, serial, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// This table stores postcode search results from the OS Places API
|
||||
// for re-use and caching purposes. The data is stored in jsonb format, to
|
||||
// allow for fast queries and flexibility with the API response structure.
|
||||
|
||||
export interface OSPlacesHeader {
|
||||
totalresults?: number;
|
||||
offset?: number;
|
||||
maxresults?: number;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export interface OSPlacesItem {
|
||||
DPA?: Record<string, any>;
|
||||
LPI?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface OSPlacesResponse {
|
||||
header?: OSPlacesHeader;
|
||||
results?: OSPlacesItem[];
|
||||
}
|
||||
|
||||
export const postcodeSearch = pgTable("postcode_search", {
|
||||
id: serial("id").primaryKey(),
|
||||
|
||||
// Normalized postcode (uppercase, no spaces)
|
||||
postcode: text("postcode").notNull().unique(),
|
||||
|
||||
// Full OS Places API response
|
||||
resultData: jsonb("result_data").$type<OSPlacesResponse>().notNull(),
|
||||
|
||||
// Timestamp for when the entry was first created
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
29
src/app/db/schema/crm/property_status_tracker.ts
Normal file
29
src/app/db/schema/crm/property_status_tracker.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { pgTable, uuid, text, bigint, timestamp } from "drizzle-orm/pg-core";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
import { property } from "../property";
|
||||
import { portfolio } from "../portfolio";
|
||||
|
||||
export const propertyStatusTracker = pgTable("property_status_tracker", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
hubspotDealId: text("hubspot_deal_id").notNull(),
|
||||
|
||||
// foreign keys
|
||||
propertyId: bigint("property_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => property.id, { onDelete: "cascade" }),
|
||||
|
||||
portfolioId: bigint("portfolio_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id, { onDelete: "cascade" }),
|
||||
|
||||
|
||||
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
190
src/app/db/schema/inspections.ts
Normal file
190
src/app/db/schema/inspections.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import {
|
||||
bigserial,
|
||||
text,
|
||||
timestamp,
|
||||
pgTable,
|
||||
pgEnum,
|
||||
bigint,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { property } from "./property";
|
||||
|
||||
const inspection_archetypes: [string, ...string[]] = [
|
||||
"Bungalow",
|
||||
"Flat",
|
||||
"Maisonette",
|
||||
"House",
|
||||
"non-domestic",
|
||||
];
|
||||
export const inspectionArchetypeEnum = pgEnum(
|
||||
"inspection_archetype",
|
||||
inspection_archetypes
|
||||
);
|
||||
|
||||
const inspection_archetypes_2: [string, ...string[]] = [
|
||||
"detached",
|
||||
"mid-terrace",
|
||||
"enclosed mid-terrace",
|
||||
"end-terrace",
|
||||
"enclosed end-terrace",
|
||||
"semi-detached",
|
||||
];
|
||||
export const inspectionArchetype2Enum = pgEnum(
|
||||
"inspection_archetype_2",
|
||||
inspection_archetypes_2
|
||||
);
|
||||
|
||||
const inspections_wall_constructions: [string, ...string[]] = [
|
||||
"cavity",
|
||||
"solid",
|
||||
"system built",
|
||||
"timber framed",
|
||||
"steel framed",
|
||||
"re-walled cavity",
|
||||
"mansard pre-fab",
|
||||
"mansard ewi",
|
||||
"mansard re-walled",
|
||||
];
|
||||
export const inspectionsWallConstructionEnum = pgEnum(
|
||||
"inspections_wall_construction",
|
||||
inspections_wall_constructions
|
||||
);
|
||||
|
||||
const inspections_wall_insulation: [string, ...string[]] = [
|
||||
"empty cavity",
|
||||
"filled at build",
|
||||
"partial",
|
||||
"retro drilled",
|
||||
"ewi",
|
||||
"iwi",
|
||||
"solid non-cavity",
|
||||
"system built",
|
||||
"timber framed",
|
||||
"steel framed",
|
||||
];
|
||||
export const inspectionsWallInsulationEnum = pgEnum(
|
||||
"inspections_wall_insulation",
|
||||
inspections_wall_insulation
|
||||
);
|
||||
|
||||
const inspectionsInsulationMaterial: [string, ...string[]] = [
|
||||
"empty 50-90",
|
||||
"empty 100+",
|
||||
"empty 30-40",
|
||||
"empty less than 30",
|
||||
"loose fibre/wool",
|
||||
"eps/celo/king",
|
||||
"fibre batts - with cavity",
|
||||
"fibre batts - no cavity",
|
||||
"loose bead",
|
||||
"glued bead",
|
||||
"formaldehyde",
|
||||
"bubble wrap",
|
||||
"poly chunks",
|
||||
];
|
||||
export const inspectionsInsulationMaterialEnum = pgEnum(
|
||||
"inspections_insulation_material",
|
||||
inspectionsInsulationMaterial
|
||||
);
|
||||
|
||||
const inspectionBorescoped: [string, ...string[]] = ["yes", "no", "refused"];
|
||||
export const inspectionBorescopedEnum = pgEnum(
|
||||
"inspection_borescoped",
|
||||
inspectionBorescoped
|
||||
);
|
||||
|
||||
const inspectionsRoofOrientations: [string, ...string[]] = [
|
||||
"north",
|
||||
"east",
|
||||
"south",
|
||||
"west",
|
||||
"north-east",
|
||||
"north-west",
|
||||
"south-east",
|
||||
"south-west",
|
||||
"n/s split",
|
||||
"e/w split",
|
||||
"ne/sw split",
|
||||
"nw/se split",
|
||||
"flat roof",
|
||||
"no roof",
|
||||
"roof too small",
|
||||
"already has solar pv",
|
||||
];
|
||||
export const inspectionsRoofOrientationEnum = pgEnum(
|
||||
"inspections_roof_orientation",
|
||||
inspectionsRoofOrientations
|
||||
);
|
||||
|
||||
const inspectionsTileHung: [string, ...string[]] = [
|
||||
"yes",
|
||||
"no",
|
||||
"first floor flats are tile hung",
|
||||
];
|
||||
export const inspectionsTileHungEnum = pgEnum(
|
||||
"inspections_tile_hung",
|
||||
inspectionsTileHung
|
||||
);
|
||||
|
||||
const renderedOptions: [string, ...string[]] = [
|
||||
"no render",
|
||||
"rendered with “insufficient” space between dpc and render",
|
||||
"rendered with “sufficient” space between dpc and render",
|
||||
];
|
||||
export const inspectionsRenderedEnum = pgEnum(
|
||||
"inspections_rendered",
|
||||
renderedOptions
|
||||
);
|
||||
|
||||
const claddingOptions: [string, ...string[]] = [
|
||||
"none",
|
||||
"cladded with “sufficient space to fill the wall”",
|
||||
"cladded with “insufficient space to fill the wall”",
|
||||
];
|
||||
|
||||
export const inspectionsCladdingEnum = pgEnum(
|
||||
"inspections_cladding",
|
||||
claddingOptions
|
||||
);
|
||||
|
||||
const access_issuesOptions: [string, ...string[]] = [
|
||||
"see notes",
|
||||
"damp issues",
|
||||
"foliage on walls",
|
||||
"bushes against wall",
|
||||
"trees around/anove property",
|
||||
"high rise block flats/maisonettes",
|
||||
"conservatory",
|
||||
"lean-to",
|
||||
"garage",
|
||||
"extension",
|
||||
"decking",
|
||||
"shed against wall",
|
||||
];
|
||||
|
||||
export const inspectionsAccessIssuesEnum = pgEnum(
|
||||
"inspections_access_issues",
|
||||
access_issuesOptions
|
||||
);
|
||||
|
||||
export const inspections = pgTable("inspections", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => property.id),
|
||||
// TODO Revise
|
||||
archetype: inspectionArchetypeEnum("archetype"),
|
||||
archetype2: inspectionArchetype2Enum("archetype_2"),
|
||||
wallConstruction: inspectionsWallConstructionEnum("wall_construction"),
|
||||
insulation: inspectionsWallInsulationEnum("insulation"),
|
||||
insulationMaterial: inspectionsInsulationMaterialEnum("insulation_material"),
|
||||
borescoped: inspectionBorescopedEnum("borescoped"),
|
||||
roofOrientation: inspectionsRoofOrientationEnum("roof_orientation"),
|
||||
tileHung: inspectionsTileHungEnum("tile_hung"),
|
||||
rendered: inspectionsRenderedEnum("rendered"),
|
||||
cladding: inspectionsCladdingEnum("cladding"),
|
||||
access_issues: inspectionsAccessIssuesEnum("access_issues"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
surveyorName: text("surveyor_name"),
|
||||
uploadedAt: timestamp("uploaded_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -97,6 +97,7 @@ export const property = pgTable("property", {
|
|||
.references(() => portfolio.id),
|
||||
creationStatus: propertyCreationStatusEnum("creation_status").notNull(),
|
||||
uprn: bigint("uprn", { mode: "bigint" }),
|
||||
landlordPropertyId: text("landlord_property_id"), // Optional ID used by landlords
|
||||
buildingReferenceNumber: bigint("building_reference_number", {
|
||||
mode: "bigint",
|
||||
}),
|
||||
|
|
@ -268,6 +269,13 @@ export interface PropertyWithRelations {
|
|||
cost?: number | null;
|
||||
currentEpcRating: string | null;
|
||||
currentSapPoints: number | null;
|
||||
plans: {
|
||||
id: bigint;
|
||||
isDefault?: boolean;
|
||||
fundingPackage?: {
|
||||
scheme: string | null;
|
||||
} | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type NonIntrusiveSurveyNotes = InferModel<
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export const recommendationMaterials = pgTable("recommendation_materials", {
|
|||
estimatedCost: real("estimated_cost").notNull(),
|
||||
});
|
||||
|
||||
// We create a plan type, for common plan types that we produce for clients
|
||||
const PlanType: [string, ...string[]] = [
|
||||
"solar_eco4",
|
||||
"solar_hhrsh_eco4",
|
||||
"empty_cavity_eco",
|
||||
"partial_cavity_eco",
|
||||
"extraction_eco",
|
||||
];
|
||||
export const planTypeEnum = pgEnum("plan_type", PlanType);
|
||||
|
||||
export const plan = pgTable("plan", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: text("name"),
|
||||
|
|
@ -82,6 +92,7 @@ export const plan = pgTable("plan", {
|
|||
valuationIncreaseLowerBound: real("valuation_increase_lower_bound"),
|
||||
valuationIncreaseUpperBound: real("valuation_increase_upper_bound"),
|
||||
valuationIncreaseAverage: real("valuation_increase_average"),
|
||||
planType: planTypeEnum("plan_type"), // This may be null for custom plans, outside of our common plan types
|
||||
});
|
||||
|
||||
export const planRecommendations = pgTable("plan_recommendations", {
|
||||
|
|
@ -272,5 +283,5 @@ export const MeasureKeyEnum = z.enum([
|
|||
...Object.keys(measuresDisplayLabels),
|
||||
] as [
|
||||
MeasureKey, // Force at least one measure key
|
||||
...MeasureKey[]
|
||||
...MeasureKey[],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -62,10 +62,19 @@ export const recommendationMaterialsRelations = relations(
|
|||
// create a one to many relation to map a plan to the details in the underlying recommendation
|
||||
// create a many to many map from a plan to a recommendation
|
||||
// A recommendation can be in multiple plans and therefore we have a many to many relationship between
|
||||
// plan and recommendations. This relationship is facilitated by the planRecommdnations table
|
||||
// plan and recommendations. This relationship is facilitated by the planRecommendations table.
|
||||
// We also need to be able to get from a plan to its property
|
||||
|
||||
export const planRelations = relations(plan, ({ many }) => ({
|
||||
export const planRelations = relations(plan, ({ one, many }) => ({
|
||||
property: one(property, {
|
||||
fields: [plan.propertyId],
|
||||
references: [property.id],
|
||||
}),
|
||||
planRecommendations: many(planRecommendations),
|
||||
fundingPackage: one(fundingPackage, {
|
||||
fields: [plan.id],
|
||||
references: [fundingPackage.planId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const planRecommendationsRelations = relations(
|
||||
|
|
@ -83,6 +92,7 @@ export const planRecommendationsRelations = relations(
|
|||
);
|
||||
|
||||
// one to one relationship between property and propertyTargets, and also to the recommendations table
|
||||
// We also relate a property to it's many plans, from which we can take the default
|
||||
export const propertyRelations = relations(property, ({ one, many }) => ({
|
||||
target: one(propertyTargets, {
|
||||
fields: [property.id],
|
||||
|
|
@ -93,6 +103,7 @@ export const propertyRelations = relations(property, ({ one, many }) => ({
|
|||
fields: [property.id],
|
||||
references: [propertyDetailsEpc.propertyId],
|
||||
}),
|
||||
plans: many(plan),
|
||||
}));
|
||||
|
||||
// We have a many to many relationship between users and portfolios
|
||||
|
|
@ -159,15 +170,25 @@ export const energyAssessmentDocumentsRelations = relations(
|
|||
// Relation from a funding package to funding package measures
|
||||
// Define a relation from a EnergyAssessmentDocument to EnergyAssessmentScenario. This is a many to one
|
||||
|
||||
// funding package links to multiple funding package measures
|
||||
export const fundingPackageRelations = relations(fundingPackage, ({ many }) => ({
|
||||
fundingPackageMeasures: many(fundingPackageMeasures),
|
||||
}));
|
||||
// funding package links to multiple funding package measures. We also link to a plan
|
||||
export const fundingPackageRelations = relations(
|
||||
fundingPackage,
|
||||
({ one, many }) => ({
|
||||
plan: one(plan, {
|
||||
fields: [fundingPackage.planId],
|
||||
references: [plan.id],
|
||||
}),
|
||||
fundingPackageMeasures: many(fundingPackageMeasures),
|
||||
})
|
||||
);
|
||||
|
||||
// funding package measures belong to a funding package
|
||||
export const fundingPackageMeasuresRelations = relations(fundingPackageMeasures, ({ one }) => ({
|
||||
fundingPackage: one(fundingPackage, {
|
||||
fields: [fundingPackageMeasures.fundingPackageId],
|
||||
references: [fundingPackage.id],
|
||||
}),
|
||||
}));
|
||||
export const fundingPackageMeasuresRelations = relations(
|
||||
fundingPackageMeasures,
|
||||
({ one }) => ({
|
||||
fundingPackage: one(fundingPackage, {
|
||||
fields: [fundingPackageMeasures.fundingPackageId],
|
||||
references: [fundingPackage.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -1,29 +1,28 @@
|
|||
import CardTiles from "../components/home/CardTiles";
|
||||
import { getPortfolios } from "./utils";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
||||
const Home = async () => {
|
||||
const user = await getServerSession(AuthOptions);
|
||||
|
||||
|
||||
if (!user?.user) {
|
||||
console.error("User not found");
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const portfolios = await getPortfolios(user.user.dbId);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<h1 className="text-3xl font-bold mt-3 mb-5 text-gray-700"> Your Portfolios </h1>
|
||||
<h1 className="text-3xl font-bold mt-3 mb-5 text-gray-700">
|
||||
{" "}
|
||||
Your Portfolios{" "}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<CardTiles Portfolios={portfolios} />
|
||||
<CardTiles Portfolios={portfolios} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import "./globals.css";
|
|||
import Provider from "./components/Provider";
|
||||
import Nav from "./components/Navbar";
|
||||
import { ReactQueryProvider } from "./ReactQueryProvider";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { cache } from "react";
|
||||
import { Inter } from "next/font/google";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import { getServerSession } from "next-auth/next";
|
||||
import { AuthOptions } from "./api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "./api/auth/[...nextauth]/authOptions";
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ function EmptyPropertyState() {
|
|||
);
|
||||
}
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined | number }>;
|
||||
}
|
||||
) {
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined | number;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
// This page is served from the server so we can make calls to the database
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export default async function DashboardLayout(props: {
|
|||
<Toolbar
|
||||
propertyId={propertyId}
|
||||
portfolioId={portfolioId}
|
||||
propertyMeta={propertyMeta}
|
||||
decentHomes={decentHomes}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,4 +67,4 @@ export default async function BuildingPassportHome(
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/app/portfolio/[slug]/components/BookSurveyModal.tsx
Normal file
96
src/app/portfolio/[slug]/components/BookSurveyModal.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Label } from "@/app/shadcn_components/ui/label";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
interface BookSurveyModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
propertyId: bigint;
|
||||
portfolioId: string;
|
||||
address: string;
|
||||
onSuccess?: () => void; // ✅ fix: properly declare optional callback
|
||||
}
|
||||
|
||||
export default function BookSurveyModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
propertyId,
|
||||
portfolioId,
|
||||
address,
|
||||
onSuccess, // ✅ fix: remove “?:” here, we already declared it optional in interface
|
||||
}: BookSurveyModalProps) {
|
||||
// 🧠 Simple mutation to call your HubSpot API
|
||||
const bookSurveyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/book-survey", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dealName: address,
|
||||
pipelineId: "2400089278",
|
||||
dealStageId: "3288115388",
|
||||
propertyId: propertyId.toString(),
|
||||
portfolioId: portfolioId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to create HubSpot deal");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log("✅ Deal created successfully:", data);
|
||||
console.log("HUBSPOT DEAL ID MADE", data.dealId);
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess(); // 👈 trigger confetti toast
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("❌ Deal creation failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// 🚀 Submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
bookSurveyMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Booking a Survey</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={bookSurveyMutation.isPending}
|
||||
>
|
||||
{bookSurveyMutation.isPending ? "Creating..." : "Submit"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// TODO: check if bug is happening and why.
|
||||
// TODO: Ask khalim what we want to do, maybe a list of Hubspot DB record? if someone presses twice, currently just updates
|
||||
// TODO: Make a sexy toast that the deal has been processed
|
||||
// TODO: Show khalim a demo and other clean ups for good user experience
|
||||
66
src/app/portfolio/[slug]/components/BookingSuccessToast.tsx
Normal file
66
src/app/portfolio/[slug]/components/BookingSuccessToast.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Confetti from "react-confetti";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
|
||||
interface BookingSuccessToastProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
message?: string;
|
||||
subtext?: string;
|
||||
}
|
||||
|
||||
export default function BookingSuccessToast({
|
||||
show,
|
||||
onClose,
|
||||
message = "Booking Confirmed!",
|
||||
subtext = "You’re all set. 🎉",
|
||||
}: BookingSuccessToastProps) {
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setConfetti(true);
|
||||
const timer = setTimeout(() => {
|
||||
setConfetti(false);
|
||||
onClose();
|
||||
}, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [show, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="fixed bottom-8 right-8 z-50 bg-white shadow-2xl rounded-2xl p-4 pr-6 flex items-center gap-3 border border-green-100"
|
||||
>
|
||||
<div className="p-2 bg-green-100 rounded-full">
|
||||
<CheckCircle className="text-green-600 w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-green-700 text-lg">{message}</p>
|
||||
<p className="text-sm text-gray-500">{subtext}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{confetti && (
|
||||
<Confetti
|
||||
numberOfPieces={180}
|
||||
gravity={0.4}
|
||||
recycle={false}
|
||||
colors={["#10B981", "#34D399", "#6EE7B7", "#ECFDF5"]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,9 +19,9 @@ export const RemoteAssessmentFormSchema = baseFormSchema
|
|||
addressLineOne: z.string().min(1),
|
||||
postcode: z.string().min(1),
|
||||
uprn: z.number().min(1, "UPRN must be a valid number"),
|
||||
valuation: z.number().min(1, "Valuation must be a valid number"),
|
||||
propertyType: z.string().nullable(),
|
||||
builtForm: z.string().nullable(),
|
||||
valuation: z.number().min(1, "Valuation must be a valid number").optional(),
|
||||
propertyType: z.string().nullable().optional(),
|
||||
builtForm: z.string().nullable().optional(),
|
||||
})
|
||||
.refine((data) => data.goal !== "Increasing EPC" || !!data.goalValue, {
|
||||
path: ["goalValue"],
|
||||
|
|
@ -50,11 +50,14 @@ export const uploadCsvSchema = baseFormSchema.extend({
|
|||
if (val === "" || val === undefined) return undefined;
|
||||
return Number(val);
|
||||
}, z.number().min(0.1)),
|
||||
budget: z.preprocess((val) => {
|
||||
if (val === "" || val === undefined) return undefined;
|
||||
if (val === null) return null;
|
||||
return Number(val);
|
||||
}, z.union([z.number(), z.null()]).optional()),
|
||||
budget: z.preprocess(
|
||||
(val) => {
|
||||
if (val === "" || val === undefined) return undefined;
|
||||
if (val === null) return null;
|
||||
return Number(val);
|
||||
},
|
||||
z.union([z.number(), z.null()]).optional()
|
||||
),
|
||||
});
|
||||
|
||||
export type UploadCsvFormValues = z.infer<typeof uploadCsvSchema>;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function SelectScenarioDropdown({
|
|||
<Menu.Button
|
||||
as={Button}
|
||||
variant="default"
|
||||
className="w-full justify-start bg-brandmidblue text-white rounded-lg shadow-sm hover:bg-brandblue focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brandmidblue"
|
||||
className="w-full justify-start bg-brandblue text-white rounded-lg shadow-sm hover:bg-brandblue focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brandmidblue"
|
||||
>
|
||||
{selectedValue === newOption.value && (
|
||||
<PlusIcon className="mr-2 h-5 w-5 text-white" aria-hidden="true" />
|
||||
|
|
@ -157,8 +157,8 @@ export function SelectDropdown({
|
|||
disabled
|
||||
? "cursor-not-allowed text-gray-400"
|
||||
: active
|
||||
? "bg-brandbrown text-white"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-brandbrown text-white"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
|
|
|||
|
|
@ -1,948 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/app/shadcn_components/ui/form";
|
||||
import { useToast } from "@/app/hooks/use-toast";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
SelectScenarioDropdown,
|
||||
SelectDropdown,
|
||||
} from "./RemoteAssessmentDropdowns";
|
||||
import MeasuresCheckboxes from "./MeasuresCheckboxes";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import {
|
||||
RemoteAssessmentFormSchema,
|
||||
RemoteAssessmentFormValues,
|
||||
} from "./FormSchema";
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DropdownProps = {
|
||||
options: Option[];
|
||||
selectedOption: string;
|
||||
onSelectOption: (option: Option) => void;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
// Extend the existing props
|
||||
type OptionalDropdownProps = Omit<DropdownProps, "selectedOption"> & {
|
||||
selectedOption: string | null | undefined;
|
||||
};
|
||||
|
||||
const selecthousingTypeOptions = [
|
||||
{
|
||||
label: "Social",
|
||||
value: "Social",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Private",
|
||||
value: "Private",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const propertyTypeOptions = [
|
||||
{
|
||||
label: "House",
|
||||
value: "House",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Flat",
|
||||
value: "Flat",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Bungalow",
|
||||
value: "bungalow",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Maisonette",
|
||||
value: "Maisonette",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Other",
|
||||
value: "Other",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const builtFormOptions = [
|
||||
{
|
||||
label: "Detached",
|
||||
value: "Detached",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Semi-Detached",
|
||||
value: "Semi-Detached",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Mid-Terrace",
|
||||
value: "Mid-Terrace",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "End-Terrace",
|
||||
value: "End-Terrace",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const selectGoalOptions = [
|
||||
{
|
||||
label: "Increasing EPC",
|
||||
value: "Increasing EPC",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Energy Savings",
|
||||
value: "Energy Savings",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Reducing CO2 emissions",
|
||||
value: "Reducing CO2 emissions",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const goalValueOptions = [
|
||||
{
|
||||
label: "C",
|
||||
value: "C",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "B",
|
||||
value: "B",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "A",
|
||||
value: "A",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface EngineTriggerBody {
|
||||
portfolio_id: string;
|
||||
housing_type: string;
|
||||
goal: string;
|
||||
goal_value: string | null;
|
||||
trigger_file_path: string;
|
||||
already_installed_file_path: string;
|
||||
patches_file_path: string;
|
||||
non_invasive_recommendations_file_path: string;
|
||||
valuation_file_path: string;
|
||||
scenario_name: string;
|
||||
multi_plan: boolean;
|
||||
budget: number | null;
|
||||
event_type: string;
|
||||
inclusions: (typeof measuresList)[number][];
|
||||
scenario_id?: string | null;
|
||||
}
|
||||
|
||||
async function uploadCsvToS3({
|
||||
presignedUrl,
|
||||
file,
|
||||
}: {
|
||||
presignedUrl: string;
|
||||
file: Blob;
|
||||
}) {
|
||||
try {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Upload failed.");
|
||||
}
|
||||
console.log("File uploaded successfully");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function generatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
}: {
|
||||
userId: string;
|
||||
portfolioId: string;
|
||||
fileKey: string;
|
||||
}) {
|
||||
// fileKey is a location in S3 where we want to upload the file
|
||||
const response = await fetch("/api/upload/csv", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate presigned url");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
data.fileKey = fileKey;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function generateS3Keys(userId: string, portfolioId: string) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
|
||||
const assetListFileKey = `${userId}/${portfolioId}/${timestamp}/asset_list.csv`;
|
||||
const valuationDataFileKey = `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`;
|
||||
return { assetListFileKey, valuationDataFileKey };
|
||||
}
|
||||
|
||||
type GenericObject = Record<string, any>;
|
||||
|
||||
const convertToCSV = <T extends Record<string, any>>(data: T[]): string => {
|
||||
if (data.length === 0) return "";
|
||||
|
||||
const headers = Object.keys(data[0]) as (keyof T)[];
|
||||
|
||||
const escape = (value: any): string => {
|
||||
if (value == null) return "";
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// Check if field contains special characters
|
||||
if (/[",\n]/.test(str)) {
|
||||
// Escape double quotes and wrap in quotes
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
const rows = data.map((row) =>
|
||||
headers.map((header) => escape(row[header])).join(",")
|
||||
);
|
||||
|
||||
return [headers.join(","), ...rows].join("\n");
|
||||
};
|
||||
|
||||
function useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn,
|
||||
addressLineOne,
|
||||
postcode,
|
||||
valuation,
|
||||
propertyType,
|
||||
builtForm,
|
||||
measures,
|
||||
scenarioId,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
uprn: number | undefined | null;
|
||||
addressLineOne: string;
|
||||
postcode: string;
|
||||
valuation: number | undefined | null;
|
||||
measures: (typeof measuresList)[number][];
|
||||
propertyType?: string | null;
|
||||
builtForm?: string | null;
|
||||
scenarioId?: string | null;
|
||||
}) {
|
||||
// 1) We want to upload the asset data. To do this, we format the asset data, generate a presigned URL, and upload the data to S3.
|
||||
// 2) We then want to upload valuation data. To do this, we format the valuation data, generate a presigned URL, and upload the data to S3.
|
||||
// 3) Trigger the engine!!!! This is an api at /api/plan/trigger with our body that we looked at in Miro
|
||||
|
||||
// Set up the mutation with react-query, to generate a presigned URL
|
||||
|
||||
const session = useSession();
|
||||
const userId = String(session.data?.user.dbId);
|
||||
|
||||
if (uprn === undefined || valuation === undefined) {
|
||||
throw new Error("UPRN and valuation must be provided");
|
||||
}
|
||||
|
||||
const { assetListFileKey, valuationDataFileKey } = useMemo(
|
||||
() => generateS3Keys(userId, portfolioId),
|
||||
[userId, portfolioId]
|
||||
);
|
||||
|
||||
const {
|
||||
mutate: mutateUploadFile,
|
||||
isLoading: uploadFileIsLoading,
|
||||
isError: uploadFileIsError,
|
||||
} = useMutation(uploadCsvToS3, {
|
||||
onSuccess: (data) => {
|
||||
// Callback for successful mutation
|
||||
console.log("Files uploaded successfully");
|
||||
// Trigger the engine here if needed
|
||||
},
|
||||
onError: (error) => {
|
||||
// Callback for failed mutation
|
||||
console.error("Error uploading files:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: mutatePresignedUrl,
|
||||
isLoading: presignedUrlIsLoading,
|
||||
isError: presignedUrlIsError,
|
||||
} = useMutation(generatePresignedUrl, {
|
||||
onSuccess: (data) => {
|
||||
// console.log(data.url);
|
||||
// // On success, upload to that URL!!!!
|
||||
|
||||
let csvFile: Blob = new Blob();
|
||||
|
||||
if (data.fileKey === assetListFileKey) {
|
||||
const assetList = [
|
||||
{
|
||||
uprn: uprn,
|
||||
address: addressLineOne,
|
||||
postcode: postcode,
|
||||
property_type: propertyType,
|
||||
built_form: builtForm,
|
||||
},
|
||||
];
|
||||
|
||||
csvFile = new Blob([convertToCSV(assetList)], {
|
||||
type: "text/csv",
|
||||
});
|
||||
} else if (data.fileKey === valuationDataFileKey) {
|
||||
const valuationData = [
|
||||
{
|
||||
uprn: uprn,
|
||||
valuation: valuation,
|
||||
},
|
||||
];
|
||||
|
||||
csvFile = new Blob([convertToCSV(valuationData)], {
|
||||
type: "text/csv",
|
||||
});
|
||||
}
|
||||
|
||||
mutateUploadFile({
|
||||
file: csvFile,
|
||||
presignedUrl: data.url,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
async function triggerEngine(data: RemoteAssessmentFormValues) {
|
||||
try {
|
||||
// Goal value should not be missing at this point
|
||||
if (data.goal === "Increasing EPC" && !data.goalValue) {
|
||||
throw new Error("Goal value is required");
|
||||
}
|
||||
|
||||
const triggerBody: EngineTriggerBody = {
|
||||
scenario_id: scenarioId === "__new__" ? null : scenarioId,
|
||||
portfolio_id: portfolioId,
|
||||
housing_type: data.housingType,
|
||||
goal: data.goal,
|
||||
// We only send goal_value if the goal is "Increasing EPC"
|
||||
goal_value: data.goalValue || null,
|
||||
trigger_file_path: assetListFileKey,
|
||||
already_installed_file_path: "",
|
||||
patches_file_path: "",
|
||||
non_invasive_recommendations_file_path: "",
|
||||
valuation_file_path: valuationDataFileKey,
|
||||
scenario_name: data.scenario,
|
||||
inclusions: data.measures,
|
||||
multi_plan: true,
|
||||
// If the goal is "Increasing EPC", we don't send a budget
|
||||
budget: data.budget || null,
|
||||
event_type: "remote_assessment",
|
||||
};
|
||||
|
||||
console.log("Triggering engine with body:", triggerBody);
|
||||
|
||||
const response = await fetch("/api/plan/trigger", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(triggerBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to trigger engine");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error triggering engine:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: RemoteAssessmentFormValues) {
|
||||
try {
|
||||
await Promise.all([
|
||||
mutatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: assetListFileKey,
|
||||
}),
|
||||
mutatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: valuationDataFileKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
await triggerEngine(formData);
|
||||
} catch (error) {
|
||||
console.error("Error in submission process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
triggerEngine,
|
||||
mutateUploadFile,
|
||||
presignedUrlIsLoading,
|
||||
presignedUrlIsError,
|
||||
uploadFileIsLoading,
|
||||
uploadFileIsError,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RemoteAssessmentModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
portfolioId,
|
||||
scenarios,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
}) {
|
||||
const NEW_SENTINEL = "__new__";
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const [showMeasures, setShowMeasures] = useState(false);
|
||||
|
||||
const scenarioOptions: Option[] = useMemo(
|
||||
() => [
|
||||
...scenarios.map((s) => ({
|
||||
label: s.name || "",
|
||||
value: String(s.id) || "",
|
||||
disabled: false,
|
||||
})),
|
||||
],
|
||||
[scenarios]
|
||||
);
|
||||
|
||||
const form = useForm<RemoteAssessmentFormValues>({
|
||||
resolver: zodResolver(RemoteAssessmentFormSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
scenario: "",
|
||||
housingType: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
addressLineOne: "",
|
||||
postcode: "",
|
||||
uprn: undefined,
|
||||
valuation: undefined,
|
||||
propertyType: null,
|
||||
builtForm: null,
|
||||
measures: measuresList,
|
||||
},
|
||||
});
|
||||
const { reset, setValue, formState } = form;
|
||||
const { isValid, isSubmitting } = formState;
|
||||
|
||||
const measures = form.watch("measures");
|
||||
const goal = form.watch("goal");
|
||||
|
||||
const {
|
||||
handleSubmit: triggerAssessment,
|
||||
presignedUrlIsLoading,
|
||||
presignedUrlIsError,
|
||||
} = useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn: form.watch("uprn") ?? null,
|
||||
addressLineOne: form.watch("addressLineOne"),
|
||||
postcode: form.watch("postcode"),
|
||||
valuation: form.watch("valuation") ?? null,
|
||||
propertyType: form.watch("propertyType"),
|
||||
builtForm: form.watch("builtForm"),
|
||||
measures: measures,
|
||||
scenarioId: selectedScenario,
|
||||
});
|
||||
|
||||
const onSelectScenario = (opt: Option) => {
|
||||
setSelectedScenario(opt.value);
|
||||
if (opt.value === NEW_SENTINEL) {
|
||||
reset({
|
||||
...form.getValues(),
|
||||
scenario: "",
|
||||
housingType: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
});
|
||||
} else {
|
||||
const picked = scenarios.find((s) => String(s.id) === opt.value);
|
||||
|
||||
if (!picked) return;
|
||||
setValue("scenario", picked.name || "");
|
||||
setValue("housingType", picked.housingType);
|
||||
setValue("goal", picked.goal);
|
||||
setValue("goalValue", picked.goalValue || "");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
await triggerAssessment(data);
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
toast({ title: "Remote assessment sent" });
|
||||
});
|
||||
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="fixed inset-0 z-10 overflow-y-auto"
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="min-h-screen px-4 text-center">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<DialogBackdrop className="fixed inset-0 bg-black/25" />
|
||||
</TransitionChild>
|
||||
|
||||
{/* Spacer for centering */}
|
||||
<span
|
||||
className="inline-block h-screen align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="inline-block w-full max-w-2xl p-6 my-8 overflow-visible text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
|
||||
<DialogTitle className="text-lg font-medium">
|
||||
Remote Assessment Details
|
||||
</DialogTitle>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-6 mt-4">
|
||||
{/* Scenario selector */}
|
||||
<FormItem>
|
||||
<FormLabel>Select scenario</FormLabel>
|
||||
<FormControl>
|
||||
<SelectScenarioDropdown
|
||||
scenarios={scenarioOptions}
|
||||
selectedValue={selectedScenario}
|
||||
onSelect={onSelectScenario}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
{selectedScenario !== null && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Scenario Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scenario"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Scenario Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={selectedScenario !== NEW_SENTINEL}
|
||||
placeholder="Scenario name"
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Housing Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="housingType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Housing Type
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={selecthousingTypeOptions}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(o) =>
|
||||
field.onChange(o.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Goal */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Goal
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={selectGoalOptions}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(o) =>
|
||||
field.onChange(o.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{goal && (
|
||||
<>
|
||||
{goal === "Increasing EPC" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goalValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Target EPC Rating
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={goalValueOptions}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(opt) =>
|
||||
field.onChange(opt.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ✅ Budget shows for all goals but is only mandatory when goal != Increasing EPC */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budget"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
{/* We mark budget as (optional) when the goal is increasing EPC*/}
|
||||
Budget (£){" "}
|
||||
{goal === "Increasing EPC" && (
|
||||
<span className="text-sm text-gray-500">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter budget"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="addressLineOne"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter address"
|
||||
{...field}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postcode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Postcode
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter postcode"
|
||||
{...field}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="uprn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">UPRN</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter UPRN"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="valuation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Valuation
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
The valuation can be found at{" "}
|
||||
<a
|
||||
href={`https://www.zoopla.co.uk/property/uprn/${form.watch(
|
||||
"uprn"
|
||||
)}/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
zoopla property page
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter valuation"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Optional:</strong> Property Type and Built Form
|
||||
are only required if no EPC is available.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="propertyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Property Type</FormLabel>
|
||||
<FormControl>
|
||||
<SelectDropdown
|
||||
options={propertyTypeOptions}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(o) => field.onChange(o.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="builtForm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Built Form</FormLabel>
|
||||
<FormControl>
|
||||
<SelectDropdown
|
||||
options={builtFormOptions}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(o) => field.onChange(o.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Measures Section */}
|
||||
<div className="border-t pt-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMeasures(!showMeasures)}
|
||||
className="flex items-center justify-between w-full text-sm font-medium"
|
||||
>
|
||||
<span>Measures</span>
|
||||
<span>{showMeasures ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showMeasures && <MeasuresCheckboxes form={form} />}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!isValid || presignedUrlIsLoading || isSubmitting
|
||||
}
|
||||
>
|
||||
{isSubmitting || presignedUrlIsLoading
|
||||
? "Submitting…"
|
||||
: "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{presignedUrlIsError && (
|
||||
<p className="text-red-500">Error uploading files</p>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
function setIsOpen(arg0: boolean) {
|
||||
throw new Error("Function not implemented.");
|
||||
}
|
||||
|
|
@ -63,10 +63,11 @@ export function DataTableFilterHeader<TData, TValue>({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{PortfolioStatus.map((status) => (
|
||||
{[...PortfolioStatus, "ECO4", "GBIS"].map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => {
|
||||
console.log("status filter:", status);
|
||||
column.setFilterValue(status);
|
||||
}}
|
||||
>
|
||||
|
|
@ -127,10 +128,22 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
},
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") ?? "";
|
||||
const plans = row.original.plans || [];
|
||||
// Check if any plan has an ECO4 or GBIS funding package
|
||||
const fundingScheme = plans.find((p) => {
|
||||
const scheme = p?.fundingPackage?.scheme;
|
||||
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
|
||||
})?.fundingPackage?.scheme;
|
||||
|
||||
const effectiveStatus = fundingScheme
|
||||
? fundingScheme.toUpperCase()
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{status && <StatusBadge status={String(status)} isProperty={true} />}
|
||||
{effectiveStatus && (
|
||||
<StatusBadge status={String(effectiveStatus)} isProperty={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -226,35 +239,37 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
// onClick={() => navigator.clipboard.writeText(payment.id)}
|
||||
className="text-gray-700 cursor-pointer"
|
||||
>
|
||||
<a href={`${portfolioId}/building-passport/${propertyId}`}>
|
||||
Building Passport
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
// onClick={() => navigator.clipboard.writeText(payment.id)}
|
||||
className="text-gray-700 cursor-pointer"
|
||||
>
|
||||
<a href={`${portfolioId}/building-passport/${propertyId}`}>
|
||||
Building Passport
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="text-gray-700 cursor-pointer">
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-500 focus:text-red-700 cursor-pointer">
|
||||
Delete Property
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DropdownMenuItem className="text-gray-700 cursor-pointer">
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-500 focus:text-red-700 cursor-pointer">
|
||||
Delete Property
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,75 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect, use } from "react";
|
||||
import { useState, useEffect, useCallback, use } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
Activity,
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
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 [countdown, setCountdown] = useState(10);
|
||||
const [stageIndex, setStageIndex] = useState(0);
|
||||
|
||||
const handleBackToPortfolio = () => {
|
||||
if (portfolioId) {
|
||||
router.push(`/portfolio/${portfolioId}`);
|
||||
} else {
|
||||
router.push(`/home`);
|
||||
}
|
||||
};
|
||||
const stages = [
|
||||
{
|
||||
icon: <Database className="w-14 h-14 text-white/90" />,
|
||||
title: "Gathering Data",
|
||||
text: "Collecting EPC, property, and mapping data from trusted sources.",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-14 h-14 text-white/90" />,
|
||||
title: "Analysing Models",
|
||||
text: "Running retrofit simulations to identify the most effective measures.",
|
||||
},
|
||||
{
|
||||
icon: <Lightbulb className="w-14 h-14 text-white/90" />,
|
||||
title: "Generating Plan",
|
||||
text: "Assembling your detailed retrofit plan and funding summary.",
|
||||
},
|
||||
];
|
||||
|
||||
const handleBackToPortfolio = useCallback(() => {
|
||||
router.push(portfolioId ? `/portfolio/${portfolioId}` : "/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);
|
||||
}, 1000);
|
||||
|
||||
// Clean up the interval when the component unmounts
|
||||
const timer = setInterval(() => setCountdown((prev) => prev - 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown, handleBackToPortfolio]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setStageIndex((prev) => (prev + 1) % stages.length);
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [stages.length]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start min-h-screen text-center p-4 pt-32">
|
||||
<div className="bg-gray-100 p-6 rounded shadow flex flex-col items-center justify-cente">
|
||||
<h1 className="text-2xl font-semibold mb-2 text-slate-700 ">
|
||||
We're building your portfolio plan
|
||||
</h1>
|
||||
<div className="text-md mb-4 text-slate-600">
|
||||
This could take a few minutes. Thank you for your patience.
|
||||
</div>
|
||||
<div className="text-md mb-4 text-slate-600">
|
||||
Click on 'Go back to portfolio'.
|
||||
</div>
|
||||
<div className="text-sm mb-4 text-slate-600">
|
||||
We'll redirect you automatically in {countdown} seconds...
|
||||
</div>
|
||||
<div className="relative flex flex-col md:flex-row min-h-screen bg-gray-50 overflow-hidden">
|
||||
{/* LEFT PANEL – Centered Blueprint */}
|
||||
<div className="hidden md:flex w-1/2 relative items-center justify-center overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/images/Alexandra-Road-Park.webp')" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-brandblue/95 to-midblue/85" />
|
||||
|
||||
{/* Animated grid overlay */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
className="absolute inset-0 w-full h-full opacity-20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 800 600"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
<g stroke="white" strokeWidth="0.4">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<line
|
||||
key={`v-${i}`}
|
||||
x1={(i + 1) * 40}
|
||||
y1="0"
|
||||
x2={(i + 1) * 40}
|
||||
y2="600"
|
||||
className="animate-[pulse_6s_ease-in-out_infinite]"
|
||||
/>
|
||||
))}
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<line
|
||||
key={`h-${i}`}
|
||||
y1={(i + 1) * 40}
|
||||
x1="0"
|
||||
y2={(i + 1) * 40}
|
||||
x2="800"
|
||||
className="animate-[pulse_8s_ease-in-out_infinite]"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
<button
|
||||
onClick={handleBackToPortfolio}
|
||||
className="mt-5 px-4 py-2 bg-brandblue text-white rounded hover:bg-hoverblue"
|
||||
|
||||
{/* Centered analysis stage */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-center text-white px-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={stages[stageIndex].title}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -30 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<div className="mb-5">{stages[stageIndex].icon}</div>
|
||||
<h2 className="text-4xl font-bold mb-3">
|
||||
{stages[stageIndex].title}
|
||||
</h2>
|
||||
<p className="text-lg text-white/80 max-w-sm leading-relaxed">
|
||||
{stages[stageIndex].text}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL – Glass Card */}
|
||||
<div className="flex flex-1 flex-col justify-center items-center p-8 bg-gradient-to-b from-white to-sky-50 relative">
|
||||
<div className="absolute top-0 left-0 w-64 h-64 bg-brandbrown/10 rounded-full blur-3xl -translate-x-24 -translate-y-24 animate-pulse" />
|
||||
<div className="absolute bottom-0 right-0 w-80 h-80 bg-brandblue/10 rounded-full blur-3xl translate-x-20 translate-y-20 animate-[pulse_4s_ease-in-out_infinite]" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative z-10 bg-white/80 backdrop-blur-md border border-gray-100 shadow-2xl rounded-3xl px-10 py-12 text-center max-w-lg"
|
||||
>
|
||||
Go back to portfolio
|
||||
</button>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-12 h-12 text-brandblue animate-spin" />
|
||||
<Home className="absolute inset-0 w-5 h-5 text-brandbrown m-auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-semibold text-brandblue mb-3">
|
||||
Building your retrofit plan
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-3 text-base">
|
||||
Domna IQ is analysing your data and generating your plan summary.
|
||||
</p>
|
||||
|
||||
<div className="relative w-full h-2 bg-gray-200 rounded-full overflow-hidden mb-6">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-full bg-brandbrown"
|
||||
animate={{ width: `${((10 - countdown) / 10) * 100}%` }}
|
||||
transition={{ ease: "easeInOut", duration: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Redirecting in{" "}
|
||||
<span className="font-semibold text-brandbrown">{countdown}</span>{" "}
|
||||
seconds...
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleBackToPortfolio}
|
||||
className="mt-3 px-5 py-3 bg-brandblue text-white rounded-xl hover:bg-hoverblue flex items-center gap-2 mx-auto transition-all shadow-sm"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Go back to portfolio
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="relative z-10 mt-8 text-sm text-gray-500"
|
||||
>
|
||||
Your assessment is running securely in the background.
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
191
src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx
Normal file
191
src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
import { Pencil } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import usePostcodeLookup from "./usePostcodeLookup";
|
||||
|
||||
interface AddressItem {
|
||||
uprn: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default function AddressSearch({
|
||||
onAddressSelect,
|
||||
onPostcodeSelect,
|
||||
postcode,
|
||||
}: {
|
||||
onAddressSelect: (address: AddressItem | null) => void; // ✅ Fix type
|
||||
onPostcodeSelect: (postcode: string) => void;
|
||||
postcode: string;
|
||||
}) {
|
||||
const [addresses, setAddresses] = useState<AddressItem[]>([]);
|
||||
const [selectedAddress, setSelectedAddress] = useState<AddressItem | null>(
|
||||
null
|
||||
);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [triggerSearch, setTriggerSearch] = useState(false);
|
||||
const [loadingAddresses, setLoadingAddresses] = useState(false);
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
|
||||
const { data, isFetching, refetch } = usePostcodeLookup(
|
||||
postcode,
|
||||
triggerSearch
|
||||
);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!postcode.trim()) return;
|
||||
setTriggerSearch(true);
|
||||
const validation = await refetch();
|
||||
setTriggerSearch(false);
|
||||
|
||||
if (!validation.data || validation.data.status !== 200) return;
|
||||
|
||||
const validatedPostcode =
|
||||
validation.data.result?.postcode?.toUpperCase() ?? postcode.toUpperCase();
|
||||
|
||||
// Use the validated postcode for fetching addresses
|
||||
onPostcodeSelect(validatedPostcode);
|
||||
|
||||
setLoadingAddresses(true);
|
||||
setAddressError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(validatedPostcode)}/addresses`
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setAddressError(json.error || "Unable to retrieve addresses");
|
||||
setShowDropdown(false);
|
||||
} else if (json.results?.length) {
|
||||
const mapped = json.results.map((r: any) => ({
|
||||
address: r.address,
|
||||
uprn: r.uprn,
|
||||
propertyType: r.propertyType,
|
||||
builtForm: r.builtForm,
|
||||
}));
|
||||
setAddresses(mapped);
|
||||
setShowDropdown(true);
|
||||
} else {
|
||||
setAddressError("No addresses found for this postcode");
|
||||
}
|
||||
} catch {
|
||||
setAddressError("There was an issue contacting the address service.");
|
||||
} finally {
|
||||
setLoadingAddresses(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAddress(value: string) {
|
||||
const selected = addresses.find((a) => a.address === value) || null;
|
||||
setSelectedAddress(selected);
|
||||
setShowDropdown(false);
|
||||
onAddressSelect(selected);
|
||||
}
|
||||
|
||||
function handleChangeAddress() {
|
||||
setSelectedAddress(null);
|
||||
setShowDropdown(true);
|
||||
onAddressSelect(null);
|
||||
}
|
||||
|
||||
const showInvalid = data && data.status === 404;
|
||||
const showServerError = data && data.status === 500;
|
||||
const isLoading = isFetching || loadingAddresses;
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
{!selectedAddress && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="Enter postcode"
|
||||
value={postcode}
|
||||
onChange={(e) => onPostcodeSelect(e.target.value.toUpperCase())}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading || !postcode}
|
||||
className="bg-brandbrown text-white"
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation and errors */}
|
||||
{showInvalid && (
|
||||
<p className="text-sm text-red-600 mb-3">
|
||||
Invalid postcode — please check and try again.
|
||||
</p>
|
||||
)}
|
||||
{showServerError && (
|
||||
<p className="text-sm text-orange-600 mb-3">
|
||||
The postcode service is currently unavailable. Please try again later.
|
||||
</p>
|
||||
)}
|
||||
{addressError && (
|
||||
<p className="text-sm text-orange-600 mb-3">{addressError}</p>
|
||||
)}
|
||||
|
||||
{/* Address dropdown */}
|
||||
{showDropdown && addresses.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm text-gray-700 mb-2">
|
||||
Select your address
|
||||
</label>
|
||||
<Select
|
||||
onValueChange={handleSelectAddress}
|
||||
value={selectedAddress?.address || undefined} // ✅ fix value binding
|
||||
>
|
||||
<SelectTrigger className="w-full border-gray-300">
|
||||
<SelectValue placeholder="Choose an address" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{addresses.map((a) => (
|
||||
<SelectItem key={a.uprn} value={a.address}>
|
||||
{a.address}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected address display */}
|
||||
{selectedAddress && !showDropdown && (
|
||||
<div className="bg-gray-100 border rounded-xl p-6 mt-4 flex flex-col justify-between min-h-[140px]">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-brandblue mb-2">
|
||||
Selected Address
|
||||
</h3>
|
||||
<p className="text-gray-700 text-sm break-words">
|
||||
{selectedAddress.address}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleChangeAddress}
|
||||
className="flex gap-1 text-brandbrown border-brandbrown"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import AddressSearch from "./AddressSearch";
|
||||
import ScenarioSetup from "./ScenarioSetup";
|
||||
import { ScenarioSelect, measuresList } from "@/app/db/schema/recommendations";
|
||||
import useCreateRemoteAssessment from "./useCreateRemoteAssessment";
|
||||
import { RemoteAssessmentFormValues } from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
import BackToPortfolioButton from "@/app/components/building-passport/BackToPortfolioButton";
|
||||
import { MapPin, ClipboardCheck, Zap } from "lucide-react";
|
||||
|
||||
export default function RemoteAssessmentClient({
|
||||
portfolioId,
|
||||
scenarios,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selectedAddress, setSelectedAddress] = useState<{
|
||||
address: string;
|
||||
uprn: string;
|
||||
propertyType?: string;
|
||||
builtForm?: string;
|
||||
} | null>(null);
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string>("");
|
||||
|
||||
const { handleSubmit: submitAssessment, isUploading } =
|
||||
useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn: selectedAddress?.uprn ? parseInt(selectedAddress.uprn) : null,
|
||||
addressLineOne: selectedAddress?.address || "",
|
||||
postcode: selectedPostcode,
|
||||
valuation: null,
|
||||
propertyType: selectedAddress?.propertyType || null,
|
||||
builtForm: selectedAddress?.builtForm || null,
|
||||
measures: measuresList,
|
||||
});
|
||||
|
||||
async function onSubmitRemoteAssessment(values: RemoteAssessmentFormValues) {
|
||||
await submitAssessment(values);
|
||||
router.push(`/portfolio/${portfolioId}/plan-loading`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white">
|
||||
{/* --- HERO / EXPLANATION SECTION --- */}
|
||||
<div className="relative bg-gradient-to-r from-brandblue to-midblue text-white overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center opacity-20"
|
||||
style={{
|
||||
backgroundImage: "url('/images/energy-analysis-placeholder.webp')",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-8 py-8 md:py-10 flex flex-col gap-6">
|
||||
{/* Title & back button pinned to top */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<BackToPortfolioButton portfolioId={portfolioId} />
|
||||
<h1 className="text-3xl md:text-4xl font-bold">
|
||||
Remote Assessment
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Hero text split to use horizontal space better */}
|
||||
<div className="flex flex-col md:flex-row justify-between gap-10">
|
||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl">
|
||||
Domna IQ analyses your property data, models retrofit options, and
|
||||
estimates potential funding — all without an on-site survey.
|
||||
</p>
|
||||
<p className="text-sm text-white/70 max-w-md md:text-right">
|
||||
Start by selecting your property, then choose retrofit goals and
|
||||
configurations. Our model will generate your baseline and plan.
|
||||
This isn`'`t a replacement for an on-site survey but a
|
||||
powerful first step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex gap-6 mt-6 text-sm text-white/80 justify-start md:justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" /> Find Address
|
||||
</div>
|
||||
<div className="w-8 h-[1px] bg-white/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="w-5 h-5" /> Configure Scenario
|
||||
</div>
|
||||
<div className="w-8 h-[1px] bg-white/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" /> Generate Plan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional subtle fade transition into workspace */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-2 bg-gradient-to-r from-brandblue/30 to-midblue/30 blur-sm"></div>
|
||||
</div>
|
||||
|
||||
{/* --- TWO-COLUMN WORKSPACE --- */}
|
||||
<div className="flex-1 max-w-7xl mx-auto w-full px-8 py-8">
|
||||
<div className="grid md:grid-cols-2 gap-6 items-stretch auto-rows-fr">
|
||||
{/* LEFT: Address Search */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="flex flex-col bg-white border border-gray-100 shadow-lg rounded-2xl p-8"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-brandbrown">
|
||||
Step 1: Find your property
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Address lookup</p>
|
||||
</div>
|
||||
|
||||
<AddressSearch
|
||||
onAddressSelect={(addr) => setSelectedAddress(addr)}
|
||||
onPostcodeSelect={setSelectedPostcode}
|
||||
postcode={selectedPostcode}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT: Scenario Setup */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.6 }}
|
||||
className={`flex flex-col bg-white border border-gray-100 shadow-lg rounded-2xl p-8 transition-opacity duration-300 ${
|
||||
selectedAddress ? "opacity-100" : "opacity-40 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-brandbrown">
|
||||
Step 2: Configure scenario
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Your model setup</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow flex flex-col">
|
||||
<ScenarioSetup
|
||||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
disabled={!selectedAddress}
|
||||
selectedAddress={selectedAddress?.address ?? ""}
|
||||
selectedPostcode={selectedPostcode}
|
||||
selectedUprn={Number(selectedAddress?.uprn) ?? null}
|
||||
selectedPropertyType={selectedAddress?.propertyType ?? null}
|
||||
selectedBuiltForm={selectedAddress?.builtForm ?? null}
|
||||
isSubmitting={isUploading}
|
||||
onSubmitRemoteAssessment={onSubmitRemoteAssessment}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- FOOTER NOTE --- */}
|
||||
<div className="pb-8 text-center text-xs text-gray-500">
|
||||
All assessments use verified EPC, OS AddressBase, and open data sources.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx
Normal file
335
src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/app/shadcn_components/ui/form";
|
||||
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import {
|
||||
SelectScenarioDropdown,
|
||||
SelectDropdown,
|
||||
ScenarioOption,
|
||||
} from "@/app/portfolio/[slug]/components/RemoteAssessmentDropdowns";
|
||||
import MeasuresCheckboxes from "@/app/portfolio/[slug]/components/MeasuresCheckboxes";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
import {
|
||||
RemoteAssessmentFormSchema,
|
||||
type RemoteAssessmentFormValues,
|
||||
} from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
|
||||
const housingTypeOptions = [
|
||||
{ label: "Social", value: "Social" },
|
||||
{ label: "Private", value: "Private" },
|
||||
];
|
||||
|
||||
// -------------------
|
||||
// Component
|
||||
// -------------------
|
||||
export default function ScenarioSetup({
|
||||
portfolioId,
|
||||
scenarios,
|
||||
disabled = false,
|
||||
selectedAddress,
|
||||
selectedPostcode,
|
||||
selectedUprn,
|
||||
selectedPropertyType,
|
||||
selectedBuiltForm,
|
||||
isSubmitting,
|
||||
onSubmitRemoteAssessment,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
disabled?: boolean;
|
||||
selectedAddress: string | null;
|
||||
selectedPostcode: string;
|
||||
selectedUprn: number | null;
|
||||
selectedPropertyType: string | null;
|
||||
selectedBuiltForm: string | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmitRemoteAssessment: (values: RemoteAssessmentFormValues) => void;
|
||||
}) {
|
||||
const NEW_SENTINEL = "__new__";
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const [showMeasures, setShowMeasures] = useState(false);
|
||||
|
||||
const form = useForm<RemoteAssessmentFormValues>({
|
||||
resolver: zodResolver(RemoteAssessmentFormSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
scenario: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
housingType: "Social",
|
||||
addressLineOne: "",
|
||||
postcode: "",
|
||||
uprn: 1,
|
||||
measures: measuresList,
|
||||
},
|
||||
});
|
||||
|
||||
const { setValue, watch, handleSubmit, formState } = form;
|
||||
const values = watch();
|
||||
const [localSubmitting, setLocalSubmitting] = useState(false);
|
||||
|
||||
const scenarioOptions: ScenarioOption[] = useMemo(
|
||||
() =>
|
||||
scenarios.map((s) => ({
|
||||
label: s.name || "",
|
||||
value: String(s.id) || "",
|
||||
housingType: s.housingType || "",
|
||||
goal: s.goal || "",
|
||||
goalValue: s.goalValue || "",
|
||||
})),
|
||||
[scenarios]
|
||||
);
|
||||
|
||||
function handleSelect(opt: ScenarioOption) {
|
||||
setSelectedScenario(opt.value);
|
||||
|
||||
if (opt.value === NEW_SENTINEL) {
|
||||
form.reset({
|
||||
...form.getValues(),
|
||||
scenario: "",
|
||||
housingType: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
measures: measuresList,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode || "",
|
||||
uprn: selectedUprn || undefined,
|
||||
propertyType: selectedPropertyType || null,
|
||||
builtForm: selectedBuiltForm || null,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
scenario: opt.label || "",
|
||||
housingType: opt.housingType || "",
|
||||
goal: opt.goal || "",
|
||||
goalValue: opt.goalValue || "",
|
||||
budget: undefined,
|
||||
measures: measuresList,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode || "",
|
||||
uprn: selectedUprn || undefined,
|
||||
propertyType: selectedPropertyType || null,
|
||||
builtForm: selectedBuiltForm || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(data: RemoteAssessmentFormValues) {
|
||||
setLocalSubmitting(true);
|
||||
// Keep the button in submitting state until redirect completes
|
||||
await onSubmitRemoteAssessment(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-6 transition-all ${
|
||||
disabled
|
||||
? "opacity-50 pointer-events-none cursor-not-allowed"
|
||||
: "opacity-100 cursor-default"
|
||||
}`}
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-gray-800 text-sm font-medium mb-2">
|
||||
Select Scenario
|
||||
</label>
|
||||
<SelectScenarioDropdown
|
||||
selectedValue={selectedScenario}
|
||||
onSelect={handleSelect}
|
||||
scenarios={scenarioOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedScenario && (
|
||||
<>
|
||||
{/* Scenario Name + Housing Type */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scenario"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scenario Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={selectedScenario !== NEW_SENTINEL}
|
||||
placeholder="Enter scenario name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="housingType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Housing Type</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={housingTypeOptions}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(opt) => field.onChange(opt.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Goal + EPC */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Goal</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{
|
||||
label: "Increasing EPC",
|
||||
value: "Increasing EPC",
|
||||
},
|
||||
{
|
||||
label: "Energy Savings",
|
||||
value: "Energy Savings",
|
||||
},
|
||||
{
|
||||
label: "Reducing CO₂ emissions",
|
||||
value: "Reducing CO2 emissions",
|
||||
},
|
||||
]}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(opt) => field.onChange(opt.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{values.goal === "Increasing EPC" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goalValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target EPC Rating</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ label: "C", value: "C" },
|
||||
{ label: "B", value: "B" },
|
||||
{ label: "A", value: "A" },
|
||||
]}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(opt) =>
|
||||
field.onChange(opt.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value || ""} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Measures Section */}
|
||||
<div className="border-t pt-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMeasures(!showMeasures)}
|
||||
className="flex items-center justify-between w-full text-sm font-medium text-gray-800"
|
||||
>
|
||||
<span>Measures</span>
|
||||
<span>{showMeasures ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showMeasures && <MeasuresCheckboxes form={form} />}
|
||||
</div>
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting || localSubmitting}
|
||||
onClick={() =>
|
||||
handleSubmit(onSubmit, (err) =>
|
||||
console.log("Validation failed:", err)
|
||||
)()
|
||||
}
|
||||
className="flex items-center gap-2 bg-brandblue text-white py-3 font-semibold hover:bg-hoverblue"
|
||||
>
|
||||
{isSubmitting || localSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
Run Assessment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
19
src/app/portfolio/[slug]/remote-assessment/page.tsx
Normal file
19
src/app/portfolio/[slug]/remote-assessment/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import RemoteAssessmentClient from "./RemoteAssessmentClient";
|
||||
import { getPortfolioScenarios } from "@/app/portfolio/[slug]/utils";
|
||||
|
||||
export default async function RemoteAssessmentPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined | number;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
|
||||
// 🔹 Replace this with your real Drizzle query
|
||||
const scenarios = await getPortfolioScenarios(portfolioId);
|
||||
|
||||
return (
|
||||
<RemoteAssessmentClient portfolioId={portfolioId} scenarios={scenarios} />
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import { RemoteAssessmentFormValues } from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
|
||||
interface EngineTriggerBody {
|
||||
portfolio_id: string;
|
||||
housing_type: string;
|
||||
goal: string;
|
||||
goal_value: string | null;
|
||||
trigger_file_path: string;
|
||||
already_installed_file_path: string;
|
||||
patches_file_path: string;
|
||||
non_invasive_recommendations_file_path: string;
|
||||
valuation_file_path: string;
|
||||
scenario_name: string;
|
||||
multi_plan: boolean;
|
||||
budget: number | null;
|
||||
event_type: string;
|
||||
inclusions: (typeof measuresList)[number][];
|
||||
scenario_id?: string | null;
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
async function uploadCsvToS3({
|
||||
presignedUrl,
|
||||
file,
|
||||
}: {
|
||||
presignedUrl: string;
|
||||
file: Blob;
|
||||
}) {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
throw new Error("Failed to upload CSV to S3");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function generatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
}: {
|
||||
userId: string;
|
||||
portfolioId: string;
|
||||
fileKey: string;
|
||||
}) {
|
||||
const response = await fetch("/api/upload/csv", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId, portfolioId, fileKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { ...data, fileKey };
|
||||
}
|
||||
|
||||
function generateS3Keys(userId: string, portfolioId: string) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
|
||||
return {
|
||||
assetListFileKey: `${userId}/${portfolioId}/${timestamp}/asset_list.csv`,
|
||||
valuationDataFileKey: `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`,
|
||||
};
|
||||
}
|
||||
|
||||
const convertToCSV = <T extends Record<string, any>>(data: T[]): string => {
|
||||
if (data.length === 0) return "";
|
||||
const headers = Object.keys(data[0]) as (keyof T)[];
|
||||
const escape = (val: any) =>
|
||||
val == null
|
||||
? ""
|
||||
: /[",\n]/.test(String(val))
|
||||
? `"${String(val).replace(/"/g, '""')}"`
|
||||
: String(val);
|
||||
return [
|
||||
headers.join(","),
|
||||
...data.map((r) => headers.map((h) => escape(r[h])).join(",")),
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
/* ---------- Main Hook ---------- */
|
||||
|
||||
function useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn,
|
||||
addressLineOne,
|
||||
postcode,
|
||||
valuation,
|
||||
propertyType,
|
||||
builtForm,
|
||||
measures,
|
||||
scenarioId,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
uprn: number | undefined | null;
|
||||
addressLineOne: string;
|
||||
postcode: string;
|
||||
valuation: number | undefined | null;
|
||||
measures: (typeof measuresList)[number][];
|
||||
propertyType?: string | null;
|
||||
builtForm?: string | null;
|
||||
scenarioId?: string | null;
|
||||
}) {
|
||||
const { data: session } = useSession();
|
||||
const userId = String(session?.user.dbId);
|
||||
|
||||
if (uprn === undefined || valuation === undefined) {
|
||||
console.warn("Missing UPRN or valuation, cannot proceed");
|
||||
}
|
||||
|
||||
const { assetListFileKey, valuationDataFileKey } = useMemo(
|
||||
() => generateS3Keys(userId, portfolioId),
|
||||
[userId, portfolioId]
|
||||
);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadCsvToS3,
|
||||
});
|
||||
|
||||
const presignedMutation = useMutation({
|
||||
mutationFn: generatePresignedUrl,
|
||||
onSuccess: (data) => {
|
||||
let csvFile: Blob;
|
||||
if (data.fileKey === assetListFileKey) {
|
||||
const assetList: {
|
||||
uprn: number | null | undefined;
|
||||
address: string;
|
||||
postcode: string;
|
||||
property_type?: string;
|
||||
built_form?: string;
|
||||
}[] = [
|
||||
{
|
||||
uprn,
|
||||
address: addressLineOne,
|
||||
postcode,
|
||||
},
|
||||
];
|
||||
|
||||
// if we have property type and built form, include them. Handle typescript optionality
|
||||
if (propertyType) {
|
||||
assetList[0]["property_type"] = propertyType;
|
||||
}
|
||||
if (builtForm) {
|
||||
assetList[0]["built_form"] = builtForm;
|
||||
}
|
||||
|
||||
csvFile = new Blob([convertToCSV(assetList)], { type: "text/csv" });
|
||||
} else {
|
||||
const valuationData = [{ uprn, valuation }];
|
||||
csvFile = new Blob([convertToCSV(valuationData)], { type: "text/csv" });
|
||||
}
|
||||
|
||||
uploadMutation.mutate({ file: csvFile, presignedUrl: data.url });
|
||||
},
|
||||
});
|
||||
|
||||
async function triggerEngine(data: RemoteAssessmentFormValues) {
|
||||
const triggerBody: EngineTriggerBody = {
|
||||
scenario_id: scenarioId === "__new__" ? null : scenarioId,
|
||||
portfolio_id: portfolioId,
|
||||
housing_type: data.housingType,
|
||||
goal: data.goal,
|
||||
goal_value: data.goalValue || null,
|
||||
trigger_file_path: assetListFileKey,
|
||||
already_installed_file_path: "",
|
||||
patches_file_path: "",
|
||||
non_invasive_recommendations_file_path: "",
|
||||
valuation_file_path: valuation ? valuationDataFileKey : "", // We only pass a valution filepath if we have a valuation
|
||||
scenario_name: data.scenario,
|
||||
inclusions: data.measures,
|
||||
multi_plan: true,
|
||||
budget: data.budget || null,
|
||||
event_type: "remote_assessment",
|
||||
};
|
||||
|
||||
const response = await fetch("/api/plan/trigger", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(triggerBody),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to trigger engine");
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: RemoteAssessmentFormValues) {
|
||||
await Promise.all([
|
||||
presignedMutation.mutateAsync({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: assetListFileKey,
|
||||
}),
|
||||
presignedMutation.mutateAsync({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: valuationDataFileKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
await triggerEngine(formData);
|
||||
}
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
triggerEngine,
|
||||
isUploading: uploadMutation.isLoading || presignedMutation.isLoading,
|
||||
hasError: uploadMutation.isError || presignedMutation.isError,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCreateRemoteAssessment;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface PostcodeResult {
|
||||
postcode: string;
|
||||
country: string;
|
||||
region: string | null;
|
||||
admin_district: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface PostcodeLookupResponse {
|
||||
status: number;
|
||||
result: PostcodeResult | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls your /api/postcode/:postcode endpoint.
|
||||
* Handles 404 gracefully (invalid postcode),
|
||||
* 500 gracefully (external service issue),
|
||||
* and only throws on client-side misuse (400).
|
||||
*/
|
||||
async function fetchPostcode(
|
||||
postcode: string
|
||||
): Promise<PostcodeLookupResponse> {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(postcode)}`);
|
||||
const data = await res.json();
|
||||
|
||||
switch (res.status) {
|
||||
case 200:
|
||||
return { status: 200, result: data.result };
|
||||
case 404:
|
||||
// Invalid postcode (user input issue)
|
||||
return { status: 404, result: null, message: "Invalid postcode" };
|
||||
case 500:
|
||||
// External API error
|
||||
return {
|
||||
status: 500,
|
||||
result: null,
|
||||
message:
|
||||
"We're having trouble reaching the postcode service. Please try again later.",
|
||||
};
|
||||
case 400:
|
||||
// Bad query from our side (should not happen in production)
|
||||
throw new Error("Postcode API query malformed (400).");
|
||||
default:
|
||||
// Unexpected case
|
||||
return {
|
||||
status: res.status,
|
||||
result: null,
|
||||
message: data.error || "Unexpected response from postcode API",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query hook for postcode validation and lookup
|
||||
*/
|
||||
function usePostcodeLookup(postcode: string, shouldFetch: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["postcode-lookup", postcode],
|
||||
queryFn: () => fetchPostcode(postcode),
|
||||
enabled: shouldFetch && !!postcode,
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
export default usePostcodeLookup;
|
||||
|
|
@ -114,7 +114,6 @@ export interface DataItem {
|
|||
scenarios: Scenario[];
|
||||
}
|
||||
|
||||
|
||||
export async function getOverviewPortfolioData(
|
||||
portfolioId: string
|
||||
): Promise<DataItem[]> {
|
||||
|
|
@ -313,7 +312,11 @@ export async function getOverviewPortfolioData(
|
|||
{ scenarioName: "Today", data: "" },
|
||||
{
|
||||
scenarioName: portfolioName || "Default",
|
||||
data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)),
|
||||
data:
|
||||
"£" +
|
||||
formatNumber(
|
||||
(data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -333,7 +336,11 @@ export async function getOverviewPortfolioData(
|
|||
{ scenarioName: "Today", data: "" },
|
||||
{
|
||||
scenarioName: portfolioName || "Default",
|
||||
data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)),
|
||||
data:
|
||||
"£" +
|
||||
formatNumber(
|
||||
(data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -410,56 +417,6 @@ export async function getProperties(
|
|||
limit: number = 1000,
|
||||
offset: number = 0
|
||||
): Promise<PropertyWithRelations[]> {
|
||||
// When pulling in the associated recommendations, we need to pull in the associated plan and take recommendations that are associated to the
|
||||
// default plans
|
||||
|
||||
// const data: PropertyWithRelations[] = await db.query.property.findMany({
|
||||
// limit: limit,
|
||||
// offset: offset,
|
||||
// columns: {
|
||||
// id: true,
|
||||
// portfolioId: true,
|
||||
// address: true,
|
||||
// postcode: true,
|
||||
// status: true,
|
||||
// creationStatus: true,
|
||||
// currentEpcRating: true,
|
||||
// currentSapPoints: true,
|
||||
// },
|
||||
// where: eq(property.portfolioId, BigInt(portfolioId)),
|
||||
// with: {
|
||||
// target: {
|
||||
// columns: {
|
||||
// epc: true,
|
||||
// },
|
||||
// },
|
||||
// recommendations: {
|
||||
// columns: {
|
||||
// id: true,
|
||||
// estimatedCost: true,
|
||||
// sapPoints: true,
|
||||
// },
|
||||
// where: eq(recommendation.default, true),
|
||||
// with: {
|
||||
// planRecommendations: {
|
||||
// columns: {
|
||||
// planId: true,
|
||||
// },
|
||||
// with: {
|
||||
// plan: {
|
||||
// columns: {
|
||||
// id: true,
|
||||
// isDefault: true,
|
||||
// },
|
||||
// where: eq(plan.isDefault, true),
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// We need to perform the query like this because the nested query is not supported in the ORM right now
|
||||
const data: PropertyWithRelations[] = await db.query.property.findMany({
|
||||
limit: limit,
|
||||
|
|
@ -501,10 +458,37 @@ export async function getProperties(
|
|||
)
|
||||
),
|
||||
},
|
||||
plans: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
where: eq(plan.isDefault, true),
|
||||
// Associate the funding information
|
||||
with: {
|
||||
fundingPackage: {
|
||||
columns: {
|
||||
scheme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
// override status to reflect ECO4/GBIS if present
|
||||
const updated = data.map((p) => {
|
||||
const fundingScheme = p.plans.find((pl) => {
|
||||
const scheme = pl?.fundingPackage?.scheme;
|
||||
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
|
||||
})?.fundingPackage?.scheme;
|
||||
|
||||
return {
|
||||
...p,
|
||||
status: fundingScheme ? fundingScheme.toUpperCase() : p.status,
|
||||
};
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
interface UnaggregatedPortfolioPlanRecommendation {
|
||||
|
|
@ -545,7 +529,8 @@ function aggregateRecommendations(
|
|||
};
|
||||
} else {
|
||||
// if quantity is null previously, set to 0
|
||||
grouped[key].quantity = (grouped[key].quantity ?? 0) + (item.quantity ?? 0);
|
||||
grouped[key].quantity =
|
||||
(grouped[key].quantity ?? 0) + (item.quantity ?? 0);
|
||||
grouped[key].estimatedCost += item.estimatedCost;
|
||||
|
||||
if (item.propertyId) {
|
||||
|
|
@ -560,7 +545,9 @@ function aggregateRecommendations(
|
|||
|
||||
// Round the results to 2 decimal places, compute uniquePropertyCount, and count unique measureTypes
|
||||
for (const key in grouped) {
|
||||
grouped[key].quantity = parseFloat(grouped[key].quantity?.toFixed(2) || "0.00");
|
||||
grouped[key].quantity = parseFloat(
|
||||
grouped[key].quantity?.toFixed(2) || "0.00"
|
||||
);
|
||||
grouped[key].estimatedCost = parseFloat(
|
||||
grouped[key].estimatedCost.toFixed(2)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,49 @@
|
|||
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;
|
||||
|
||||
// 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