Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions src/components/compare/compare-column.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/**
* One column of the side-by-side `/compare` view.
*
* Designed for fast scanning when deciding between two shortlisted
* properties — strips the listing-detail page down to the facts that
* actually change a decision: hero photo, headline price + address,
* the consolidated cost breakdown, highlights / watchouts at a
* glance, the key public records (EPC, broadband, crime relative to
* the area baseline), and the nearest-station travel times.
*
* Reuses the persisted `ListingDetailPayload` from `getListingDetail`
* — no new server function needed; the `/compare` route just runs
* two queries in parallel.
*/

import { Link } from "@tanstack/react-router";
import type {
ListingDetailHighlight,
ListingDetailPayload,
ListingDetailStationRoute,
ListingDetailWatchout,
} from "../../server/functions/listing-detail";
import { CostsCard } from "../listing-detail/costs";

type Props = {
/** Side label ("A" / "B") rendered as the column eyebrow. */
side: string;
data: ListingDetailPayload;
};

export function CompareColumn({ side, data }: Props) {
const {
cluster,
headline,
photos,
summary,
highlights,
watchouts,
epc,
publicRecords,
stationRoutes,
fineprint,
} = data;
const heroPhoto = photos[0];
return (
<article className="flex flex-col gap-3.5">
<header className="flex items-baseline justify-between">
<span className="font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.14em]">
{side}
</span>
<Link
className="text-[12px] text-primary hover:underline"
params={{ clusterId: cluster.id }}
search={{ from: "compare" }}
to="/listings/$clusterId"
>
Open full listing →
</Link>
</header>

{heroPhoto ? (
<div className="relative h-44 w-full overflow-hidden rounded-2xl bg-muted">
{/* biome-ignore lint/nursery/noImgElement: TanStack Start is not Next.js; <Image> isn't available. */}
<img
alt={headline.addressRaw}
className="h-full w-full object-cover"
src={heroPhoto.url}
/>
</div>
) : (
<div className="flex h-44 w-full items-center justify-center rounded-2xl bg-muted">
<span className="text-muted-foreground text-xs">No photos</span>
</div>
)}

<div className="flex flex-col gap-0.5">
<span className="font-medium font-serif text-[28px] text-foreground leading-none tracking-tight">
{headline.priceMonthly !== null
? `£${headline.priceMonthly.toLocaleString("en-GB")}`
: "£—"}
<span className="ml-1 text-[12px] text-muted-foreground">/mo</span>
</span>
<h2 className="font-serif text-[18px] text-foreground">
{headline.addressRaw}
</h2>
{summary ? (
<p className="mt-1 text-[13px] text-muted-foreground">{summary}</p>
) : null}
</div>

<CostsCard
fineprint={fineprint}
priceMonthly={headline.priceMonthly}
/>

<VerdictsBlock
highlights={highlights}
watchouts={watchouts}
/>

<KeyStats
crime={publicRecords?.crime}
broadband={publicRecords?.broadband}
epcRating={epc?.rating ?? null}
/>

<StationsBlock routes={stationRoutes} />
</article>
);
}

/**
* Compact highlight/watchout chip row. Caps at 3 of each to keep the
* column scannable — anyone wanting the full lists clicks 'Open full
* listing'.
*/
function VerdictsBlock({
highlights,
watchouts,
}: {
highlights: ListingDetailHighlight[];
watchouts: ListingDetailWatchout[];
}) {
if (highlights.length === 0 && watchouts.length === 0) {
return null;
}
const topHighlights = highlights.slice(0, 3);
const topWatchouts = watchouts.slice(0, 3);
return (
<section className="flex flex-col gap-2 rounded-2xl border border-border bg-card px-4 py-3.5">
<span className="font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.12em]">
Stands out
</span>
{topHighlights.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{topHighlights.map((h) => (
<li
className="rounded-full bg-[#5D7A4A]/15 px-2.5 py-1 text-[11px] text-foreground"
key={h.label}
>
{h.label}
</li>
))}
</ul>
) : null}
{topWatchouts.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{topWatchouts.map((w) => (
<li
className={`rounded-full px-2.5 py-1 text-[11px] text-foreground ${
w.severity === "problem"
? "bg-[#B26B3F]/20"
: "bg-[#B26B3F]/10"
}`}
key={w.label}
>
{w.label}
</li>
))}
</ul>
) : null}
</section>
);
}

function KeyStats({
crime,
broadband,
epcRating,
}: {
crime: NonNullable<ListingDetailPayload["publicRecords"]>["crime"];
broadband: NonNullable<ListingDetailPayload["publicRecords"]>["broadband"];
epcRating: string | null;
}) {
return (
<section className="flex flex-col gap-2 rounded-2xl border border-border bg-card px-4 py-3.5">
<span className="font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.12em]">
Key stats
</span>
<dl className="flex flex-col gap-1.5 text-[13px]">
<Stat label="EPC" value={epcRating ?? "Pending"} />
<Stat
label="Broadband"
value={
broadband
? `${broadband.technology ?? "—"} · ${broadband.downloadMbps ?? "?"} Mbps`
: "Pending"
}
/>
<Stat
label="Crime"
value={crime ? `${crime.total} in 1mi` : "Pending"}
sub={crime?.comparison?.label}
/>
</dl>
</section>
);
}

function Stat({
label,
value,
sub,
}: {
label: string;
value: string;
sub?: string;
}) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-muted-foreground">{label}</dt>
<dd className="flex flex-col items-end">
<span className="font-medium text-foreground">{value}</span>
{sub ? <span className="text-[11px] text-muted-foreground">{sub}</span> : null}
</dd>
</div>
);
}

function StationsBlock({
routes,
}: {
routes: ListingDetailStationRoute[] | undefined;
}) {
if (!routes || routes.length === 0) {
return null;
}
return (
<section className="flex flex-col gap-2 rounded-2xl border border-border bg-card px-4 py-3.5">
<span className="font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.12em]">
Nearest station{routes.length === 1 ? "" : "s"}
</span>
<ul className="flex flex-col gap-1.5">
{routes.map((route) => (
<li
className="flex items-baseline justify-between gap-3 text-[13px]"
key={route.name}
>
<span className="min-w-0 truncate font-medium text-foreground">
{route.name}
</span>
<span className="flex shrink-0 items-baseline gap-2 text-muted-foreground text-xs">
{route.walkMinutes != null ? (
<span>
<span className="font-semibold text-foreground">
{route.walkMinutes}
</span>{" "}
min walk
</span>
) : null}
{route.walkMinutes != null && route.transitMinutes != null ? (
<span aria-hidden className="text-muted-foreground/50">
·
</span>
) : null}
{route.transitMinutes != null ? (
<span>
<span className="font-semibold text-foreground">
{route.transitMinutes}
</span>{" "}
min bus
</span>
) : null}
</span>
</li>
))}
</ul>
</section>
);
}
8 changes: 6 additions & 2 deletions src/lib/listing-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
import { z } from "zod";

export const listingFromOriginSchema = z
.enum(["review", "shortlist", "matches"])
.enum(["review", "shortlist", "matches", "compare"])
.optional();

export type ListingFromOrigin = z.infer<typeof listingFromOriginSchema>;

type OriginMeta = {
/** Path the back button + breadcrumb anchor should target. */
path: "/" | "/shortlist" | "/matches";
path: "/" | "/shortlist" | "/matches" | "/compare";
/** Label rendered in the breadcrumb. */
label: string;
/** Sidebar nav `to` to mark active when on /listings/*. */
Expand All @@ -31,6 +31,10 @@ const ORIGIN_TABLE: Record<NonNullable<ListingFromOrigin>, OriginMeta> = {
// Matches lives under Shortlist in the IA, so the sidebar still
// highlights Shortlist even though the breadcrumb says Matches.
matches: { path: "/matches", label: "Matches", sidebarTo: "/shortlist" },
// `/compare` is reached from Shortlist (the only place you'd select
// two listings to compare), so the back button + sidebar both
// resolve to Shortlist on the listing-detail page when `from=compare`.
compare: { path: "/compare", label: "Compare", sidebarTo: "/shortlist" },
};

const DEFAULT_ORIGIN: OriginMeta = ORIGIN_TABLE.review;
Expand Down
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Route as SignupRouteImport } from './routes/signup'
import { Route as ShortlistRouteImport } from './routes/shortlist'
import { Route as MatchesRouteImport } from './routes/matches'
import { Route as LoginRouteImport } from './routes/login'
import { Route as CompareRouteImport } from './routes/compare'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SearchesIndexRouteImport } from './routes/searches/index'
import { Route as SettingsHouseholdRouteImport } from './routes/settings/household'
Expand Down Expand Up @@ -41,6 +42,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const CompareRoute = CompareRouteImport.update({
id: '/compare',
path: '/compare',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
Expand Down Expand Up @@ -79,6 +85,7 @@ const InviteTokenRoute = InviteTokenRouteImport.update({

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/compare': typeof CompareRoute
'/login': typeof LoginRoute
'/matches': typeof MatchesRoute
'/shortlist': typeof ShortlistRoute
Expand All @@ -92,6 +99,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/compare': typeof CompareRoute
'/login': typeof LoginRoute
'/matches': typeof MatchesRoute
'/shortlist': typeof ShortlistRoute
Expand All @@ -106,6 +114,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/compare': typeof CompareRoute
'/login': typeof LoginRoute
'/matches': typeof MatchesRoute
'/shortlist': typeof ShortlistRoute
Expand All @@ -121,6 +130,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/compare'
| '/login'
| '/matches'
| '/shortlist'
Expand All @@ -134,6 +144,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/compare'
| '/login'
| '/matches'
| '/shortlist'
Expand All @@ -147,6 +158,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/compare'
| '/login'
| '/matches'
| '/shortlist'
Expand All @@ -161,6 +173,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
CompareRoute: typeof CompareRoute
LoginRoute: typeof LoginRoute
MatchesRoute: typeof MatchesRoute
ShortlistRoute: typeof ShortlistRoute
Expand Down Expand Up @@ -203,6 +216,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/compare': {
id: '/compare'
path: '/compare'
fullPath: '/compare'
preLoaderRoute: typeof CompareRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
Expand Down Expand Up @@ -257,6 +277,7 @@ declare module '@tanstack/react-router' {

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
CompareRoute: CompareRoute,
LoginRoute: LoginRoute,
MatchesRoute: MatchesRoute,
ShortlistRoute: ShortlistRoute,
Expand Down
Loading
Loading