This commit is contained in:
Andras Schmelczer 2026-02-15 09:48:30 +00:00
parent 128b3191e7
commit 03445188ea
54 changed files with 596953 additions and 3577 deletions

View file

@ -0,0 +1,131 @@
import { useState } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
const SUBSCRIPTION_OPTIONS = ['free', 'rental', 'buyer'] as const;
const SUBSCRIPTION_LABELS: Record<string, string> = {
free: 'Free',
rental: 'Rental',
buyer: 'Buyer',
};
export default function AccountPage({
user,
onRefreshAuth,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
}) {
const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setError(null);
setSaved(false);
try {
const res = await fetch(apiUrl('subscription'), {
method: 'PATCH',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: selectedSubscription }),
}),
});
assertOk(res, 'Update subscription');
await onRefreshAuth();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update subscription';
setError(msg);
} finally {
setSaving(false);
}
};
const badgeColor =
user.subscription === 'buyer'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: user.subscription === 'rental'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-lg mx-auto px-6 py-16">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-8">Account</h1>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</div>
{/* Subscription */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
{SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'}
</span>
</div>
</div>
{/* Admin section */}
{user.isAdmin && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
Admin: Change subscription
</p>
<div className="flex items-center gap-3">
<select
value={selectedSubscription}
onChange={(e) => setSelectedSubscription(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
>
{SUBSCRIPTION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{SUBSCRIPTION_LABELS[opt]}
</option>
))}
</select>
<button
onClick={handleSave}
disabled={saving || selectedSubscription === user.subscription}
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{saving ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckIcon className="w-4 h-4" />
) : null}
{saved ? 'Saved' : 'Save'}
</button>
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,131 +1,63 @@
import { useRef, useEffect } from 'react';
import { useMemo } from 'react';
const HEX_COUNT = 70;
const TAU = Math.PI * 2;
const HEX_COUNT = 50;
interface Hex {
x: number;
y: number;
baseY: number;
interface HexConfig {
size: number;
opacity: number;
speed: number;
phase: number;
top: number;
driftDuration: number;
bobDuration: number;
bobAmount: number;
delay: number;
reverse: boolean;
}
function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
function generateHexes(): HexConfig[] {
const hexes: HexConfig[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
const side = Math.random() < 0.5 ? 'left' : 'right';
const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3;
const driftDuration = 18 + Math.random() * 35;
hexes.push({
x,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.08 + Math.random() * 0.15,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
size: 10 + Math.random() * 32,
opacity: 0.06 + Math.random() * 0.18,
top: Math.random() * 100,
driftDuration,
bobDuration: 3 + Math.random() * 5,
bobAmount: 8 + Math.random() * 30,
delay: -Math.random() * driftDuration,
reverse: Math.random() < 0.3,
});
}
return hexes;
}
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (TAU / 6) * i - Math.PI / 6;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = 0;
let h = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas!.parentElement!.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = `${w}px`;
canvas!.style.height = `${h}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
hexesRef.current = initHexes(w, h);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas.parentElement!);
let prev = performance.now();
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
ctx!.clearRect(0, 0, w, h);
for (const hex of hexesRef.current) {
hex.x += hex.speed * dt * 0.3;
if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
hex.x = w * 0.7 + hex.size;
}
if (hex.x > w + hex.size * 2) {
hex.x = -hex.size * 2;
hex.y = Math.random() * h;
hex.baseY = hex.y;
}
const bob = Math.sin(now / 1000 + hex.phase) * 8;
hex.y = hex.baseY + bob;
const dark = isDarkRef.current;
ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1);
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1);
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.stroke();
}
animRef.current = requestAnimationFrame(frame);
}
animRef.current = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(animRef.current);
ro.disconnect();
};
}, []);
const hexes = useMemo(generateHexes, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 0 }}
/>
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
{hexes.map((hex, i) => (
<div
key={i}
className="absolute"
style={{
top: `${hex.top}%`,
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
}}
>
<div
className="bg-teal-500"
style={{
width: hex.size,
height: hex.size,
opacity: hex.opacity * (isDark ? 0.6 : 1),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
'--bob': `${hex.bobAmount}px`,
} as React.CSSProperties}
/>
</div>
))}
</div>
);
}

View file

@ -43,7 +43,7 @@ export default function HomePage({
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6">
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>

View file

@ -119,7 +119,37 @@ export default memo(function Filters({
onAiFilterSubmit,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const enabledFeatureList = features.filter(
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
);
const listingToggles = useMemo(() => {
const val = filters['Listing status'] as string[] | undefined;
if (!val) return { historical: true, buy: true, rent: true };
return {
historical: val.includes('Historical sale'),
buy: val.includes('For sale'),
rent: val.includes('For rent'),
};
}, [filters]);
const handleListingToggle = useCallback(
(key: 'historical' | 'buy' | 'rent') => {
const next = { ...listingToggles, [key]: !listingToggles[key] };
const allOn = next.historical && next.buy && next.rent;
const allOff = !next.historical && !next.buy && !next.rent;
if (allOn || allOff) {
onRemoveFilter('Listing status');
return;
}
const values: string[] = [];
if (next.historical) values.push('Historical sale');
if (next.buy) values.push('For sale');
if (next.rent) values.push('For rent');
onFilterChange('Listing status', values);
},
[listingToggles, onFilterChange, onRemoveFilter]
);
const containerRef = useRef<HTMLDivElement>(null);
const [showPhilosophy, setShowPhilosophy] = useState(false);
@ -155,7 +185,8 @@ export default memo(function Filters({
return scales;
}, [features]);
const badgeCount = enabledFeatureList.length + activeModes.length;
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
const badgeCount = enabledFeatureList.length + activeModes.length + (hasListingFilter ? 1 : 0);
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -171,6 +202,17 @@ export default memo(function Filters({
</button>
</div>
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
<PillGroup>
<PillToggle label="Historical" active={listingToggles.historical}
onClick={() => handleListingToggle('historical')} size="xs" />
<PillToggle label="Buy" active={listingToggles.buy}
onClick={() => handleListingToggle('buy')} size="xs" />
<PillToggle label="Rent" active={listingToggles.rent}
onClick={() => handleListingToggle('rent')} size="xs" />
</PillGroup>
</div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">

View file

@ -118,7 +118,7 @@ export default function LocationSearch({
}
return (
<div ref={containerRef} className="absolute top-3 left-3 z-10 flex flex-col">
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput

View file

@ -37,7 +37,7 @@ interface MapProps {
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
@ -18,6 +18,9 @@ import { usePaneResize } from '../../hooks/usePaneResize';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
TRANSPORT_MODES,
@ -191,8 +194,8 @@ export default function MapPage({
// On mobile, open drawer and switch tab when hexagon is clicked
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean) => {
handleHexagonClick(id, isPostcode);
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
if (id) {
setMobileDrawerOpen(true);
}
@ -225,6 +228,9 @@ export default function MapPage({
mapData.resolution,
]);
// Tutorial
const tutorial = useTutorial(initialLoading, isMobile);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
@ -551,8 +557,20 @@ export default function MapPage({
</div>
)}
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
/>
{/* Left Pane */}
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
@ -566,7 +584,7 @@ export default function MapPage({
</div>
{/* Map */}
<div className="flex-1 relative">
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -599,6 +617,7 @@ export default function MapPage({
)}
{/* Floating POI button */}
<button
data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
@ -614,6 +633,7 @@ export default function MapPage({
{/* Right Pane */}
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
export default function Header({
activePage,
@ -200,7 +200,7 @@ export default function Header({
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} />
<UserMenu user={user} onLogout={onLogout} onPageChange={onPageChange} />
) : (
<>
<button

View file

@ -83,6 +83,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (

View file

@ -1,7 +1,16 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { Page } from './Header';
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
export default function UserMenu({
user,
onLogout,
onPageChange,
}: {
user: AuthUser;
onLogout: () => void;
onPageChange: (page: Page) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -37,6 +46,15 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
</p>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onPageChange('account');
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</button>
<button
onClick={() => {
setOpen(false);