Merge pull request #107 from Hestia-Homes/main

funding filtering, new data tables
This commit is contained in:
KhalimCK 2025-10-28 12:06:52 +00:00 committed by GitHub
commit adab712fc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 28905 additions and 2382 deletions

1
generate_migration.sh Normal file
View file

@ -0,0 +1 @@
npx drizzle-kit generate

2011
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

1
push_to_db.sh Normal file
View file

@ -0,0 +1 @@
npx drizzle-kit push

View file

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

View file

@ -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 its available in sessions
*/
async jwt({ token, user: userFromLogin }) {
// Initial sign-in: attach DB fields
if (userFromLogin) {
const existing = await db.query.user.findFirst({
where: eq(users.email, userFromLogin.email!),
});
if (existing) {
token.onboarded = existing.onboarded;
}
} else if (token.email) {
// On subsequent calls, keep token synced from DB
const existing = await db.query.user.findFirst({
where: eq(users.email, token.email),
});
if (existing) {
token.onboarded = existing.onboarded;
}
}
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,
};

View file

@ -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 };

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

View file

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

View file

@ -0,0 +1,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);
}

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

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

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

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

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

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/app/db/db";
import { user } from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/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,
});
}
}

View file

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/app/db/db";
import { userProfiles, user } from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/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",
"25",
"620",
"21+",
"150",
"51100",
"101300",
"3011000",
"1000+",
])
.optional(),
goals: z
.array(
z.enum([
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
])
)
.min(1),
referralSource: z.enum([
"search",
"social_media",
"NRLA",
"partner",
"word_of_mouth",
"other",
]),
nrlaMembershipId: z.string().optional(),
marketingOptIn: z.boolean().optional(),
acceptedPrivacy: z.boolean().refine((v) => v === true, {
message: "You must accept our privacy policy to continue",
}),
});
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return new NextResponse(JSON.stringify({ msg: "Unauthenticated" }), {
status: 401,
});
}
const body = await req.json();
const parsed = UserProfileSchema.parse(body);
// 1⃣ Get user from DB
const existingUser = await db.query.user.findFirst({
where: eq(user.email, session.user.email),
});
if (!existingUser) {
return new NextResponse(JSON.stringify({ msg: "User not found" }), {
status: 404,
});
}
// 2⃣ Check if profile already exists
const existingProfile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, existingUser.id),
});
// Timestamps for policy and marketing
const now = new Date();
const acceptedPrivacyAt = parsed.acceptedPrivacy ? now : null;
const marketingOptInAt = parsed.marketingOptIn ? now : null;
if (existingProfile) {
await db
.update(userProfiles)
.set({
firstName: parsed.firstName,
lastName: parsed.lastName,
userType: parsed.userType,
propertyCount: parsed.propertyCount,
goals: parsed.goals,
referralSource: parsed.referralSource,
nrlaMembershipId: parsed.nrlaMembershipId,
marketingOptIn: parsed.marketingOptIn ?? false,
acceptedPrivacy: parsed.acceptedPrivacy,
acceptedPrivacyAt,
marketingOptInAt,
updatedAt: now,
})
.where(eq(userProfiles.userId, existingUser.id));
} else {
await db.insert(userProfiles).values({
userId: existingUser.id,
firstName: parsed.firstName,
lastName: parsed.lastName,
userType: parsed.userType,
propertyCount: parsed.propertyCount,
goals: parsed.goals,
referralSource: parsed.referralSource,
nrlaMembershipId: parsed.nrlaMembershipId,
marketingOptIn: parsed.marketingOptIn ?? false,
acceptedPrivacy: parsed.acceptedPrivacy,
acceptedPrivacyAt,
marketingOptInAt,
});
}
return new NextResponse(JSON.stringify({ msg: "Profile saved" }), {
status: 200,
});
} catch (err) {
console.error("Error saving user profile:", err);
if (err instanceof z.ZodError) {
return new NextResponse(
JSON.stringify({ msg: "Invalid input", errors: err.flatten() }),
{
status: 400,
}
);
}
return new NextResponse(JSON.stringify({ msg: "Server error" }), {
status: 500,
});
}
}

View file

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

View file

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

View file

@ -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. 🎉"
/>
</>
);
}

View file

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

View file

@ -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">

View file

@ -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}

View file

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

View file

@ -9,6 +9,7 @@ import * as solarSchema from "@/app/db/schema/solar";
import * as EnergyAssessmentsSchema from "@/app/db/schema/energy_assessments";
import * as FundingSchema from "@/app/db/schema/funding";
import * as Relations from "@/app/db/schema/relations";
import * as Users from "@/app/db/schema/users";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -29,6 +30,7 @@ const schema = {
...Relations,
...EnergyAssessmentsSchema,
...FundingSchema,
...Users,
};
export const db = drizzle(pool, {

View file

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

View file

@ -0,0 +1,22 @@
CREATE TYPE "public"."user_profiles_property_count" AS ENUM('1', '25', '620', '21+', '150', '51100', '101300', '3011000', '1000+');--> statement-breakpoint
CREATE TYPE "public"."user_profiles_referral_source" AS ENUM('search', 'social_media', 'NRLA', 'partner', 'word_of_mouth', 'other');--> statement-breakpoint
CREATE TYPE "public"."user_profiles_user_type" AS ENUM('private_landlord', 'private_tenant', 'social_landlord', 'social_tenant', 'homeowner', 'other');--> statement-breakpoint
CREATE TABLE "user_profiles" (
"id" bigserial PRIMARY KEY NOT NULL,
"user_id" bigint NOT NULL,
"user_type" "user_profiles_user_type" NOT NULL,
"property_count" "user_profiles_property_count",
"goals" json,
"referral_source" "user_profiles_referral_source",
"nrla_membership_id" varchar(255),
"accepted_privacy" boolean DEFAULT false NOT NULL,
"accepted_privacy_at" timestamp (6) with time zone,
"marketing_opt_in" boolean DEFAULT false,
"marketing_opt_in_at" timestamp (6) with time zone,
"first_name" text,
"last_name" text,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View 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;

View file

@ -0,0 +1 @@
ALTER TABLE "property_status_tracker" DROP COLUMN "hubspot_pipeline_id";

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

View 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;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -820,6 +820,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
}
]
}

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

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

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,174 @@
import { bigserial, text, timestamp, pgTable } from "drizzle-orm/pg-core";
import {
bigint,
bigserial,
text,
timestamp,
pgTable,
primaryKey,
integer,
boolean,
json,
pgEnum,
varchar,
} from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
// -------------------------
// USERS
// -------------------------
export const user = pgTable("user", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
firstName: text("firstName"),
// At the moment, Drizzle doesn't support unique constraints
email: text("email").notNull(),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
oauthId: text("oauth_id"),
oauthProvider: text("oauth_provider").$type<"google" | "credentials">(),
// role: text("role").$type<"admin" | "write" | "read">(),
oauthProvider: text("oauth_provider").$type<
"google" | "credentials" | "azure-ad-b2c"
>(),
image: text("image"),
onboarded: boolean("onboarded").default(false).notNull(),
lastLogin: timestamp("last_login", { mode: "date" }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
// -------------------------
// ACCOUNTS (OAuth providers)
// -------------------------
export const accounts = pgTable(
"account",
{
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: text("type").$type<"oauth" | "email" | "credentials">().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
primaryKey({ columns: [account.provider, account.providerAccountId] }),
]
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
export const UserType: [string, ...string[]] = [
"private_landlord",
"private_tenant",
"social_landlord",
"social_tenant",
"homeowner",
"other",
];
export const PropertyCount: [string, ...string[]] = [
// Private landlord options
"1",
"25",
"620",
"21+",
// Social landlord options
"150",
"51100",
"101300",
"3011000",
"1000+",
];
export const ReferralSource: [string, ...string[]] = [
"search",
"social_media",
"NRLA",
"partner",
"word_of_mouth",
"other",
];
export const Goal: [string, ...string[]] = [
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
];
export const userTypeEnum = pgEnum("user_profiles_user_type", UserType);
export const propertyCountEnum = pgEnum(
"user_profiles_property_count",
PropertyCount
);
export const referralSourceEnum = pgEnum(
"user_profiles_referral_source",
ReferralSource
);
// ----------------------------
// MAIN TABLE
// ----------------------------
export const userProfiles = pgTable("user_profiles", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
userId: bigint("user_id", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Profile
userType: userTypeEnum("user_type").notNull(),
propertyCount: propertyCountEnum("property_count"), // Nullable for homeowners / tenants
// Goals (multi-select)
goals: json("goals").$type<(typeof Goal)[number][]>(),
// Referral
referralSource: referralSourceEnum("referral_source"),
nrlaMembershipId: varchar("nrla_membership_id", { length: 255 }),
// Compliance
acceptedPrivacy: boolean("accepted_privacy").notNull().default(false),
acceptedPrivacyAt: timestamp("accepted_privacy_at", {
withTimezone: true,
precision: 6,
}),
// Marketing
marketingOptIn: boolean("marketing_opt_in").default(false),
marketingOptInAt: timestamp("marketing_opt_in_at", {
withTimezone: true,
precision: 6,
}),
// Basic user identity
firstName: text("first_name"),
lastName: text("last_name"),
// Metadata
createdAt: timestamp("created_at", {
precision: 6,
withTimezone: true,
@ -23,5 +183,15 @@ export const user = pgTable("user", {
.notNull(),
});
// -------------------------
// Types
// -------------------------
export type User = InferModel<typeof user, "select">;
export type NewUser = InferModel<typeof user, "insert">;
export type Account = InferModel<typeof accounts, "select">;
export type Session = InferModel<typeof sessions, "select">;
export type VerificationToken = InferModel<typeof verificationTokens, "select">;
export type UserProfile = InferModel<typeof userProfiles, "select">;
export type NewUserProfile = InferModel<typeof userProfiles, "insert">;

View file

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

View file

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

View file

@ -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>
&copy; {new Date().getFullYear()} Domna. All rights reserved. Domna
proprietary IP.

View file

@ -0,0 +1,45 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
interface OnboardData {
firstName: string;
lastName: string;
userType: string;
propertyCount?: string;
goals: string[];
referralSource: string;
nrlaMembershipId?: string;
marketingOptIn?: boolean;
}
export function useOnboardUser() {
const router = useRouter();
const { update } = useSession();
return useMutation({
mutationFn: async (data: OnboardData) => {
// 1) Create user profile
const res1 = await fetch("/api/user/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res1.ok) throw new Error("Failed to create user profile");
// 2) Mark user as onboarded
const res2 = await fetch("/api/user/onboarded", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ onboarded: true }),
});
if (!res2.ok) throw new Error("Failed to update onboarding status");
},
onSuccess: async () => {
// 3) Redirect once both requests succeed
// Refresh the token with the next-auth session update
await update();
router.push("/home");
},
});
}

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

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

View file

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

View file

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

View file

@ -60,6 +60,7 @@ export default async function DashboardLayout(props: {
<Toolbar
propertyId={propertyId}
portfolioId={portfolioId}
propertyMeta={propertyMeta}
decentHomes={decentHomes}
/>
</div>

View file

@ -67,4 +67,4 @@ export default async function BuildingPassportHome(
</div>
</div>
);
}
}

View 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

View 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 = "Youre 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"]}
/>
)}
</>
);
}

View file

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

View file

@ -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}

View file

@ -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"
>
&#8203;
</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.");
}

View file

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

View file

@ -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&apos;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 &apos;Go back to portfolio&apos;.
</div>
<div className="text-sm mb-4 text-slate-600">
We&apos;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>
);

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

View file

@ -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`&apos;`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>
);
}

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,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 apps authenticated areas
"/home/:path*",
"/portfolio/:path*",
"/search/:path*",
"/addresses/:path",
"/due-considerations/:path",
"/eco-spreadsheet/:path",
"/addresses/:path*",
"/due-considerations/:path*",
"/eco-spreadsheet/:path*",
"/onboarding", // add onboarding itself
],
};
export { default } from "next-auth/middleware";

View file

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

View file

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