remote assessment ui done

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-23 21:59:09 +01:00
parent 2775477363
commit 422a30cf25
7 changed files with 330 additions and 113 deletions

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

@ -96,6 +96,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
</NavigationMenuItem>
<AddNewDropDown
portfolioId={portfolioId}
isUploadCsvOpen={modalIsOpen}
setIsUploadCsvOpen={setModalIsOpen}
isRemoteAssessmentOpen={isRemoteAssessmentOpen}

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

@ -106,17 +106,12 @@ export default function AddressSearch({
return (
<Card className="p-6">
<h2 className="text-xl font-semibold text-brandbrown mb-4">
Step 1: Search for Address
</h2>
{!selectedAddress && (
<div className="flex gap-2 mb-4">
<Input
placeholder="Enter postcode"
value={postcode}
onChange={(e) => onPostcodeSelect(e.target.value.toUpperCase())}
className="text-lg"
/>
<Button
onClick={handleSearch}
@ -169,20 +164,26 @@ export default function AddressSearch({
{/* Selected address display */}
{selectedAddress && !showDropdown && (
<div className="relative bg-gray-100 border rounded-xl p-6 mt-4">
<h3 className="text-lg font-semibold text-brandblue mb-2">
Selected Address
</h3>
<p className="text-gray-700">{selectedAddress.address}</p>
<Button
size="sm"
variant="outline"
onClick={handleChangeAddress}
className="absolute bottom-4 right-4 flex gap-1 text-brandbrown border-brandbrown"
>
<Pencil className="w-4 h-4" />
Change
</Button>
<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

@ -1,13 +1,15 @@
"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 } from "@/app/db/schema/recommendations";
import { measuresList } from "@/app/db/schema/recommendations";
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,
@ -16,6 +18,7 @@ export default function RemoteAssessmentClient({
portfolioId: string;
scenarios: ScenarioSelect[];
}) {
const router = useRouter();
const [selectedAddress, setSelectedAddress] = useState<{
address: string;
uprn: string;
@ -38,40 +41,124 @@ export default function RemoteAssessmentClient({
async function onSubmitRemoteAssessment(values: RemoteAssessmentFormValues) {
await submitAssessment(values);
router.push(`/portfolio/${portfolioId}/plan-loading`);
}
return (
<div className="max-w-4xl mx-auto p-8 space-y-12">
<div className="flex items-center gap-3 mb-8">
<BackToPortfolioButton portfolioId={portfolioId} />
<h1 className="text-3xl font-bold text-brandblue">Remote Assessment</h1>
<div className="min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white">
{/* --- HERO / EXPLANATION SECTION --- */}
<div className="relative bg-gradient-to-r from-brandblue to-midblue text-white overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center opacity-20"
style={{
backgroundImage: "url('/images/energy-analysis-placeholder.webp')",
}}
/>
<div className="relative z-10 max-w-7xl mx-auto px-8 py-8 md:py-10 flex flex-col gap-6">
{/* Title & back button pinned to top */}
<div className="flex items-center gap-4 mb-4">
<BackToPortfolioButton portfolioId={portfolioId} />
<h1 className="text-3xl md:text-4xl font-bold">
Remote Assessment
</h1>
</div>
{/* Hero text split to use horizontal space better */}
<div className="flex flex-col md:flex-row justify-between gap-10">
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl">
Domna IQ analyses your property data, models retrofit options, and
estimates potential funding all without an on-site survey.
</p>
<p className="text-sm text-white/70 max-w-md md:text-right">
Start by selecting your property, then choose retrofit goals and
configurations. Our model will generate your baseline and plan.
This isn't a replacement for an on-site survey but a powerful
first step.
</p>
</div>
{/* Step indicators */}
<div className="flex gap-6 mt-6 text-sm text-white/80 justify-start md:justify-center">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5" /> Find Address
</div>
<div className="w-8 h-[1px] bg-white/50" />
<div className="flex items-center gap-2">
<ClipboardCheck className="w-5 h-5" /> Configure Scenario
</div>
<div className="w-8 h-[1px] bg-white/50" />
<div className="flex items-center gap-2">
<Zap className="w-5 h-5" /> Generate Plan
</div>
</div>
</div>
{/* Optional subtle fade transition into workspace */}
<div className="absolute bottom-0 left-0 w-full h-2 bg-gradient-to-r from-brandblue/30 to-midblue/30 blur-sm"></div>
</div>
<AddressSearch
onAddressSelect={setSelectedAddress}
onPostcodeSelect={setSelectedPostcode}
postcode={selectedPostcode}
/>
{/* --- 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>
<div
className={`transition-all duration-300 ${
selectedAddress
? "opacity-100 pointer-events-auto cursor-default"
: "opacity-50 pointer-events-none cursor-not-allowed"
}`}
>
<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}
/>
<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

@ -82,6 +82,7 @@ export default function ScenarioSetup({
const { setValue, watch, handleSubmit, formState } = form;
const values = watch();
const [localSubmitting, setLocalSubmitting] = useState(false);
const scenarioOptions: ScenarioOption[] = useMemo(
() =>
@ -130,10 +131,10 @@ export default function ScenarioSetup({
}
}
function onSubmit(data: RemoteAssessmentFormValues) {
console.log("form Data", data);
onSubmitRemoteAssessment(data);
console.log("Submitted scenario data:", data);
async function onSubmit(data: RemoteAssessmentFormValues) {
setLocalSubmitting(true);
// Keep the button in submitting state until redirect completes
await onSubmitRemoteAssessment(data);
}
return (
@ -144,10 +145,6 @@ export default function ScenarioSetup({
: "opacity-100 cursor-default"
}`}
>
<h2 className="text-xl font-semibold text-brandbrown mb-4">
Step 2: Select or Create Scenario
</h2>
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
@ -289,7 +286,7 @@ export default function ScenarioSetup({
<div className="flex justify-end pt-6">
<Button
type="button"
disabled={isSubmitting}
disabled={isSubmitting || localSubmitting}
onClick={() =>
handleSubmit(onSubmit, (err) =>
console.log("Validation failed:", err)
@ -297,8 +294,30 @@ export default function ScenarioSetup({
}
className="flex items-center gap-2 bg-brandblue text-white py-3 font-semibold hover:bg-hoverblue"
>
{isSubmitting ? (
"Submitting..."
{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" />