Lots of improvements
This commit is contained in:
parent
ef921361ec
commit
80a5a2a774
21 changed files with 489 additions and 337 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ body,
|
|||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: #fafaf9;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #0a0e1a;
|
||||
color-scheme: dark;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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[])
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
1
frontend/src/lib/rightmove-outcodes.json
Normal file
1
frontend/src/lib/rightmove-outcodes.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue