Lots of improvements

This commit is contained in:
Andras Schmelczer 2026-03-10 22:05:51 +00:00
parent ef921361ec
commit 80a5a2a774
21 changed files with 489 additions and 337 deletions

View file

@ -20,13 +20,6 @@ const ACCOUNT_TABS = [
{ key: 'settings', label: 'Settings' },
];
const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const;
const SUBSCRIPTION_LABELS: Record<string, string> = {
free: 'Free',
licensed: 'Licensed',
};
function SavedSearchesContent({
searches,
loading,
@ -196,10 +189,6 @@ function SettingsContent({
onRefreshAuth: () => Promise<void>;
onRequestVerification: (email: string) => 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 [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState<string | null>(null);
@ -207,65 +196,50 @@ function SettingsContent({
const [verificationSending, setVerificationSending] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
// Invite state
const [creatingInvite, setCreatingInvite] = useState(false);
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [inviteError, setInviteError] = useState<string | null>(null);
const [inviteCopied, setInviteCopied] = useState(false);
// Invite state — keyed by invite type for admins (who can create both kinds)
const [creatingInvite, setCreatingInvite] = useState<Record<string, boolean>>({});
const [inviteUrl, setInviteUrl] = useState<Record<string, string>>({});
const [inviteError, setInviteError] = useState<Record<string, string>>({});
const [inviteCopied, setInviteCopied] = useState<Record<string, boolean>>({});
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 handleCreateInvite = async () => {
setCreatingInvite(true);
setInviteError(null);
setInviteUrl(null);
setInviteCopied(false);
const handleCreateInvite = async (type: string) => {
setCreatingInvite((prev) => ({ ...prev, [type]: true }));
setInviteError((prev) => {
const next = { ...prev };
delete next[type];
return next;
});
setInviteUrl((prev) => {
const next = { ...prev };
delete next[type];
return next;
});
setInviteCopied((prev) => ({ ...prev, [type]: false }));
try {
const res = await fetch(apiUrl('invites'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ invite_type: type }),
}),
});
assertOk(res, 'Create invite');
const data = await res.json();
setInviteUrl(data.url);
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create invite';
setInviteError(msg);
setInviteError((prev) => ({ ...prev, [type]: msg }));
} finally {
setCreatingInvite(false);
setCreatingInvite((prev) => ({ ...prev, [type]: false }));
}
};
const handleCopyInvite = () => {
if (!inviteUrl) return;
copyToClipboard(inviteUrl, () => {
setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000);
const handleCopyInvite = (type: string) => {
const url = inviteUrl[type];
if (!url) return;
copyToClipboard(url, () => {
setInviteCopied((prev) => ({ ...prev, [type]: true }));
setTimeout(() => setInviteCopied((prev) => ({ ...prev, [type]: false })), 2000);
});
};
@ -323,7 +297,7 @@ function SettingsContent({
<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'}
{user.subscription === 'licensed' ? 'Licensed' : 'Free'}
</span>
</div>
</div>
@ -369,83 +343,48 @@ function SettingsContent({
</div>
{/* Invite friends */}
{isLicensed && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
</p>
{inviteUrl ? (
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={inviteUrl}
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
/>
{isLicensed &&
(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
<div key={type} className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
</p>
{inviteUrl[type] ? (
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={inviteUrl[type]}
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
/>
<button
onClick={() => handleCopyInvite(type)}
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
>
{inviteCopied[type] ? (
<CheckIcon className="w-4 h-4" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
{inviteCopied[type] ? 'Copied' : 'Copy'}
</button>
</div>
) : (
<button
onClick={handleCopyInvite}
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
onClick={() => handleCreateInvite(type)}
disabled={!!creatingInvite[type]}
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-wait flex items-center gap-2"
>
{inviteCopied ? (
<CheckIcon className="w-4 h-4" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
{inviteCopied ? 'Copied' : 'Copy'}
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{type === 'admin' ? 'Generate free invite link' : 'Generate referral link'}
</button>
</div>
) : (
<button
onClick={handleCreateInvite}
disabled={creatingInvite}
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-wait flex items-center gap-2"
>
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{user.isAdmin ? 'Generate free invite link' : 'Generate referral link'}
</button>
)}
{inviteError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError}</p>
)}
</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>
)}
{inviteError[type] && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError[type]}</p>
)}
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
)}
))}
</div>
{/* Support */}

View file

@ -48,7 +48,7 @@ const DATA_SOURCES = [
id: 'ethnicity',
name: 'Population by Ethnicity (2021 Census)',
origin: 'ONS',
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
license: 'Open Government Licence v3.0',
},
@ -60,14 +60,6 @@ const DATA_SOURCES = [
url: 'https://data.police.uk/data/',
license: 'Open Government Licence v3.0',
},
{
id: 'tfl-journey-times',
name: 'TfL Journey Times',
origin: 'Transport for London',
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
url: 'https://api-portal.tfl.gov.uk/',
license: 'Powered by TfL Open Data',
},
{
id: 'osm-pois',
name: 'OpenStreetMap POIs',

View file

@ -2,6 +2,12 @@ import type { FeatureMeta } from '../../types';
import { InfoIcon } from './icons';
import { getGroupIcon } from '../../lib/group-icons';
const MODE_LABELS: Record<string, string> = {
historical: 'Historical',
buy: 'Buy',
rent: 'Rent',
};
interface FeatureLabelProps {
feature: FeatureMeta;
onShowInfo?: (feature: FeatureMeta) => void;
@ -17,6 +23,10 @@ export function FeatureLabel({
}: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
const modeTag =
feature.modes && feature.modes.length > 0
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
: null;
return (
<div
@ -30,6 +40,11 @@ export function FeatureLabel({
>
{feature.name}
</span>
{modeTag && (
<span className="shrink-0 text-[10px] leading-none font-medium px-1.5 py-0.5 rounded-full bg-warm-100 dark:bg-warm-800 text-warm-500 dark:text-warm-400 border border-warm-200 dark:border-warm-700">
{modeTag}
</span>
)}
{feature.detail && onShowInfo && (
<button
onClick={() => onShowInfo(feature)}

View file

@ -20,10 +20,10 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
useClickOutside(popupRef, handleClose);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
<div
ref={popupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>

View file

@ -169,6 +169,9 @@ export function useMapData({
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
if (travelParam) {
params.set('travel', travelParam);
}
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -261,7 +264,7 @@ export function useMapData({
const vals: number[] = [];
if (usePostcodeView && !isTravelTime) {
if (usePostcodeView) {
if (effectivePostcodeData.length === 0) return null;
for (const feat of effectivePostcodeData) {
if (bounds) {

View file

@ -17,6 +17,10 @@ export function useTheme() {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
const color = theme === 'dark' ? '#0a0e1a' : '#fafaf9';
document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
m.setAttribute('content', color);
});
}, [theme]);
const toggleTheme = useCallback(() => {

View file

@ -13,6 +13,11 @@ body,
overscroll-behavior: none;
}
html {
background-color: #fafaf9;
color-scheme: light;
}
html.dark {
background-color: #0a0e1a;
color-scheme: dark;

View file

@ -119,7 +119,7 @@ export const STACKED_GROUPS: Record<
{
label: 'Ethnic composition',
unit: '%',
components: ['% White', '% Asian', '% Black', '% Mixed', '% Other'],
components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'],
},
],
};
@ -153,8 +153,8 @@ export const STACKED_ENUM_GROUPS: Record<
},
{
label: 'Leasehold/Freehold',
feature: 'Leashold/Freehold',
components: ['Leashold/Freehold'],
feature: 'Leasehold/Freehold',
components: ['Leasehold/Freehold'],
valueOrder: ['Freehold', 'Leasehold'],
valueColors: ['#3b82f6', '#f59e0b'],
},

View file

@ -113,7 +113,7 @@ export function buildPropertySearchUrls({
const maxBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined;
const tenureFilter = filters['Leashold/Freehold'];
const tenureFilter = filters['Leasehold/Freehold'];
const selectedTenures =
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
? (tenureFilter as string[])

View file

@ -15,7 +15,8 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
return `${p}${value.toFixed(1)}${s}`;
}
export function formatFilterValue(value: number): string {
export function formatFilterValue(value: number, raw?: boolean): string {
if (raw) return Math.round(value).toString();
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
if (Number.isInteger(value)) return value.toString();

File diff suppressed because one or more lines are too long

View file

@ -112,13 +112,19 @@ export interface PlaceResult {
city?: string;
}
export interface JourneyLeg {
mode: string;
from?: string;
to?: string;
minutes: number;
}
export interface RenovationEvent {
year: number;
event: string;
}
export interface Property {
// String fields
address?: string;
postcode?: string;
property_type?: string;