seems alright
This commit is contained in:
parent
ebe7bbb51d
commit
eac1bd0d13
58 changed files with 23125 additions and 153505 deletions
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
|
|
@ -47,6 +47,7 @@
|
|||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"compression-webpack-plugin": "^12.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^9.39.4",
|
||||
|
|
@ -66,6 +67,7 @@
|
|||
"sharp": "^0.34.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"ts-loader": "^9.5.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5",
|
||||
|
|
@ -8169,6 +8171,27 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression-webpack-plugin": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-12.0.0.tgz",
|
||||
"integrity": "sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"schema-utils": "^4.2.0",
|
||||
"serialize-javascript": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"webpack": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"compression-webpack-plugin": "^12.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^9.39.4",
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"sharp": "^0.34.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"ts-loader": "^9.5.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5",
|
||||
|
|
|
|||
|
|
@ -236,6 +236,13 @@ export default function App() {
|
|||
const authCompletedRef = useRef(false);
|
||||
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
|
||||
|
||||
// Keep a ref to the latest refreshAuth so the mount-only startup effect always
|
||||
// calls the current implementation without re-running when the callback identity changes.
|
||||
const refreshAuthRef = useRef(refreshAuth);
|
||||
useEffect(() => {
|
||||
refreshAuthRef.current = refreshAuth;
|
||||
}, [refreshAuth]);
|
||||
|
||||
const openAuthModal = useCallback(
|
||||
(
|
||||
tab: 'login' | 'register',
|
||||
|
|
@ -284,14 +291,14 @@ export default function App() {
|
|||
async function refreshOnStartup() {
|
||||
if (!returnedFromCheckout) {
|
||||
// Always refresh auth on startup to pick up server-side subscription changes.
|
||||
refreshAuth().catch(() => {});
|
||||
refreshAuthRef.current().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
setLicenseSuccessStatus('verifying');
|
||||
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const refreshedUser = await refreshAuth();
|
||||
const refreshedUser = await refreshAuthRef.current();
|
||||
if (cancelled) return;
|
||||
if (hasFullAccess(refreshedUser)) {
|
||||
trackEvent('Purchase');
|
||||
|
|
@ -314,7 +321,9 @@ export default function App() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Mount-only: this is a startup auth refresh / license verification handshake
|
||||
// that must fire exactly once on initial load. refreshAuth is read via ref.
|
||||
}, []);
|
||||
|
||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
|
@ -381,20 +390,17 @@ export default function App() {
|
|||
[inviteCode]
|
||||
);
|
||||
|
||||
const handleEditSearch = useCallback(
|
||||
(id: string, name: string, params: string) => {
|
||||
const search = params.startsWith('?') ? params : `?${params}`;
|
||||
dashboardSearchRef.current = search;
|
||||
const url = `/dashboard${search}`;
|
||||
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(search);
|
||||
setRouteHash('');
|
||||
setActivePage('dashboard');
|
||||
setEditingSearch({ id, name });
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleEditSearch = useCallback((id: string, name: string, params: string) => {
|
||||
const search = params.startsWith('?') ? params : `?${params}`;
|
||||
dashboardSearchRef.current = search;
|
||||
const url = `/dashboard${search}`;
|
||||
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(search);
|
||||
setRouteHash('');
|
||||
setActivePage('dashboard');
|
||||
setEditingSearch({ id, name });
|
||||
}, []);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingSearch(null);
|
||||
|
|
@ -451,13 +457,25 @@ export default function App() {
|
|||
activePageRef.current = activePage;
|
||||
}, [activePage]);
|
||||
|
||||
// Refs for the initial history.replaceState seed below — the popstate effect runs
|
||||
// mount-only, but it needs to read the *initial* page/hash/inviteCode values once.
|
||||
const initialPageRef = useRef(activePage);
|
||||
const initialRouteHashRef = useRef(routeHash);
|
||||
const initialInviteCodeRef = useRef(inviteCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.history.state?.page) {
|
||||
const hash = routeHash || normalizeHash(window.location.hash);
|
||||
const initialActivePage = initialPageRef.current;
|
||||
const hash = initialRouteHashRef.current || normalizeHash(window.location.hash);
|
||||
window.history.replaceState(
|
||||
{ page: activePage, hash },
|
||||
{ page: initialActivePage, hash },
|
||||
'',
|
||||
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
|
||||
buildPageUrl(
|
||||
initialActivePage,
|
||||
initialInviteCodeRef.current ?? undefined,
|
||||
window.location.search,
|
||||
hash
|
||||
)
|
||||
);
|
||||
}
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
|
|
@ -487,7 +505,10 @@ export default function App() {
|
|||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Mount-only: registers a single popstate listener for the app lifetime and
|
||||
// seeds initial history state. The handler uses only stable setters and module
|
||||
// functions; initial-render values are read via refs above.
|
||||
}, []);
|
||||
|
||||
const { fetchSearches } = savedSearches;
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ export default function HomePage({
|
|||
)}
|
||||
</td>
|
||||
{[row.postcode, row.guides].map((has, j) => {
|
||||
const statusLabel = has ? 'Yes' : 'No';
|
||||
const statusLabel = has ? t('common.yes') : t('common.no');
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
|
|
@ -520,11 +520,11 @@ export default function HomePage({
|
|||
);
|
||||
})}
|
||||
<td
|
||||
aria-label="Yes"
|
||||
aria-label={t('common.yes')}
|
||||
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
|
||||
>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span className="sr-only">Yes</span>
|
||||
<span className="sr-only">{t('common.yes')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export default function AreaPane({
|
|||
};
|
||||
|
||||
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
|
||||
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.min')}`;
|
||||
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.minute')}`;
|
||||
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
|
||||
};
|
||||
|
||||
|
|
@ -165,369 +165,217 @@ export default function AreaPane({
|
|||
<div className="relative flex h-full flex-col">
|
||||
<IndeterminateProgressBar show={loading && stats != null} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
||||
</h2>
|
||||
{loading && (
|
||||
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
||||
</h2>
|
||||
{loading && (
|
||||
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||
{t('areaPane.statsFor', {
|
||||
type: isPostcode
|
||||
? t('common.postcode').toLowerCase()
|
||||
: t('common.area').toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('common.propertiesPlural')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
||||
{t('areaPane.statsBasis')}
|
||||
</span>
|
||||
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filtersActive}
|
||||
aria-pressed={statsUseFilters && filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
statsUseFilters && filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.matchingFiltersOption')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!statsUseFilters || !filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
!statsUseFilters || !filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.allPropertiesOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||
{filtersActive
|
||||
? statsUseFilters
|
||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||
: t('areaPane.filtersIgnoredForStats')
|
||||
: t('areaPane.noFiltersAffectStats')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showFlipToggleCallout && (
|
||||
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
|
||||
<p className="mt-1">
|
||||
{unfilteredCount != null
|
||||
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
|
||||
: t('areaPane.showAllStatsFallback')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||
>
|
||||
{t('areaPane.showAllStats')}
|
||||
</button>
|
||||
{filterExclusions.length > 0 && (
|
||||
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
|
||||
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
|
||||
<ol className="mt-1.5 space-y-1.5">
|
||||
{filterExclusions.map((exclusion) => (
|
||||
<li
|
||||
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
||||
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
||||
>
|
||||
<div className="truncate font-medium">
|
||||
{getExclusionLabel(exclusion)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
||||
{getExclusionAdjustment(exclusion)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||
{t('areaPane.statsFor', {
|
||||
type: isPostcode
|
||||
? t('common.postcode').toLowerCase()
|
||||
: t('common.area').toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('common.propertiesPlural')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
||||
{t('areaPane.statsBasis')}
|
||||
</span>
|
||||
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filtersActive}
|
||||
aria-pressed={statsUseFilters && filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
statsUseFilters && filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.matchingFiltersOption')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!statsUseFilters || !filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
!statsUseFilters || !filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.allPropertiesOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||
{filtersActive
|
||||
? statsUseFilters
|
||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||
: t('areaPane.filtersIgnoredForStats')
|
||||
: t('areaPane.noFiltersAffectStats')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showFlipToggleCallout && (
|
||||
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
|
||||
<p className="mt-1">
|
||||
{unfilteredCount != null
|
||||
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
|
||||
: t('areaPane.showAllStatsFallback')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||
>
|
||||
{t('areaPane.showAllStats')}
|
||||
</button>
|
||||
{filterExclusions.length > 0 && (
|
||||
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
|
||||
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
|
||||
<ol className="mt-1.5 space-y-1.5">
|
||||
{filterExclusions.map((exclusion) => (
|
||||
<li
|
||||
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
||||
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
||||
>
|
||||
<div className="truncate font-medium">{getExclusionLabel(exclusion)}</div>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
||||
{getExclusionAdjustment(exclusion)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{hexagonLocation && stats && (
|
||||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||
)}
|
||||
{(() => {
|
||||
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
|
||||
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
|
||||
<JourneyInstructions
|
||||
postcode={journeyPostcode}
|
||||
entries={travelTimeEntries}
|
||||
label={!isPostcode ? journeyPostcode : undefined}
|
||||
shareCode={shareCode}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
{stats.count > 0 && <HistogramLegend />}
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
if (!hasData) return null;
|
||||
|
||||
{hexagonLocation && stats && (
|
||||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||
)}
|
||||
{(() => {
|
||||
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
|
||||
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
|
||||
<JourneyInstructions
|
||||
postcode={journeyPostcode}
|
||||
entries={travelTimeEntries}
|
||||
label={!isPostcode ? journeyPostcode : undefined}
|
||||
shareCode={shareCode}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
{stats.count > 0 && <HistogramLegend />}
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
if (!hasData) return null;
|
||||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
||||
|
||||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
||||
const stackedEnumFeatureNames = new Set<string>(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
||||
) ?? []
|
||||
);
|
||||
|
||||
const stackedEnumFeatureNames = new Set<string>(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
||||
) ?? []
|
||||
);
|
||||
const expanded = isGroupExpanded(group.name);
|
||||
|
||||
const expanded = isGroupExpanded(group.name);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={expanded}
|
||||
onToggle={() => onToggleGroup(group.name)}
|
||||
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{expanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{stackedCharts?.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
||||
const displaySegments = isPercentageComposition
|
||||
? normalizePercentageSegments(segments)
|
||||
: segments;
|
||||
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
// Use rateFeature (e.g. per-1k) for display if available
|
||||
const rateStats = chart.rateFeature
|
||||
? numericByName.get(chart.rateFeature)
|
||||
: undefined;
|
||||
const displayValue = isPercentageComposition
|
||||
? 100
|
||||
: rateStats
|
||||
? rateStats.mean
|
||||
: total;
|
||||
|
||||
// Use rateFeature for info popup and national average when available
|
||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||
const featureMeta = infoFeatureName
|
||||
? globalFeatureByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
|
||||
const globalMean = featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(displayValue)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
{globalMean != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={displaySegments}
|
||||
total={total}
|
||||
colorMap={
|
||||
chart.label === 'Political vote share'
|
||||
? PARTY_FEATURE_COLORS
|
||||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
const stackedFeatureNames = new Set<string>(
|
||||
stackedCharts?.flatMap((c) =>
|
||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
||||
Boolean(s)
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
return group.features
|
||||
.filter(
|
||||
(f) =>
|
||||
!stackedFeatureNames.has(f.name) &&
|
||||
!stackedEnumFeatureNames.has(f.name)
|
||||
)
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
meanLabel={t('areaPane.nationalAvg')}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
})()}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={expanded}
|
||||
onToggle={() => onToggleGroup(group.name)}
|
||||
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{expanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{stackedCharts?.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
||||
const displaySegments = isPercentageComposition
|
||||
? normalizePercentageSegments(segments)
|
||||
: segments;
|
||||
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
// Use rateFeature (e.g. per-1k) for display if available
|
||||
const rateStats = chart.rateFeature
|
||||
? numericByName.get(chart.rateFeature)
|
||||
: undefined;
|
||||
const displayValue = isPercentageComposition
|
||||
? 100
|
||||
: rateStats
|
||||
? rateStats.mean
|
||||
: total;
|
||||
|
||||
// Use rateFeature for info popup and national average when available
|
||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||
const featureMeta = infoFeatureName
|
||||
? globalFeatureByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
|
||||
const globalMean = featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -538,7 +386,7 @@ export default function AreaPane({
|
|||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={featureMeta}
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
|
|
@ -547,62 +395,216 @@ export default function AreaPane({
|
|||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(displayValue)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
{globalMean != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
segments={displaySegments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
colorMap={
|
||||
chart.label === 'Political vote share'
|
||||
? PARTY_FEATURE_COLORS
|
||||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{(() => {
|
||||
const stackedFeatureNames = new Set<string>(
|
||||
stackedCharts?.flatMap((c) =>
|
||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
||||
Boolean(s)
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
return group.features
|
||||
.filter(
|
||||
(f) =>
|
||||
!stackedFeatureNames.has(f.name) &&
|
||||
!stackedEnumFeatureNames.has(f.name)
|
||||
)
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
if (components.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
meanLabel={t('areaPane.nationalAvg')}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
if (enumStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
})()}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.filter((s) => s.value > 0);
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={featureMeta}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (components.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -86,10 +86,7 @@ export default function FeatureBrowser({
|
|||
|
||||
const showTravelModes =
|
||||
visibleModes.length > 0 &&
|
||||
(!search ||
|
||||
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
|
||||
search.toLowerCase()
|
||||
));
|
||||
(!search || t('filters.travelTimeKeywords').toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
// Keep "Transport" first because journey and transport proximity controls belong together.
|
||||
const mergedGrouped = useMemo(() => {
|
||||
|
|
@ -123,7 +120,7 @@ export default function FeatureBrowser({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length +
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({
|
|||
if (key === 'areaPane.to') return `To ${values?.destination}`;
|
||||
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
|
||||
if (key === 'common.min') return 'min';
|
||||
if (key === 'common.minute') return 'min';
|
||||
if (key === 'common.loading') return 'Loading';
|
||||
if (key === 'travel.bestCase') return 'Best case';
|
||||
if (key === 'areaPane.walk') return 'Walk';
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
)}
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
|
||||
{t('common.min')}
|
||||
{t('common.minute')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -191,7 +191,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<RouteBadge mode={leg.mode} />
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{leg.minutes} {t('common.min')}
|
||||
{leg.minutes} {t('common.minute')}
|
||||
</span>
|
||||
</div>
|
||||
{leg.from && leg.to && (
|
||||
|
|
@ -333,7 +333,7 @@ export default function JourneyInstructions({
|
|||
{!j.loading && totalMin > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
|
||||
{totalMin} {t('common.min')}
|
||||
{totalMin} {t('common.minute')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -381,7 +381,7 @@ export default function JourneyInstructions({
|
|||
)}
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
|
||||
{t('common.min')}
|
||||
{t('common.minute')}
|
||||
</span>
|
||||
</div>
|
||||
{showGoogleMapsLink && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
|
|
@ -85,10 +86,10 @@ function formatListingPrice(price: number): string {
|
|||
return `£${price.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatListingHeadline(listing: ActualListing): string | null {
|
||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||
const parts: string[] = [];
|
||||
if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
|
||||
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
|
||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
|
||||
if (listing.property_sub_type) parts.push(listing.property_sub_type);
|
||||
else if (listing.property_type) parts.push(listing.property_type);
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
|
|
@ -730,9 +731,9 @@ export default memo(function Map({
|
|||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{formatListingHeadline(listingPopup.listing) && (
|
||||
{formatListingHeadline(listingPopup.listing, t) && (
|
||||
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
||||
{formatListingHeadline(listingPopup.listing)}
|
||||
{formatListingHeadline(listingPopup.listing, t)}
|
||||
</div>
|
||||
)}
|
||||
{listingPopup.listing.address && (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { SearchedLocation } from './LocationSearch';
|
|||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
import { useActualListings } from '../../hooks/useActualListings';
|
||||
import { buildTravelParam } from '../../lib/travel-params';
|
||||
import { useFilters } from '../../hooks/useFilters';
|
||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
|
|
@ -15,7 +16,7 @@ import { useUrlSync } from '../../hooks/useUrlSync';
|
|||
import { useTutorial } from '../../hooks/useTutorial';
|
||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders } from '../../lib/api';
|
||||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||
|
|
@ -408,10 +409,15 @@ export default function MapPage({
|
|||
}, []);
|
||||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
const { listings: actualListings } = useActualListings(
|
||||
mapData.bounds,
|
||||
mapData.currentView?.zoom ?? 0
|
||||
const actualListingsFilterParam = useMemo(
|
||||
() => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
);
|
||||
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||||
const { listings: actualListings } = useActualListings(mapData.bounds, {
|
||||
filterParam: actualListingsFilterParam,
|
||||
travelParam: actualListingsTravelParam,
|
||||
});
|
||||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||
|
||||
useUrlSync(
|
||||
|
|
@ -464,11 +470,7 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
areaStats
|
||||
);
|
||||
const tutorial = useTutorial(
|
||||
initialLoading,
|
||||
isMobile,
|
||||
deferTutorial || mapData.licenseRequired
|
||||
);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||
|
|
@ -499,15 +501,7 @@ export default function MapPage({
|
|||
entries,
|
||||
shareCode
|
||||
).toString(),
|
||||
[
|
||||
entries,
|
||||
features,
|
||||
filters,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
|
||||
);
|
||||
const handleSaveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
|
|
@ -652,11 +646,7 @@ export default function MapPage({
|
|||
};
|
||||
|
||||
const exportToast = (
|
||||
<ExportToast
|
||||
notice={exportNotice}
|
||||
closeLabel={t('common.close')}
|
||||
onClose={clearExportNotice}
|
||||
/>
|
||||
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
|
||||
);
|
||||
const toasts = exportToast;
|
||||
|
||||
|
|
@ -671,9 +661,7 @@ export default function MapPage({
|
|||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{
|
||||
strong: (
|
||||
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
|
||||
),
|
||||
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -61,64 +61,67 @@ export function PropertiesPane({
|
|||
<div className="relative flex h-full flex-col">
|
||||
<IndeterminateProgressBar show={loading && properties.length > 0} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title={t('propertyCard.propertyData')}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{t('propertyCard.propertyDataDesc')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{loading && properties.length === 0 ? (
|
||||
<PropertyLoadingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((property, idx) => (
|
||||
<PropertyCard key={idx} property={property} />
|
||||
))}
|
||||
{properties.length < total && (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title={t('propertyCard.propertyData')}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{t('propertyCard.propertyDataDesc')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{loading && properties.length === 0 ? (
|
||||
<PropertyLoadingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((property) => (
|
||||
<PropertyCard
|
||||
key={`${property.lat},${property.lon}|${property.postcode ?? ''}|${property.address ?? ''}`}
|
||||
property={property}
|
||||
/>
|
||||
))}
|
||||
{properties.length < total && (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function TravelTimeCard({
|
|||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||
|
|
@ -158,10 +158,10 @@ export function TravelTimeCard({
|
|||
/>
|
||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute left-0">
|
||||
{formatFilterValue(displayRange[0])} {t('common.min')}
|
||||
{formatFilterValue(displayRange[0])} {t('common.minute')}
|
||||
</span>
|
||||
<span className="absolute right-0">
|
||||
{formatFilterValue(displayRange[1])} {t('common.min')}
|
||||
{formatFilterValue(displayRange[1])} {t('common.minute')}
|
||||
</span>
|
||||
</div>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export function ActiveFilterList({
|
|||
name={group.name}
|
||||
expanded={expanded}
|
||||
onToggle={() => onToggleGroup(group.name)}
|
||||
className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, type FormEvent } from 'react';
|
||||
import { useEffect, useRef, type FormEvent } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
|
||||
|
|
@ -30,6 +30,8 @@ export function ClearFiltersDialog({
|
|||
}: ClearFiltersDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!editingSearchName && !!onUpdateAndClear;
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -40,17 +42,41 @@ export function ClearFiltersDialog({
|
|||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
||||
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
|
||||
'input, button, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
(firstFocusable ?? dialogRef.current)?.focus();
|
||||
return () => {
|
||||
previouslyFocusedRef.current?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="clear-filters-dialog-title"
|
||||
tabIndex={-1}
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
<h2
|
||||
id="clear-filters-dialog-title"
|
||||
className="text-lg font-semibold text-navy-950 dark:text-white"
|
||||
>
|
||||
{t('filters.clearAllTitle')}
|
||||
</h2>
|
||||
<button
|
||||
|
|
@ -67,9 +93,7 @@ export function ClearFiltersDialog({
|
|||
i18nKey="filters.clearAllUpdatePrompt"
|
||||
values={{ name: editingSearchName }}
|
||||
components={{
|
||||
strong: (
|
||||
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
|
||||
),
|
||||
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
|
|
@ -34,11 +35,20 @@ export default function AuthModal({
|
|||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const dialogRef = useModalA11y();
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('Auth Modal Open', { tab: initialTab });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const switchView = useCallback(
|
||||
(newView: View) => {
|
||||
setView(newView);
|
||||
|
|
@ -97,14 +107,26 @@ export default function AuthModal({
|
|||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700">
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
|
||||
<h2 id="auth-modal-title" className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function FeatureLabel({
|
|||
}: FeatureLabelProps) {
|
||||
const { t } = useTranslation();
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const gapClass = size === 'sm' ? 'gap-2' : 'gap-1';
|
||||
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
|
||||
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||
|
|
@ -56,7 +57,7 @@ export function FeatureLabel({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} ${gapClass} min-w-0 ${className}`}
|
||||
>
|
||||
{featureIcon}
|
||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ interface IndeterminateProgressBarProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function IndeterminateProgressBar({
|
||||
show,
|
||||
className = '',
|
||||
}: IndeterminateProgressBarProps) {
|
||||
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useCallback, type ReactNode } from 'react';
|
||||
import { useRef, useCallback, useEffect, useId, type ReactNode } from 'react';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { CloseIcon } from './icons';
|
||||
import { IconButton } from './IconButton';
|
||||
|
|
@ -12,6 +12,8 @@ interface InfoPopupProps {
|
|||
|
||||
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
|
|
@ -19,14 +21,42 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
|||
|
||||
useClickOutside(popupRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
||||
const firstFocusable = popupRef.current?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
(firstFocusable ?? popupRef.current)?.focus();
|
||||
return () => {
|
||||
previouslyFocusedRef.current?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5 outline-none"
|
||||
>
|
||||
<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>
|
||||
<h3 id={titleId} className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
|
||||
{title}
|
||||
</h3>
|
||||
<IconButton onClick={onClose} className="shrink-0">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
|
||||
interface LicenseSuccessModalProps {
|
||||
onClose: () => void;
|
||||
|
|
@ -14,6 +15,7 @@ export default function LicenseSuccessModal({
|
|||
const { t } = useTranslation();
|
||||
const isSuccess = status === 'success';
|
||||
const isVerifying = status === 'verifying';
|
||||
const dialogRef = useModalA11y();
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
|
|
@ -36,6 +38,14 @@ export default function LicenseSuccessModal({
|
|||
return () => clearTimeout(timer);
|
||||
}, [isSuccess, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const title =
|
||||
status === 'verifying'
|
||||
? t('licenseSuccess.verifyingTitle')
|
||||
|
|
@ -56,9 +66,12 @@ export default function LicenseSuccessModal({
|
|||
: t('licenseSuccess.description');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
|
||||
role="presentation"
|
||||
>
|
||||
{isSuccess && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
|
|
@ -78,7 +91,14 @@ export default function LicenseSuccessModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="license-success-modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden outline-none"
|
||||
>
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||
<div className="h-14 mb-3 flex items-center justify-center">
|
||||
{isVerifying ? (
|
||||
|
|
@ -87,7 +107,9 @@ export default function LicenseSuccessModal({
|
|||
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
||||
<h2 id="license-success-modal-title" className="text-2xl font-bold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
|
||||
export default function SaveSearchModal({
|
||||
onClose,
|
||||
|
|
@ -20,6 +21,7 @@ export default function SaveSearchModal({
|
|||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const dialogRef = useModalA11y();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
|
|
@ -44,18 +46,32 @@ export default function SaveSearchModal({
|
|||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="save-search-modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
<h2
|
||||
id="save-search-modal-title"
|
||||
className="text-lg font-semibold text-navy-950 dark:text-white"
|
||||
>
|
||||
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
|
||||
interface UpgradeModalProps {
|
||||
isLoggedIn: boolean;
|
||||
|
|
@ -28,6 +29,7 @@ export default function UpgradeModal({
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pricePence, setPricePence] = useState<number | null>(null);
|
||||
const dialogRef = useModalA11y();
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
|
|
@ -38,6 +40,14 @@ export default function UpgradeModal({
|
|||
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onZoomToFreeZone();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onZoomToFreeZone]);
|
||||
|
||||
const priceLabel =
|
||||
pricePence === null
|
||||
? '...'
|
||||
|
|
@ -59,11 +69,23 @@ export default function UpgradeModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upgrade-modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomToFreeZone}
|
||||
aria-label={t('common.close')}
|
||||
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
|
|
@ -71,7 +93,9 @@ export default function UpgradeModal({
|
|||
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
|
||||
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
|
||||
{t('upgrade.title')}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-sm">
|
||||
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,16 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
|||
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export function useActualListings(bounds: Bounds | null) {
|
||||
interface UseActualListingsOptions {
|
||||
filterParam?: string;
|
||||
travelParam?: string;
|
||||
}
|
||||
|
||||
export function useActualListings(
|
||||
bounds: Bounds | null,
|
||||
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
|
||||
) {
|
||||
const [listings, setListings] = useState<ActualListing[]>([]);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
|
@ -18,7 +25,6 @@ export function useActualListings(bounds: Bounds | null) {
|
|||
if (!bounds) {
|
||||
abortControllerRef.current?.abort();
|
||||
if (listings.length !== 0) setListings([]);
|
||||
if (truncated) setTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +36,8 @@ export function useActualListings(bounds: Bounds | null) {
|
|||
try {
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filterParam) params.set('filters', filterParam);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
const res = await fetch(
|
||||
apiUrl('actual-listings', params),
|
||||
authHeaders({ signal: abortControllerRef.current.signal })
|
||||
|
|
@ -38,7 +46,6 @@ export function useActualListings(bounds: Bounds | null) {
|
|||
const json: ActualListingsResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setListings(json.listings || []);
|
||||
setTruncated(Boolean(json.truncated));
|
||||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch actual listings', err);
|
||||
}
|
||||
|
|
@ -48,9 +55,9 @@ export function useActualListings(bounds: Bounds | null) {
|
|||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
// listings/truncated intentionally excluded — they're internal state, not inputs.
|
||||
// listings intentionally excluded — it's internal state, not an input.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bounds]);
|
||||
}, [bounds, filterParam, travelParam]);
|
||||
|
||||
return { listings, truncated };
|
||||
return { listings };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ export function useDeckLayers({
|
|||
isDark,
|
||||
hexagonData: data,
|
||||
postcodeData,
|
||||
resolution: usePostcodeView ? 0 : Math.round(zoom),
|
||||
usePostcodeView,
|
||||
});
|
||||
|
||||
|
|
@ -280,21 +279,33 @@ export function useDeckLayers({
|
|||
const isEnum = enumCountRef.current > 0;
|
||||
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
|
||||
|
||||
// Per-render memo: each of getRatios0/1/2 would otherwise call distToRatios
|
||||
// on the same row, tripling the work. Cache by row reference.
|
||||
const ratiosCache = new WeakMap<HexagonData, number[]>();
|
||||
const getRatios = (d: HexagonData): number[] => {
|
||||
let r = ratiosCache.get(d);
|
||||
if (!r) {
|
||||
r = distToRatios(d[distKey]);
|
||||
ratiosCache.set(d, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pieProps: any = isEnum
|
||||
? {
|
||||
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
|
||||
getCenter: (d: HexagonData) => [d.lon, d.lat],
|
||||
getRatios0: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
const r = getRatios(d);
|
||||
return [r[0], r[1], r[2], r[3]];
|
||||
},
|
||||
getRatios1: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
const r = getRatios(d);
|
||||
return [r[4], r[5], r[6], r[7]];
|
||||
},
|
||||
getRatios2: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
const r = getRatios(d);
|
||||
return [r[8], r[9]];
|
||||
},
|
||||
updateTriggers: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
|
||||
import {
|
||||
apiUrl,
|
||||
buildFilterString,
|
||||
logNonAbortError,
|
||||
authHeaders,
|
||||
isAbortError,
|
||||
} from '../lib/api';
|
||||
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam } from '../lib/travel-params';
|
||||
|
||||
|
|
|
|||
|
|
@ -45,31 +45,46 @@ export function useListingLayers({
|
|||
}: UseListingLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
||||
|
||||
const visibleListings = useMemo(() => {
|
||||
if (listings.length === 0) return listings;
|
||||
if (usePostcodeView) {
|
||||
const allowed = new Set<string>();
|
||||
for (const feature of postcodeData) {
|
||||
if (feature.properties.count > 0) {
|
||||
allowed.add(normalizePostcode(feature.properties.postcode));
|
||||
}
|
||||
}
|
||||
if (allowed.size === 0) return [];
|
||||
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
|
||||
}
|
||||
// Split into two memos so the inactive view's data changes don't invalidate
|
||||
// the active filtered list. (e.g. in postcode view, hexagonData updates must
|
||||
// not retrigger filtering / downstream layer rebuilds.)
|
||||
const postcodeFilteredListings = useMemo(() => {
|
||||
if (!usePostcodeView || listings.length === 0) return null;
|
||||
const allowed = new Set<string>();
|
||||
for (const cell of hexagonData) {
|
||||
if (cell.count > 0) allowed.add(cell.h3);
|
||||
for (const feature of postcodeData) {
|
||||
if (feature.properties.count > 0) {
|
||||
allowed.add(normalizePostcode(feature.properties.postcode));
|
||||
}
|
||||
}
|
||||
if (allowed.size === 0) return [];
|
||||
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
|
||||
}, [listings, postcodeData, usePostcodeView]);
|
||||
|
||||
const hexFilteredListings = useMemo(() => {
|
||||
if (usePostcodeView || listings.length === 0) return null;
|
||||
const allowed = new Set<string>();
|
||||
let cellResolution: number | null = null;
|
||||
for (const cell of hexagonData) {
|
||||
if (cell.count > 0) {
|
||||
allowed.add(cell.h3);
|
||||
if (cellResolution == null) cellResolution = getResolution(cell.h3);
|
||||
}
|
||||
}
|
||||
if (allowed.size === 0 || cellResolution == null) return [];
|
||||
const resolutionForLookup = cellResolution;
|
||||
return listings.filter((listing) => {
|
||||
try {
|
||||
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
|
||||
return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
|
||||
}, [listings, hexagonData, usePostcodeView]);
|
||||
|
||||
const visibleListings = useMemo(() => {
|
||||
if (listings.length === 0) return listings;
|
||||
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
|
||||
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
|
||||
|
||||
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/
|
|||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
/** Return the p-th percentile (0–100) from a sorted typed array via linear interpolation. */
|
||||
function percentile(sorted: Float64Array, p: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const idx = (p / 100) * (sorted.length - 1);
|
||||
|
|
@ -262,10 +262,20 @@ export function useMapData({
|
|||
useEffect(() => {
|
||||
if (!activeFeature || !activeDragRequest) return;
|
||||
|
||||
// Abort any in-flight previous drag fetch before starting a new one.
|
||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||
dragAbortRef.current = new AbortController();
|
||||
|
||||
// Capture the controller locally so this effect's cleanup unambiguously
|
||||
// aborts THIS request's controller, even if `dragAbortRef.current` has
|
||||
// been swapped by a subsequent effect run.
|
||||
const controller = new AbortController();
|
||||
dragAbortRef.current = controller;
|
||||
const { signal } = controller;
|
||||
|
||||
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
|
||||
// Capture activeFeature in a local so the async .then() callback cannot
|
||||
// observe a stale-or-newer value via closure surprise.
|
||||
const effectActiveFeature = activeFeature;
|
||||
latestDragRequestKeyRef.current = requestKey;
|
||||
setDragDataKey('');
|
||||
dragFeatureRef.current = null;
|
||||
|
|
@ -278,14 +288,15 @@ export function useMapData({
|
|||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
fetch(apiUrl('postcodes', params), authHeaders({ signal }))
|
||||
.then((res) => res.json())
|
||||
.then((json: { features: PostcodeFeature[] }) => {
|
||||
if (signal.aborted) return;
|
||||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragPostcodeData(json.features);
|
||||
setDragHexData(null);
|
||||
setDragDataKey(requestKey);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
dragFeatureRef.current = effectActiveFeature;
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
||||
} else {
|
||||
|
|
@ -299,31 +310,36 @@ export function useMapData({
|
|||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal }))
|
||||
.then((res) => res.json())
|
||||
.then((json: ApiResponse) => {
|
||||
if (signal.aborted) return;
|
||||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragHexData(json.features);
|
||||
setDragPostcodeData(null);
|
||||
setDragDataKey(requestKey);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
dragFeatureRef.current = effectActiveFeature;
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dragAbortRef.current) {
|
||||
dragAbortRef.current.abort();
|
||||
// Abort the controller captured by THIS effect run rather than reading
|
||||
// from the ref (which may already have been replaced by a newer run).
|
||||
controller.abort();
|
||||
if (dragAbortRef.current === controller) {
|
||||
dragAbortRef.current = null;
|
||||
}
|
||||
if (latestDragRequestKeyRef.current === requestKey) {
|
||||
latestDragRequestKeyRef.current = '';
|
||||
}
|
||||
// Do not clear latestDragRequestKeyRef here: a newer effect run will
|
||||
// overwrite it with its own requestKey, and clearing it would create a
|
||||
// brief window in which a late-resolving fetch from this run could pass
|
||||
// the staleness check against an empty key.
|
||||
};
|
||||
}, [
|
||||
activeFeature,
|
||||
activeDragRequest,
|
||||
dataViewFeature,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
viewFeatureIsEnum,
|
||||
shareCode,
|
||||
|
|
@ -538,10 +554,14 @@ export function useMapData({
|
|||
}
|
||||
|
||||
if (vals.length === 0) return null;
|
||||
vals.sort((a, b) => a - b);
|
||||
// Typed-array sort uses the engine's optimized numeric sort with no
|
||||
// per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
|
||||
// for the 5k–10k samples a busy viewport produces.
|
||||
const sorted = Float64Array.from(vals);
|
||||
sorted.sort();
|
||||
return [
|
||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
];
|
||||
}, [
|
||||
bounds,
|
||||
|
|
|
|||
64
frontend/src/hooks/useModalA11y.ts
Normal file
64
frontend/src/hooks/useModalA11y.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Shared modal accessibility behavior: locks body scroll, traps Tab focus
|
||||
* inside the dialog, restores focus on unmount, and focuses the first
|
||||
* focusable element (or the dialog itself) on mount.
|
||||
*/
|
||||
export function useModalA11y(): React.RefObject<HTMLDivElement | null> {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const dialog = dialogRef.current;
|
||||
const focusableSelector =
|
||||
'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
|
||||
(firstFocusable ?? dialog)?.focus();
|
||||
|
||||
// Lock body scroll while preserving scroll position.
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
const prevPaddingRight = document.body.style.paddingRight;
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (scrollbarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !dialog) return;
|
||||
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement
|
||||
);
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !dialog.contains(active)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.body.style.paddingRight = prevPaddingRight;
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dialogRef;
|
||||
}
|
||||
|
|
@ -12,8 +12,16 @@ export interface SavedSearch {
|
|||
created: string;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ATTEMPTS = 15;
|
||||
// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
|
||||
// Caps total wait under a minute while staying responsive for fast jobs.
|
||||
const POLL_BASE_MS = 2000;
|
||||
const POLL_MAX_MS = 15000;
|
||||
const POLL_BACKOFF = 1.5;
|
||||
const MAX_POLL_ATTEMPTS = 8;
|
||||
|
||||
function nextPollDelay(attempt: number): number {
|
||||
return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
|
||||
}
|
||||
|
||||
export function useSavedSearches(userId: string | null) {
|
||||
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
||||
|
|
@ -21,14 +29,16 @@ export function useSavedSearches(userId: string | null) {
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pollAttemptsRef = useRef(0);
|
||||
const pollInFlightRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const userIdRef = useRef(userId);
|
||||
userIdRef.current = userId;
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
pollAttemptsRef.current = 0;
|
||||
|
|
@ -37,6 +47,15 @@ export function useSavedSearches(userId: string | null) {
|
|||
// Clean up polling on unmount or userId change
|
||||
useEffect(() => stopPolling, [userId, stopPolling]);
|
||||
|
||||
// Mark the hook as unmounted so late-arriving async work doesn't touch state
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
|
||||
const records = await pb.collection('saved_searches').getFullList({
|
||||
sort: '-created',
|
||||
|
|
@ -57,28 +76,41 @@ export function useSavedSearches(userId: string | null) {
|
|||
const startPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) return;
|
||||
pollAttemptsRef.current = 0;
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
pollInFlightRef.current = false;
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (!isMountedRef.current) return;
|
||||
const delay = nextPollDelay(pollAttemptsRef.current);
|
||||
pollTimerRef.current = setTimeout(tick, delay);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
pollTimerRef.current = null;
|
||||
if (pollInFlightRef.current) {
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
const uid = userIdRef.current;
|
||||
if (!uid) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (!uid) return;
|
||||
pollAttemptsRef.current++;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
|
||||
pollInFlightRef.current = true;
|
||||
try {
|
||||
const mapped = await fetchRecords(uid);
|
||||
if (!isMountedRef.current) return;
|
||||
setSearches(mapped);
|
||||
if (!mapped.some((s) => !s.screenshotUrl)) {
|
||||
stopPolling();
|
||||
}
|
||||
if (!mapped.some((s) => !s.screenshotUrl)) return;
|
||||
scheduleNext();
|
||||
} catch {
|
||||
// Silent — background poll errors don't surface to UI
|
||||
// Silent — background poll errors don't surface to UI; keep trying.
|
||||
if (isMountedRef.current) scheduleNext();
|
||||
} finally {
|
||||
pollInFlightRef.current = false;
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}, [stopPolling, fetchRecords]);
|
||||
};
|
||||
|
||||
scheduleNext();
|
||||
}, [fetchRecords]);
|
||||
|
||||
const fetchSearches = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
|
|
|||
|
|
@ -25,21 +25,28 @@ const de: Translations = {
|
|||
total: 'Gesamt',
|
||||
min: 'Min.',
|
||||
max: 'Max.',
|
||||
minute: 'Min.',
|
||||
or: 'oder',
|
||||
area: 'Gebiet',
|
||||
properties: 'Immobilien',
|
||||
postcode: 'Postleitzahl',
|
||||
noAreaSelected: 'Kein Gebiet ausgewählt',
|
||||
noAreaSelectedDesc:
|
||||
'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
|
||||
'Klicken Sie auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
|
||||
clickForDetails: 'Für Details klicken',
|
||||
property: 'Immobilie',
|
||||
propertiesPlural: 'Immobilien',
|
||||
bedsCount: '{{count}} Schlafzimmer',
|
||||
bedsCount_other: '{{count}} Schlafzimmer',
|
||||
bathsCount: '{{count}} Bad',
|
||||
bathsCount_other: '{{count}} Bäder',
|
||||
places: 'Orte',
|
||||
noData: 'Keine Daten',
|
||||
allLow: 'Alles niedrig',
|
||||
connectingToServer: 'Verbindung zum Server...',
|
||||
closePane: 'Bereich schließen',
|
||||
yes: 'Ja',
|
||||
no: 'Nein',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
|
|
@ -310,8 +317,7 @@ const de: Translations = {
|
|||
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
|
||||
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
|
||||
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
|
||||
'Does this show school catchment guarantees?':
|
||||
'Zeigt dies garantierte Schul-Einzugsgebiete?',
|
||||
'Does this show school catchment guarantees?': 'Zeigt dies garantierte Schul-Einzugsgebiete?',
|
||||
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
|
||||
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
|
||||
'Can I combine school filters with parks and safety?':
|
||||
|
|
@ -480,8 +486,7 @@ const de: Translations = {
|
|||
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
|
||||
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
|
||||
'Trust and coverage': 'Vertrauen und Abdeckung',
|
||||
'Perfect Postcode data sources and coverage':
|
||||
'Perfect Postcode – Datenquellen und Abdeckung',
|
||||
'Perfect Postcode data sources and coverage': 'Perfect Postcode – Datenquellen und Abdeckung',
|
||||
'Perfect Postcode data sources - Property, schools, commute and local context':
|
||||
'Perfect Postcode – Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
|
||||
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
|
||||
|
|
@ -501,8 +506,7 @@ const de: Translations = {
|
|||
'Travel-time data': 'Reisezeitdaten',
|
||||
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
|
||||
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
|
||||
'Why does coverage focus on England?':
|
||||
'Warum konzentriert sich die Abdeckung auf England?',
|
||||
'Why does coverage focus on England?': 'Warum konzentriert sich die Abdeckung auf England?',
|
||||
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
|
||||
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
|
||||
'How should I handle stale or missing data?':
|
||||
|
|
@ -588,15 +592,15 @@ const de: Translations = {
|
|||
createAccount: 'Konto erstellen',
|
||||
resetPassword: 'Passwort zurücksetzen',
|
||||
valueProp:
|
||||
'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
|
||||
'Speichern Sie Suchen, merken Sie sich Immobilien und erstellen Sie eine Auswahlliste passender Gebiete.',
|
||||
continueWithGoogle: 'Weiter mit Google',
|
||||
email: 'E-Mail',
|
||||
emailPlaceholder: 'du@beispiel.de',
|
||||
emailPlaceholder: 'name@beispiel.de',
|
||||
password: 'Passwort',
|
||||
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
||||
passwordPlaceholderLogin: 'Dein Passwort',
|
||||
passwordPlaceholderLogin: 'Ihr Passwort',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
|
||||
resetSent: 'Prüfen Sie Ihre E-Mails für einen Link zum Zurücksetzen.',
|
||||
pleaseWait: 'Bitte warten...',
|
||||
sendResetLink: 'Link zum Zurücksetzen senden',
|
||||
backToLogin: 'Zurück zur Anmeldung',
|
||||
|
|
@ -606,7 +610,7 @@ const de: Translations = {
|
|||
upgrade: {
|
||||
title: 'Jede passende Postleitzahl finden',
|
||||
description:
|
||||
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
|
||||
'Sie erkunden gerade das Demogebiet. Erhalten Sie lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
|
||||
free: 'Kostenlos',
|
||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
||||
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
||||
|
|
@ -618,7 +622,7 @@ const de: Translations = {
|
|||
continueWithDemo: 'Mit Demo fortfahren',
|
||||
backToSharedArea: 'Zurück zum geteilten Gebiet',
|
||||
sharedAreaDescription:
|
||||
'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichere dir lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
|
||||
'Sie sehen ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichern Sie sich lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
|
||||
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
||||
},
|
||||
|
||||
|
|
@ -626,7 +630,7 @@ const de: Translations = {
|
|||
saveSearch: {
|
||||
title: 'Suche speichern',
|
||||
saved: 'Suche gespeichert',
|
||||
savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
|
||||
savedSuccess: 'Ihre Suche wurde erfolgreich gespeichert.',
|
||||
viewSavedSearches: 'Gespeicherte Suchen ansehen',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Meine Suche',
|
||||
|
|
@ -636,15 +640,15 @@ const de: Translations = {
|
|||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
verifyingTitle: 'Zugang wird geprüft',
|
||||
verifyingSubtitle: 'Wir prüfen dein Konto, bevor wir die Karte freischalten.',
|
||||
verifyingSubtitle: 'Wir prüfen Ihr Konto, bevor wir die Karte freischalten.',
|
||||
verifyingDescription: 'Das dauert nach dem Bezahlen normalerweise nur ein paar Sekunden.',
|
||||
activationDelayedTitle: 'Zahlung erhalten',
|
||||
activationDelayedSubtitle: 'Der Zugang wird noch aktiviert.',
|
||||
activationDelayedDescription:
|
||||
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisiere gleich noch einmal oder kontaktiere den Support, falls der Zugang nicht erscheint.',
|
||||
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisieren Sie gleich noch einmal oder kontaktieren Sie den Support, falls der Zugang nicht erscheint.',
|
||||
stayOnPricing: 'Auf der Preisseite bleiben',
|
||||
title: 'Du bist dabei.',
|
||||
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
|
||||
title: 'Sie sind dabei.',
|
||||
subtitle: 'Ihr lebenslanger Zugang ist jetzt aktiv.',
|
||||
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
|
||||
startExploring: 'Jetzt entdecken',
|
||||
},
|
||||
|
|
@ -655,18 +659,18 @@ const de: Translations = {
|
|||
addFilter: 'Filter hinzufügen',
|
||||
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
|
||||
addFiltersHint:
|
||||
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
|
||||
'Fügen Sie unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die Ihren Kriterien entsprechen',
|
||||
upgradePrompt:
|
||||
'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
|
||||
'Finden Sie passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
|
||||
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
||||
upgradeToFullMap: 'Zur Vollversion upgraden',
|
||||
chooseFilters:
|
||||
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
|
||||
'Klicken Sie auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
|
||||
searchFeatures: 'Filter durchsuchen...',
|
||||
noMatchingFeatures: 'Keine passenden Filter',
|
||||
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
|
||||
tryDifferentSearch: 'Versuchen Sie einen anderen Suchbegriff',
|
||||
allFeaturesActive: 'Alle Filter sind aktiv',
|
||||
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
|
||||
removeFilterHint: 'Entfernen Sie einen Filter, um verfügbare Merkmale zu sehen',
|
||||
featureInfo: 'Über diese Daten',
|
||||
aboutData: 'Über diese Daten',
|
||||
aboutDataShort: 'Info',
|
||||
|
|
@ -679,7 +683,7 @@ const de: Translations = {
|
|||
replayTutorial: 'Interaktives Tutorial erneut abspielen',
|
||||
clearAll: 'Alle löschen',
|
||||
clearAllTitle: 'Alle Filter löschen?',
|
||||
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
|
||||
clearAllSavePrompt: 'Möchten Sie Ihre aktuellen Filter vor dem Löschen speichern?',
|
||||
clearAllUpdatePrompt:
|
||||
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
|
||||
saveAndClear: 'Speichern & löschen',
|
||||
|
|
@ -700,12 +704,14 @@ const de: Translations = {
|
|||
ethnicity: 'Ethnie',
|
||||
poiType: 'POI-Typ',
|
||||
party: 'Partei',
|
||||
travelTimeKeywords:
|
||||
'Reisezeit Fahrzeit Pendelzeit Pendeln Fahrt Reise Auto Fahrrad Rad Radfahren zu Fuß Gehen ÖPNV Verkehr Transport Bahnhof Bahn U-Bahn S-Bahn Zug Bus Straßenbahn öffentlich Route travel time journey commute car bicycle bike walking transit transport station train tube bus metro rail route',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
philosophy: {
|
||||
intro:
|
||||
'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
|
||||
'Beginnen Sie mit Ihren Muss-Kriterien, dann fügen Sie Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn Sie Filter hinzufügen. Die verbleibenden Gebiete sind Ihre besten Treffer.',
|
||||
step1Title: 'Budget und Grundlagen',
|
||||
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
|
||||
step2Title: 'Pendelweg',
|
||||
|
|
@ -718,7 +724,7 @@ const de: Translations = {
|
|||
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
|
||||
step6Title: 'Energie',
|
||||
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
|
||||
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
|
||||
tip: 'Tipp: Wenn nichts passt, lockern Sie eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
|
|
@ -755,14 +761,14 @@ const de: Translations = {
|
|||
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
|
||||
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
|
||||
mainDesc: 'Zeigt die Reisezeit vom ausgewählten Ziel zu jedem Gebiet.',
|
||||
sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
|
||||
sliderHint: 'Verwenden Sie den Schieberegler, um Ihre maximale Pendelzeit festzulegen.',
|
||||
},
|
||||
|
||||
// ── AI Filter ──────────────────────────────────────
|
||||
aiFilter: {
|
||||
describeIdealArea: 'Beschreibe, wo du leben möchtest',
|
||||
describeIdealArea: 'Beschreiben Sie, wo Sie leben möchten',
|
||||
aiSearch: 'KI-Suche',
|
||||
describeHint: 'beschreibe, wonach du suchst',
|
||||
describeHint: 'beschreiben Sie, wonach Sie suchen',
|
||||
placeholder: 'z. B. 2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhig...',
|
||||
example1: '2 Schlafzimmer unter £525k, 45 Min. zur Arbeit',
|
||||
example2: 'Familienfreundliche Gebiete nahe guten Schulen unter £650k',
|
||||
|
|
@ -772,7 +778,7 @@ const de: Translations = {
|
|||
generatingFilters: 'Filter werden generiert...',
|
||||
refiningResults: 'Ergebnisse werden verfeinert...',
|
||||
weeklyLimitReached:
|
||||
'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
||||
'Sie haben das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
|
|
@ -1268,7 +1274,7 @@ const de: Translations = {
|
|||
upgrade: 'Upgraden',
|
||||
redirecting: 'Weiterleitung…',
|
||||
receiveNewsletter: 'Newsletter-E-Mails erhalten',
|
||||
needHelp: 'Brauchst du Hilfe? Schreib uns an',
|
||||
needHelp: 'Brauchen Sie Hilfe? Schreiben Sie uns an',
|
||||
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
|
||||
shareLinksTitle: 'Geteilte Links',
|
||||
noShareLinksYet: 'Noch keine geteilten Links',
|
||||
|
|
@ -1281,12 +1287,12 @@ const de: Translations = {
|
|||
searches: 'Suchen',
|
||||
noSavedSearches: 'Noch keine gespeicherten Suchen',
|
||||
noSavedSearchesDesc:
|
||||
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
|
||||
'Speichern Sie Ihre Filter und Kartenansicht, um genau dort weiterzumachen, wo Sie aufgehört haben.',
|
||||
clickToRename: 'Klicken zum Umbenennen',
|
||||
notesPlaceholder: 'Notiere deine Gedanken...',
|
||||
notesPlaceholder: 'Notieren Sie Ihre Gedanken...',
|
||||
deleteSearch: 'Suche löschen',
|
||||
deleteSearchConfirm:
|
||||
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
'Möchten Sie diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
|
||||
updating: 'Aktualisiere...',
|
||||
},
|
||||
|
|
@ -1301,7 +1307,7 @@ const de: Translations = {
|
|||
copyInviteLink: 'Einladungslink kopieren',
|
||||
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
|
||||
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
|
||||
yourInviteLinks: 'Deine Einladungslinks',
|
||||
yourInviteLinks: 'Ihre Einladungslinks',
|
||||
noInvitesYet: 'Noch keine Einladungen erstellt',
|
||||
link: 'Link',
|
||||
status: 'Status',
|
||||
|
|
@ -1312,13 +1318,13 @@ const de: Translations = {
|
|||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: 'Du bist eingeladen!',
|
||||
youreInvited: 'Sie sind eingeladen!',
|
||||
specialOffer: 'Sonderangebot!',
|
||||
invitedByFree: '{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
|
||||
genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
|
||||
exploreEvery: 'Finde Postleitzahlen, die zu deinem Leben passen',
|
||||
invitedByFree: '{{name}} hat Sie eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
|
||||
genericFreeInvite: 'Sie wurden eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
|
||||
exploreEvery: 'Finden Sie Postleitzahlen, die zu Ihrem Leben passen',
|
||||
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
|
||||
invalidInvite: 'Ungültige Einladung',
|
||||
inviteAlreadyUsed: 'Einladung bereits verwendet',
|
||||
|
|
@ -1326,13 +1332,13 @@ const de: Translations = {
|
|||
invalidInviteLink: 'Ungültiger Einladungslink',
|
||||
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
|
||||
licenseActivated: 'Lizenz aktiviert!',
|
||||
fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
|
||||
fullAccessGranted: 'Sie haben jetzt vollen Zugang zu Perfect Postcode.',
|
||||
activating: 'Wird aktiviert...',
|
||||
activateLicense: 'Lizenz aktivieren',
|
||||
claimDiscount: 'Rabatt einlösen',
|
||||
registerToClaim: 'Registrieren zum Einlösen',
|
||||
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
|
||||
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
|
||||
youAlreadyHaveLicense: 'Sie haben bereits eine Lizenz',
|
||||
accountHasFullAccess: 'Ihr Konto hat bereits vollen Zugang.',
|
||||
failedToValidate: 'Einladungslink konnte nicht validiert werden',
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const en = {
|
|||
total: 'Total',
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
minute: 'min',
|
||||
or: 'or',
|
||||
area: 'Area',
|
||||
properties: 'Properties',
|
||||
|
|
@ -33,11 +34,17 @@ const en = {
|
|||
clickForDetails: 'Click for details',
|
||||
property: 'property',
|
||||
propertiesPlural: 'properties',
|
||||
bedsCount: '{{count}} bed',
|
||||
bedsCount_other: '{{count}} beds',
|
||||
bathsCount: '{{count}} bath',
|
||||
bathsCount_other: '{{count}} baths',
|
||||
places: 'places',
|
||||
noData: 'No data',
|
||||
allLow: 'All low',
|
||||
connectingToServer: 'Connecting to server...',
|
||||
closePane: 'Close pane',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
|
|
@ -653,7 +660,8 @@ const en = {
|
|||
clearAll: 'Clear all',
|
||||
clearAllTitle: 'Clear all filters?',
|
||||
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
|
||||
clearAllUpdatePrompt: 'Update <strong>{{name}}</strong> with your current filters before clearing?',
|
||||
clearAllUpdatePrompt:
|
||||
'Update <strong>{{name}}</strong> with your current filters before clearing?',
|
||||
saveAndClear: 'Save & Clear',
|
||||
updateAndClear: 'Update & Clear',
|
||||
clearWithoutSaving: 'Clear without saving',
|
||||
|
|
@ -672,6 +680,8 @@ const en = {
|
|||
ethnicity: 'Ethnicity',
|
||||
poiType: 'POI type',
|
||||
party: 'Party',
|
||||
travelTimeKeywords:
|
||||
'travel time journey commute car bicycle bike cycling walking walk transit transport public station tube train bus metro subway underground rail route',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const fr: Translations = {
|
|||
total: 'Total',
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
minute: 'min',
|
||||
or: 'ou',
|
||||
area: 'Zone',
|
||||
properties: 'Propriétés',
|
||||
|
|
@ -35,11 +36,17 @@ const fr: Translations = {
|
|||
clickForDetails: 'Cliquez pour les détails',
|
||||
property: 'propriété',
|
||||
propertiesPlural: 'propriétés',
|
||||
bedsCount: '{{count}} ch.',
|
||||
bedsCount_other: '{{count}} ch.',
|
||||
bathsCount: '{{count}} sdb',
|
||||
bathsCount_other: '{{count}} sdb',
|
||||
places: 'lieux',
|
||||
noData: 'Aucune donnée',
|
||||
allLow: 'Tout est faible',
|
||||
connectingToServer: 'Connexion au serveur...',
|
||||
closePane: 'Fermer le panneau',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
|
|
@ -318,7 +325,7 @@ const fr: Translations = {
|
|||
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
|
||||
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
|
||||
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
|
||||
"Aucun score isolé ne devrait décider d’un déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur l’école.",
|
||||
'Aucun score isolé ne devrait décider d’un déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur l’école.',
|
||||
'See where education, property, transport, and environment data comes from.':
|
||||
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
|
||||
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
|
||||
|
|
@ -337,7 +344,7 @@ const fr: Translations = {
|
|||
'Compare postcodes consistently across England.':
|
||||
'Comparez les codes postaux de manière cohérente dans toute l’Angleterre.',
|
||||
'Check the street before spending a viewing slot':
|
||||
"Vérifiez la rue avant d’y consacrer un créneau de visite",
|
||||
'Vérifiez la rue avant d’y consacrer un créneau de visite',
|
||||
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
|
||||
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
|
||||
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
|
||||
|
|
@ -702,6 +709,8 @@ const fr: Translations = {
|
|||
ethnicity: 'Origine ethnique',
|
||||
poiType: 'Type de POI',
|
||||
party: 'Parti',
|
||||
travelTimeKeywords:
|
||||
'temps trajet déplacement navette domicile-travail voiture vélo bicyclette cyclisme marche à pied piéton transports en commun public station gare train métro tramway bus RER itinéraire route travel time journey commute car bicycle bike walking transit transport station tube train',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
|
|
@ -843,8 +852,7 @@ const fr: Translations = {
|
|||
lowerMinTo: 'Abaisser le minimum à {{value}}',
|
||||
raiseMaxTo: 'Augmenter le maximum à {{value}}',
|
||||
allowCategory: 'Autoriser {{value}}',
|
||||
missingFilterValue:
|
||||
'Aucune valeur pour ce filtre ; supprimez-le',
|
||||
missingFilterValue: 'Aucune valeur pour ce filtre ; supprimez-le',
|
||||
noFilterDataShort: 'Aucune donnée',
|
||||
travelTo: 'Trajet vers {{destination}}',
|
||||
viewProperties: 'Voir {{count}} propriétés',
|
||||
|
|
@ -1297,7 +1305,8 @@ const fr: Translations = {
|
|||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed: 'Les liens d’invitation sont disponibles pour les utilisateurs sous licence.',
|
||||
inviteLinksLicensed:
|
||||
'Les liens d’invitation sont disponibles pour les utilisateurs sous licence.',
|
||||
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
||||
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
||||
generateFreeInvite: 'Générer un lien d’invitation gratuit',
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ const hi: Translations = {
|
|||
none: 'कोई नहीं',
|
||||
viewDataSource: 'डेटा स्रोत देखें',
|
||||
total: 'कुल',
|
||||
min: 'मिनट',
|
||||
min: 'न्यूनतम',
|
||||
max: 'अधिकतम',
|
||||
minute: 'मिनट',
|
||||
or: 'या',
|
||||
area: 'क्षेत्र',
|
||||
properties: 'संपत्तियां',
|
||||
|
|
@ -34,11 +35,17 @@ const hi: Translations = {
|
|||
clickForDetails: 'विवरण के लिए क्लिक करें',
|
||||
property: 'संपत्ति',
|
||||
propertiesPlural: 'संपत्तियां',
|
||||
bedsCount: '{{count}} बेड',
|
||||
bedsCount_other: '{{count}} बेड',
|
||||
bathsCount: '{{count}} बाथ',
|
||||
bathsCount_other: '{{count}} बाथ',
|
||||
places: 'स्थान',
|
||||
noData: 'कोई डेटा नहीं',
|
||||
allLow: 'सभी कम',
|
||||
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
|
||||
closePane: 'पैन बंद करें',
|
||||
yes: 'हाँ',
|
||||
no: 'नहीं',
|
||||
},
|
||||
|
||||
header: {
|
||||
|
|
@ -670,6 +677,8 @@ const hi: Translations = {
|
|||
ethnicity: 'जातीय समूह',
|
||||
poiType: 'POI प्रकार',
|
||||
party: 'पार्टी',
|
||||
travelTimeKeywords:
|
||||
'यात्रा यात्रा समय सफर आवागमन कार गाड़ी साइकिल बाइक पैदल चलना सार्वजनिक परिवहन परिवहन यातायात स्टेशन ट्रेन रेल मेट्रो ट्यूब बस मार्ग travel time journey commute car bicycle bike walking transit transport station tube train',
|
||||
},
|
||||
|
||||
philosophy: {
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ const hu: Translations = {
|
|||
none: 'Egyik sem',
|
||||
viewDataSource: 'Adatforrás megtekintése',
|
||||
total: 'Összesen',
|
||||
min: 'perc',
|
||||
min: 'min.',
|
||||
max: 'max.',
|
||||
minute: 'perc',
|
||||
or: 'vagy',
|
||||
area: 'Terület',
|
||||
properties: 'Ingatlanok',
|
||||
|
|
@ -35,11 +36,17 @@ const hu: Translations = {
|
|||
clickForDetails: 'Kattints a részletekhez',
|
||||
property: 'ingatlan',
|
||||
propertiesPlural: 'ingatlanok',
|
||||
bedsCount: '{{count}} hsz.',
|
||||
bedsCount_other: '{{count}} hsz.',
|
||||
bathsCount: '{{count}} fsz.',
|
||||
bathsCount_other: '{{count}} fsz.',
|
||||
places: 'helyek',
|
||||
noData: 'Nincs adat',
|
||||
allLow: 'Mind alacsony',
|
||||
connectingToServer: 'Kapcsolódás a szerverhez...',
|
||||
closePane: 'Panel bezárása',
|
||||
yes: 'Igen',
|
||||
no: 'Nem',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
|
|
@ -446,7 +453,8 @@ const hu: Translations = {
|
|||
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
|
||||
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
|
||||
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
|
||||
'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
|
||||
'Compare value, not just headline price':
|
||||
'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
|
||||
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
|
||||
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
|
||||
'Screen environmental and local-service signals':
|
||||
|
|
@ -686,6 +694,8 @@ const hu: Translations = {
|
|||
ethnicity: 'Etnikai csoport',
|
||||
poiType: 'POI-típus',
|
||||
party: 'Párt',
|
||||
travelTimeKeywords:
|
||||
'utazási idő utazás ingázás menetidő autó kocsi kerékpár bicikli biciklizés gyaloglás gyalog séta tömegközlekedés közlekedés közösségi közlekedés állomás vonat metró villamos busz HÉV útvonal travel time journey commute car bicycle bike walking transit transport station tube train',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,9 @@ function AppErrorFallback() {
|
|||
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">Refresh the page to try again.</p>
|
||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||||
Refresh the page to try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -74,13 +74,13 @@ export function initBugsink(): boolean {
|
|||
),
|
||||
release:
|
||||
nonempty(runtimeConfig.release) ??
|
||||
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
|
||||
readBuildTimeString(
|
||||
typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined
|
||||
),
|
||||
sendDefaultPii:
|
||||
runtimeConfig.sendDefaultPii ??
|
||||
readBuildTimeBoolean(
|
||||
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
|
||||
? __BUGSINK_SEND_DEFAULT_PII__
|
||||
: undefined
|
||||
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean' ? __BUGSINK_SEND_DEFAULT_PII__ : undefined
|
||||
),
|
||||
tracesSampleRate: 0,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -135,7 +135,6 @@ export interface ActualListing {
|
|||
export interface ActualListingsResponse {
|
||||
listings: ActualListing[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface POICategoryGroup {
|
||||
|
|
@ -198,7 +197,9 @@ export interface Property {
|
|||
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface HexagonPropertiesResponse {
|
||||
/** Shared paginated list of `Property` records returned by both
|
||||
* `/api/hexagon-properties` and `/api/postcode-properties`. */
|
||||
export interface PropertyListResponse {
|
||||
properties: Property[];
|
||||
total: number;
|
||||
limit: number;
|
||||
|
|
@ -206,6 +207,10 @@ export interface HexagonPropertiesResponse {
|
|||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated Use `PropertyListResponse`. Kept as an alias during the
|
||||
* rollout so consumers can migrate without breaking. */
|
||||
export type HexagonPropertiesResponse = PropertyListResponse;
|
||||
|
||||
export interface NumericFeatureStats {
|
||||
name: string;
|
||||
count: number;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const CompressionPlugin = require('compression-webpack-plugin');
|
||||
const zlib = require('zlib');
|
||||
const sharp = require('sharp');
|
||||
const webpack = require('webpack');
|
||||
const packageJson = require('./package.json');
|
||||
|
|
@ -150,11 +153,49 @@ module.exports = (env, argv) => {
|
|||
filename: '[name].[contenthash:8].css',
|
||||
chunkFilename: '[name].[contenthash:8].css',
|
||||
}),
|
||||
new CompressionPlugin({
|
||||
filename: '[path][base].gz',
|
||||
algorithm: 'gzip',
|
||||
test: /\.(js|css|html|svg|json|wasm)$/,
|
||||
threshold: 1024,
|
||||
minRatio: 0.8,
|
||||
}),
|
||||
new CompressionPlugin({
|
||||
filename: '[path][base].br',
|
||||
algorithm: 'brotliCompress',
|
||||
test: /\.(js|css|html|svg|json|wasm)$/,
|
||||
compressionOptions: {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
||||
},
|
||||
},
|
||||
threshold: 1024,
|
||||
minRatio: 0.8,
|
||||
}),
|
||||
]
|
||||
: [new ReactRefreshWebpackPlugin()]),
|
||||
],
|
||||
optimization: isProduction
|
||||
? {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
extractComments: false,
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
passes: 2,
|
||||
},
|
||||
format: {
|
||||
comments: false,
|
||||
},
|
||||
keep_classnames: true,
|
||||
keep_fnames: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue