Merge pull request #104 from Hestia-Homes/remote-assessment-ui

Remote assessment UI
This commit is contained in:
KhalimCK 2025-10-27 15:03:01 +00:00 committed by GitHub
commit be08df8f05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 5532 additions and 1030 deletions

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

@ -27,7 +27,7 @@ import { PropertyMeta } from "@/app/db/schema/property";
interface ToolbarProps {
propertyId: string;
portfolioId: string;
propertyMeta: PropertyMeta
propertyMeta: PropertyMeta;
decentHomes: getUploadedFile;
}
@ -172,7 +172,7 @@ export function Toolbar({
<BookSurveyModal
open={openModal}
onOpenChange={setOpenModal}
propertyId={propertyId}
propertyId={BigInt(propertyId)}
portfolioId={portfolioId}
address={propertyMeta.address}
onSuccess={() => setShowToast(true)}

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

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

File diff suppressed because it is too large Load diff

View file

@ -848,6 +848,13 @@
"when": 1761146299937,
"tag": "0120_flashy_puck",
"breakpoints": true
},
{
"idx": 121,
"version": "7",
"when": 1761218186670,
"tag": "0121_chunky_tony_stark",
"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

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

@ -1,7 +1,16 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useEffect, use, useCallback } 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 }>;
@ -9,14 +18,29 @@ export default function LoadingPage(props: {
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 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(() => {
if (portfolioId) {
router.push(`/portfolio/${portfolioId}`);
} else {
router.push(`/home`);
}
router.push(portfolioId ? `/portfolio/${portfolioId}` : "/home");
}, [portfolioId, router]);
useEffect(() => {
@ -24,51 +48,136 @@ export default function LoadingPage(props: {
handleBackToPortfolio();
return;
}
const timer = setInterval(() => {
setCountdown((prev) => prev - 1);
}, 1000);
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

@ -1,12 +1,19 @@
export default function RemoteAssessmentPage() {
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 (
<div className="max-w-3xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Remote Assessment</h1>
<p className="mb-6">
Welcome to the Remote Assessment page. Here you can start your remote
assessment process.
</p>
{/* Additional content and components for remote assessment can be added here */}
</div>
<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

@ -6,9 +6,6 @@ export async function middleware(req: NextRequest) {
const token = await getToken({ req });
const { pathname } = req.nextUrl;
console.log("token", token);
console.log("onboarded", token?.onboarded);
// If no session, send user to sign-in page
if (!token) {
return NextResponse.redirect(new URL("/", req.url));