lgtm
This commit is contained in:
parent
5e73287eaf
commit
e2b85fe819
73 changed files with 1180 additions and 2028 deletions
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 24 24" width="512" height="512"><defs><clipPath id="_dlr_clip"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_dlr_clip)"><circle vector-effect="non-scaling-stroke" cx="12" cy="12" r="9.71875" fill="rgb(0,164,167)"/><circle vector-effect="non-scaling-stroke" cx="12" cy="12" r="6.375" fill="rgb(255,255,255)"/><rect x="0.031" y="10.219" width="23.938" height="3.563" transform="matrix(1,0,0,1,0,0)" fill="rgb(0,164,167)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 628 B |
BIN
frontend/public/assets/twemoji/1f688.png
Normal file
BIN
frontend/public/assets/twemoji/1f688.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 760 B |
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from './lib/seoRoutes';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api';
|
||||
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||
import { trackEvent } from './lib/analytics';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import pb from './lib/pocketbase';
|
||||
|
|
@ -209,34 +209,6 @@ export default function App() {
|
|||
}, []);
|
||||
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||
// Demo location: a fresh visitor (no explicit URL view) is offered the "check
|
||||
// your area" prompt (in MapPage). A choice made earlier this session is reused —
|
||||
// GPS coords re-centre the map and are sent to the server (via lib/api), while
|
||||
// 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the
|
||||
// very first data request already carries the centre.
|
||||
// Read license synchronously from the persisted session so we never apply the
|
||||
// demo location for a returning licensed user (they bypass the free zone, and we
|
||||
// don't want their location in request URLs).
|
||||
const licensedAtMount = useMemo(
|
||||
() =>
|
||||
pb.authStore.isValid &&
|
||||
(pb.authStore.record?.subscription === 'licensed' || pb.authStore.record?.is_admin === true),
|
||||
[]
|
||||
);
|
||||
const initialDemoChoice = useMemo(() => {
|
||||
const choice = readDemoChoice();
|
||||
if (choice && choice !== 'declined' && !licensedAtMount) setDemoCenter(choice);
|
||||
return choice;
|
||||
}, [licensedAtMount]);
|
||||
const [demoView] = useState<typeof INITIAL_VIEW_STATE | null>(() =>
|
||||
initialDemoChoice && initialDemoChoice !== 'declined' && !licensedAtMount
|
||||
? {
|
||||
...INITIAL_VIEW_STATE,
|
||||
latitude: initialDemoChoice.lat,
|
||||
longitude: initialDemoChoice.lon,
|
||||
}
|
||||
: null
|
||||
);
|
||||
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
||||
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||
);
|
||||
|
|
@ -248,8 +220,8 @@ export default function App() {
|
|||
);
|
||||
const activePageRef = useRef<Page>('home');
|
||||
const initialViewState = useMemo(
|
||||
() => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)),
|
||||
[mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
|
||||
() => (mapUrlState.hasExplicitView ? mapUrlState.viewState : INITIAL_VIEW_STATE),
|
||||
[mapUrlState.hasExplicitView, mapUrlState.viewState]
|
||||
);
|
||||
|
||||
const isScreenshotMode = useMemo(() => {
|
||||
|
|
@ -302,16 +274,6 @@ export default function App() {
|
|||
refreshAuth,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
// Only offer the "check your area" demo prompt to non-licensed visitors on a
|
||||
// fresh visit. `user` is hydrated synchronously from the persisted PocketBase
|
||||
// session, so a returning premium user is recognised at mount (no flash).
|
||||
const offerDemoLocation =
|
||||
!urlState.hasExplicitView && initialDemoChoice == null && !hasFullAccess(user);
|
||||
// Stop transmitting the demo location once the user is licensed (in-session
|
||||
// upgrade, cross-tab sign-in, or a stale session that refreshed to licensed).
|
||||
useEffect(() => {
|
||||
if (hasFullAccess(user)) setDemoCenter(null);
|
||||
}, [user]);
|
||||
const { startCheckout: startPostAuthCheckout } = useLicense();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||
|
|
@ -351,8 +313,8 @@ export default function App() {
|
|||
if (!completed) {
|
||||
setPostAuthIntent(null);
|
||||
postAuthCheckoutReturnPathRef.current = null;
|
||||
// Only protected pages bounce home; the dashboard stays open in demo
|
||||
// mode (server-enforced free zone) when the modal is dismissed.
|
||||
// Only protected pages bounce home; the dashboard stays open when the
|
||||
// modal is dismissed.
|
||||
if (isProtectedPage(activePageRef.current)) {
|
||||
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||
setRouteHash('');
|
||||
|
|
@ -611,9 +573,8 @@ export default function App() {
|
|||
|
||||
const isProtectedPageActive = isProtectedPage(activePage);
|
||||
// Only protected pages (account/saved) prompt for login on entry. The
|
||||
// dashboard opens straight into demo mode (server-enforced free zone) so
|
||||
// visitors can try it without logging in; the upgrade modal still appears
|
||||
// when they pan outside the free zone.
|
||||
// dashboard opens straight away so visitors can try it without logging in;
|
||||
// the upgrade modal appears when they hit the free filter cap.
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (isProtectedPageActive && !user) {
|
||||
|
|
@ -768,7 +729,6 @@ export default function App() {
|
|||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={mapUrlState.filters}
|
||||
initialViewState={initialViewState}
|
||||
offerDemoLocation={offerDemoLocation}
|
||||
initialPOICategories={mapUrlState.poiCategories}
|
||||
initialOverlays={mapUrlState.overlays}
|
||||
initialCrimeTypes={mapUrlState.crimeTypes}
|
||||
|
|
|
|||
|
|
@ -383,7 +383,9 @@ export function SavedPage({
|
|||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setShareLinksError(err instanceof Error ? err.message : t('accountPage.fetchShareLinksError'));
|
||||
setShareLinksError(
|
||||
err instanceof Error ? err.message : t('accountPage.fetchShareLinksError')
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -931,7 +933,8 @@ export default function AccountPage({
|
|||
assertOk(res, 'Update newsletter');
|
||||
await onRefreshAuth();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('accountPage.updateNewsletterError');
|
||||
const msg =
|
||||
err instanceof Error ? err.message : t('accountPage.updateNewsletterError');
|
||||
setNewsletterError(msg);
|
||||
} finally {
|
||||
setNewsletterSaving(false);
|
||||
|
|
|
|||
|
|
@ -262,7 +262,12 @@ function SocialVideoCard({
|
|||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
>
|
||||
<track kind="captions" srcLang="en" label={t('common.captions')} src={`/video/${slug}.vtt`} />
|
||||
<track
|
||||
kind="captions"
|
||||
srcLang="en"
|
||||
label={t('common.captions')}
|
||||
src={`/video/${slug}.vtt`}
|
||||
/>
|
||||
</video>
|
||||
{!isPlaying && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||
|
|
|
|||
|
|
@ -41,14 +41,14 @@ export const TERMS: LegalDoc = {
|
|||
{
|
||||
heading: '2. Accounts',
|
||||
paragraphs: [
|
||||
'You need an account to use the service beyond the free demo area. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
|
||||
'You can browse the map without an account. To save searches or buy lifetime access you need an account. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
|
||||
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Free demo and lifetime access',
|
||||
heading: '3. Free access and lifetime access',
|
||||
paragraphs: [
|
||||
'Free accounts can explore all features within the demo area (inner London). Lifetime access is a one-time payment that gives your account ongoing access to the paid map — every postcode, every filter — for as long as the service runs. It is not a subscription, and routine data updates are included.',
|
||||
'Free users can explore the full map of England with every feature, applying up to three filters at a time. Lifetime access is a one-time payment that removes the filter limit and gives your account ongoing access — every postcode, every filter, unlimited — for as long as the service runs. It is not a subscription, and routine data updates are included.',
|
||||
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import type {
|
|||
} from '../../types';
|
||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations';
|
||||
import {
|
||||
formatStationDistance,
|
||||
stationDisplayName,
|
||||
type NearbyStation,
|
||||
} from '../../lib/nearby-stations';
|
||||
import {
|
||||
formatValue,
|
||||
formatFilterValue,
|
||||
|
|
@ -211,7 +215,7 @@ function NearbyStationRow({ station }: { station: NearbyStation }) {
|
|||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-warm-900 dark:text-warm-100">
|
||||
{station.name}
|
||||
{stationDisplayName(station.name)}
|
||||
</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">{ts(station.category)}</div>
|
||||
</div>
|
||||
|
|
@ -595,367 +599,370 @@ export default function AreaPane({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
{stackedCharts?.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
{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 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);
|
||||
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. a percentage) for display if available
|
||||
const rateStats = chart.rateFeature
|
||||
? numericByName.get(chart.rateFeature)
|
||||
: undefined;
|
||||
const displayValue = isPercentageComposition
|
||||
? 100
|
||||
: rateStats
|
||||
? rateStats.mean
|
||||
: total;
|
||||
// Use rateFeature (e.g. a percentage) 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;
|
||||
// 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;
|
||||
const crimeAreaAvg = infoFeatureName
|
||||
? crimeAreaAvgByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
// For crime, prefer the exact national mean so it shares
|
||||
// one estimator with the outcode/sector/selection values.
|
||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||
const globalMean = featureMeta?.histogram
|
||||
? calculateHistogramMean(featureMeta.histogram)
|
||||
: undefined;
|
||||
const crimeAreaAvg = infoFeatureName
|
||||
? crimeAreaAvgByName.get(infoFeatureName)
|
||||
: undefined;
|
||||
// For crime, prefer the exact national mean so it shares
|
||||
// one estimator with the outcode/sector/selection values.
|
||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||
|
||||
// Crime metrics get a number line comparing this area to
|
||||
// its sector / outcode / nation instead of a flat list.
|
||||
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
|
||||
? (
|
||||
[
|
||||
{
|
||||
kind: 'area',
|
||||
label: t('areaPane.thisArea'),
|
||||
value: displayValue,
|
||||
},
|
||||
nationalAvg != null
|
||||
? {
|
||||
kind: 'national',
|
||||
label: t('areaPane.national'),
|
||||
value: nationalAvg,
|
||||
}
|
||||
: null,
|
||||
crimeAreaAvg.outcode != null
|
||||
? {
|
||||
kind: 'outcode',
|
||||
label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
|
||||
value: crimeAreaAvg.outcode,
|
||||
}
|
||||
: null,
|
||||
crimeAreaAvg.sector != null
|
||||
? {
|
||||
kind: 'sector',
|
||||
label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
|
||||
value: crimeAreaAvg.sector,
|
||||
}
|
||||
: null,
|
||||
] as (NumberLinePoint | null)[]
|
||||
).filter((p): p is NumberLinePoint => p !== null)
|
||||
: [];
|
||||
// Crime metrics get a number line comparing this area to
|
||||
// its sector / outcode / nation instead of a flat list.
|
||||
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
|
||||
? (
|
||||
[
|
||||
{
|
||||
kind: 'area',
|
||||
label: t('areaPane.thisArea'),
|
||||
value: displayValue,
|
||||
},
|
||||
nationalAvg != null
|
||||
? {
|
||||
kind: 'national',
|
||||
label: t('areaPane.national'),
|
||||
value: nationalAvg,
|
||||
}
|
||||
: null,
|
||||
crimeAreaAvg.outcode != null
|
||||
? {
|
||||
kind: 'outcode',
|
||||
label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
|
||||
value: crimeAreaAvg.outcode,
|
||||
}
|
||||
: null,
|
||||
crimeAreaAvg.sector != null
|
||||
? {
|
||||
kind: 'sector',
|
||||
label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
|
||||
value: crimeAreaAvg.sector,
|
||||
}
|
||||
: null,
|
||||
] as (NumberLinePoint | null)[]
|
||||
).filter((p): p is NumberLinePoint => p !== null)
|
||||
: [];
|
||||
|
||||
if (total === 0) return null;
|
||||
if (total === 0) return null;
|
||||
|
||||
const crimeSeries = chart.feature
|
||||
? crimeByYearByType.get(chart.feature)
|
||||
: undefined;
|
||||
const crimeSeries = chart.feature
|
||||
? crimeByYearByType.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
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"
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||
{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>
|
||||
{/* Crime shows its national/outcode/sector
|
||||
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"
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||
{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>
|
||||
{/* Crime shows its national/outcode/sector
|
||||
comparison on the number line below; other
|
||||
stacked metrics keep the inline national avg. */}
|
||||
{!crimeAreaAvg && nationalAvg != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
|
||||
{!crimeAreaAvg && nationalAvg != null && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||
{t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={displaySegments}
|
||||
total={total}
|
||||
colorMap={
|
||||
chart.label === 'Political vote share'
|
||||
? PARTY_FEATURE_COLORS
|
||||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
{numberLinePoints.length >= 2 && (
|
||||
<div className="mt-2">
|
||||
<NumberLine points={numberLinePoints} format={formatValue} />
|
||||
</div>
|
||||
)}
|
||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<CrimeYearChart
|
||||
points={crimeSeries.points}
|
||||
latestAvailableYear={stats?.crime_latest_year}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={displaySegments}
|
||||
total={total}
|
||||
colorMap={
|
||||
chart.label === 'Political vote share'
|
||||
? PARTY_FEATURE_COLORS
|
||||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
{numberLinePoints.length >= 2 && (
|
||||
<div className="mt-2">
|
||||
<NumberLine points={numberLinePoints} format={formatValue} />
|
||||
</div>
|
||||
)}
|
||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<CrimeYearChart
|
||||
points={crimeSeries.points}
|
||||
latestAvailableYear={stats?.crime_latest_year}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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 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;
|
||||
const crimeSeries = crimeByYearByType.get(feature.name);
|
||||
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
|
||||
// National avg is shown for every metric here as a
|
||||
// tooltip; for crime metrics the outcode and sector
|
||||
// averages join it on their own lines, and the exact
|
||||
// national mean is preferred over the histogram one.
|
||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||
const valueTitle =
|
||||
[
|
||||
nationalAvg != null
|
||||
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
|
||||
: null,
|
||||
crimeAreaAvg?.outcode != null
|
||||
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
|
||||
: null,
|
||||
crimeAreaAvg?.sector != null
|
||||
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n') || undefined;
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
const crimeSeries = crimeByYearByType.get(feature.name);
|
||||
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
|
||||
// National avg is shown for every metric here as a
|
||||
// tooltip; for crime metrics the outcode and sector
|
||||
// averages join it on their own lines, and the exact
|
||||
// national mean is preferred over the histogram one.
|
||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||
const valueTitle =
|
||||
[
|
||||
nationalAvg != null
|
||||
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
|
||||
: null,
|
||||
crimeAreaAvg?.outcode != null
|
||||
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
|
||||
: null,
|
||||
crimeAreaAvg?.sector != null
|
||||
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n') || undefined;
|
||||
|
||||
return (
|
||||
<MetricRow
|
||||
key={feature.name}
|
||||
label={
|
||||
<MetricFeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
aboutLabel={t('filters.aboutData')}
|
||||
return (
|
||||
<MetricRow
|
||||
key={feature.name}
|
||||
label={
|
||||
<MetricFeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
aboutLabel={t('filters.aboutData')}
|
||||
/>
|
||||
}
|
||||
chart={
|
||||
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||
<CrimeYearChart
|
||||
points={crimeSeries.points}
|
||||
latestAvailableYear={stats?.crime_latest_year}
|
||||
/>
|
||||
) : (
|
||||
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
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
value={formatValue(numericStats.mean, feature)}
|
||||
valueTitle={valueTitle}
|
||||
/>
|
||||
}
|
||||
chart={
|
||||
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||
<CrimeYearChart
|
||||
points={crimeSeries.points}
|
||||
latestAvailableYear={stats?.crime_latest_year}
|
||||
/>
|
||||
) : (
|
||||
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
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
value={formatValue(numericStats.mean, feature)}
|
||||
valueTitle={valueTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
wrap
|
||||
/>
|
||||
<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;
|
||||
|
||||
if (enumStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
wrap
|
||||
/>
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
featureName={feature.name}
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={featureMeta}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||
{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]])
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
})()}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
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 (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
if (components.length === 0) 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"
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||
{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]])
|
||||
)}
|
||||
/>
|
||||
</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}
|
||||
wrap
|
||||
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}
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,10 @@ export default function CrimeRecordsSection({
|
|||
<>
|
||||
<ol className="divide-y divide-warm-100 dark:divide-navy-800">
|
||||
{records.map((record, index) => (
|
||||
<li key={`${record.month}-${record.lat}-${record.lon}-${index}`} className="py-1.5">
|
||||
<li
|
||||
key={`${record.month}-${record.lat}-${record.lon}-${index}`}
|
||||
className="py-1.5"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 break-words text-[13px] font-medium text-warm-900 dark:text-warm-100">
|
||||
{ts(record.type)}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
interface DemoLocationPromptProps {
|
||||
/** True while a geolocation request is in flight. */
|
||||
locating: boolean;
|
||||
/** Error message to show (e.g. permission denied), or null. */
|
||||
error: string | null;
|
||||
onUseLocation: () => void;
|
||||
onUseDefault: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-visit demo prompt: offers to centre the map on the visitor's GPS location
|
||||
* (with consent) or start at the Canary Wharf default. Rendered over the map (which
|
||||
* is already showing Canary Wharf), so declining is just a dismiss.
|
||||
*/
|
||||
export default function DemoLocationPrompt({
|
||||
locating,
|
||||
error,
|
||||
onUseLocation,
|
||||
onUseDefault,
|
||||
}: DemoLocationPromptProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialogRef = useModalA11y();
|
||||
|
||||
return (
|
||||
<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="demo-location-title"
|
||||
tabIndex={-1}
|
||||
className="relative 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 overflow-hidden outline-none"
|
||||
>
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-7 text-center">
|
||||
<h2 id="demo-location-title" className="text-xl font-bold text-white mb-2">
|
||||
{t('demoLocation.title')}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-sm">{t('demoLocation.body')}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6 flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseLocation}
|
||||
disabled={locating}
|
||||
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{locating && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{locating ? t('demoLocation.locating') : t('demoLocation.useLocation')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseDefault}
|
||||
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('demoLocation.useDefault')}
|
||||
</button>
|
||||
{error && <p className="text-center text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -79,7 +79,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
|||
</ul>
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{visited && <span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>}
|
||||
{visited && (
|
||||
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
||||
)}
|
||||
<span className="text-teal-600 dark:text-teal-400">{t('listing.openListing')} ↗</span>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ export class MapErrorBoundary extends Component<MapErrorBoundaryProps, MapErrorB
|
|||
</h2>
|
||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||||
{i18n.t('map.error.body', {
|
||||
defaultValue: 'This can happen when your browser’s graphics context is interrupted.',
|
||||
defaultValue:
|
||||
'This can happen when your browser’s graphics context is interrupted.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -21,21 +21,14 @@ import {
|
|||
resolveTransitVariant,
|
||||
travelFieldKey,
|
||||
useTravelTime,
|
||||
type TransportMode,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import {
|
||||
apiUrl,
|
||||
authHeaders,
|
||||
buildFilterString,
|
||||
persistDemoChoice,
|
||||
readDemoChoice,
|
||||
setDemoCenter,
|
||||
} from '../../lib/api';
|
||||
import DemoLocationPrompt from './DemoLocationPrompt';
|
||||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import {
|
||||
DEMO_MAX_FILTERS,
|
||||
DEV_LOCATION,
|
||||
REGISTERED_MAX_FILTERS,
|
||||
INITIAL_VIEW_STATE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
|
|
@ -85,8 +78,6 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
|
|||
|
||||
export type { ExportState } from './map-page/types';
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
const EMPTY_POIS: POI[] = [];
|
||||
|
||||
|
|
@ -95,7 +86,6 @@ export default function MapPage({
|
|||
poiCategoryGroups,
|
||||
initialFilters,
|
||||
initialViewState,
|
||||
offerDemoLocation,
|
||||
initialPOICategories,
|
||||
initialOverlays,
|
||||
initialCrimeTypes,
|
||||
|
|
@ -155,11 +145,50 @@ export default function MapPage({
|
|||
initialPostcode ?? null
|
||||
);
|
||||
|
||||
// Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the
|
||||
// cap opens the upgrade modal. Licensed users and admins are unlimited.
|
||||
// Filter allowance scales with the account tier: anonymous → DEMO_MAX_FILTERS,
|
||||
// registered free → REGISTERED_MAX_FILTERS, licensed/admin → unlimited. Hitting the
|
||||
// cap opens the upgrade modal.
|
||||
const isLoggedIn = !!user;
|
||||
const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
|
||||
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
|
||||
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
|
||||
// On a shared link, non-paying users may view and adjust the shared filters but
|
||||
// not add new ones — so a richer shared search can't become a way around the cap.
|
||||
const lockFilterAdds = !filtersUnlimited && !!shareCode;
|
||||
// Which upsell the modal frames: a shared-link block, or a plain filter-cap hit.
|
||||
const upgradeReason: 'filters' | 'shared' = lockFilterAdds ? 'shared' : 'filters';
|
||||
const [filterLimitHit, setFilterLimitHit] = useState(false);
|
||||
const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []);
|
||||
const handleFilterLimitReached = useCallback(() => {
|
||||
trackEvent('Upgrade Modal Shown');
|
||||
setFilterLimitHit(true);
|
||||
}, []);
|
||||
|
||||
// Travel-time destinations count toward the same cap as feature filters (each one
|
||||
// restricts which properties match). Cap an over-budget initial travel set (from a
|
||||
// bookmarked/shared URL), preferring feature filters, so the first data request
|
||||
// never exceeds the server cap.
|
||||
const cappedInitialTravelTime = useMemo(() => {
|
||||
const initialEntries = initialTravelTime?.entries ?? [];
|
||||
if (filtersUnlimited || initialEntries.length === 0) return initialTravelTime;
|
||||
const travelBudget = Math.max(0, effectiveFilterCap - Object.keys(initialFilters).length);
|
||||
return initialEntries.length > travelBudget
|
||||
? { ...initialTravelTime, entries: initialEntries.slice(0, travelBudget) }
|
||||
: initialTravelTime;
|
||||
}, [filtersUnlimited, effectiveFilterCap, initialFilters, initialTravelTime]);
|
||||
|
||||
const {
|
||||
entries,
|
||||
activeEntries,
|
||||
handleAddEntry,
|
||||
handleRemoveEntry,
|
||||
handleSetDestination,
|
||||
handleSetEntries,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
handleToggleNoChange,
|
||||
handleToggleOneChange,
|
||||
handleToggleNoBuses,
|
||||
} = useTravelTime(cappedInitialTravelTime);
|
||||
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -183,7 +212,10 @@ export default function MapPage({
|
|||
} = useFilters({
|
||||
initialFilters,
|
||||
features,
|
||||
filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
|
||||
filterLimit,
|
||||
// Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime).
|
||||
reservedFilterSlots: filtersUnlimited ? 0 : entries.length,
|
||||
lockAdds: lockFilterAdds,
|
||||
onFilterLimitReached: handleFilterLimitReached,
|
||||
});
|
||||
|
||||
|
|
@ -196,20 +228,6 @@ export default function MapPage({
|
|||
summary: aiFilterSummary,
|
||||
} = useAiFilters();
|
||||
|
||||
const {
|
||||
entries,
|
||||
activeEntries,
|
||||
handleAddEntry,
|
||||
handleRemoveEntry,
|
||||
handleSetDestination,
|
||||
handleSetEntries,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
handleToggleNoChange,
|
||||
handleToggleOneChange,
|
||||
handleToggleNoBuses,
|
||||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||
const areaPaneScrollTopRef = useRef(0);
|
||||
const propertiesPaneScrollTopRef = useRef(0);
|
||||
|
|
@ -262,13 +280,22 @@ export default function MapPage({
|
|||
const result = await fetchAiFilters(query, hasContext ? context : undefined);
|
||||
if (!result) return;
|
||||
|
||||
handleSetFilters(result.filters);
|
||||
// Non-paying users share one cap across feature filters and travel-time
|
||||
// filters, so clamp the AI's combined result (features first, then travel).
|
||||
const cappedFilters = filtersUnlimited
|
||||
? result.filters
|
||||
: Object.fromEntries(Object.entries(result.filters).slice(0, effectiveFilterCap));
|
||||
handleSetFilters(cappedFilters);
|
||||
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
|
||||
// FIRST so the same filtered list drives both entry state and fly-to.
|
||||
// Otherwise we'd fly to a destination for a mode the user can't see.
|
||||
const representable = result.travelTimeFilters
|
||||
const representableAll = result.travelTimeFilters
|
||||
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
|
||||
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
|
||||
const travelBudget = filtersUnlimited
|
||||
? representableAll.length
|
||||
: Math.max(0, effectiveFilterCap - Object.keys(cappedFilters).length);
|
||||
const representable = representableAll.slice(0, travelBudget);
|
||||
|
||||
handleSetEntries(
|
||||
representable.map(({ tt, parsed }) => ({
|
||||
|
|
@ -323,12 +350,26 @@ export default function MapPage({
|
|||
activeEntries,
|
||||
fetchAiFilters,
|
||||
filters,
|
||||
filtersUnlimited,
|
||||
getMobileMapFlyToOptions,
|
||||
handleSetEntries,
|
||||
handleSetFilters,
|
||||
]
|
||||
);
|
||||
|
||||
// Travel-time entries share the non-paying user's filter cap, so block adding one
|
||||
// when the combined feature + travel count is already at the limit.
|
||||
const handleAddTravelTimeEntry = useCallback(
|
||||
(mode: TransportMode) => {
|
||||
if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) {
|
||||
handleFilterLimitReached();
|
||||
return;
|
||||
}
|
||||
handleAddEntry(mode);
|
||||
},
|
||||
[filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
handleSetFilters({});
|
||||
handleCancelPin();
|
||||
|
|
@ -477,93 +518,6 @@ export default function MapPage({
|
|||
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
||||
);
|
||||
|
||||
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
||||
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
|
||||
// the camera fly alone to close it: flying back to the free/shared zone only
|
||||
// clears `licenseRequired` once the resulting refetch returns non-403, and
|
||||
// when the fly target equals the current view (e.g. "back to shared area"
|
||||
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
|
||||
// and the modal would otherwise stay stuck open.
|
||||
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
setUpgradeModalDismissed(true);
|
||||
setFilterLimitHit(false);
|
||||
// Fly back to the visitor's own free zone. Prefer the server-reported zone
|
||||
// (reflects their chosen demo centre — including a GPS choice made this
|
||||
// session), then a share-return view, then the initial centre.
|
||||
const fz = mapData.freeZone;
|
||||
const target =
|
||||
shareReturnViewRef.current ??
|
||||
(fz
|
||||
? {
|
||||
latitude: (fz.south + fz.north) / 2,
|
||||
longitude: (fz.west + fz.east) / 2,
|
||||
zoom: initialViewState.zoom,
|
||||
}
|
||||
: initialViewState);
|
||||
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
||||
}, [initialViewState, mapData.freeZone]);
|
||||
|
||||
// "Check your area" demo prompt: GPS recenters the map AND moves the free zone
|
||||
// (the chosen centre is sent to the server via lib/api on each request);
|
||||
// declining keeps Canary Wharf. Persisted per session so we don't re-ask.
|
||||
const [demoPromptOpen, setDemoPromptOpen] = useState(
|
||||
// Also check the persisted choice directly: MapPage remounts on navigation
|
||||
// (route key change) while App's `offerDemoLocation` is frozen at mount, so a
|
||||
// choice made earlier this session must still suppress a re-prompt.
|
||||
() => !!offerDemoLocation && !screenshotMode && !ogMode && readDemoChoice() == null
|
||||
);
|
||||
const [demoLocating, setDemoLocating] = useState(false);
|
||||
const [demoLocationError, setDemoLocationError] = useState<string | null>(null);
|
||||
|
||||
const handleUseMyLocation = useCallback(() => {
|
||||
const applyCenter = (center: { lat: number; lon: number }) => {
|
||||
setDemoCenter(center);
|
||||
persistDemoChoice(center);
|
||||
setDemoLocating(false);
|
||||
setDemoPromptOpen(false);
|
||||
// handleFlyTo uses map.jumpTo (instant) — no flashing 403s through
|
||||
// intermediate viewports; the move triggers a refetch carrying demoLat/Lon.
|
||||
mapFlyToRef.current?.(
|
||||
center.lat,
|
||||
center.lon,
|
||||
INITIAL_VIEW_STATE.zoom,
|
||||
getMobileMapFlyToOptions()
|
||||
);
|
||||
};
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
setDemoLocationError(t('locationSearch.geolocationUnsupported'));
|
||||
return;
|
||||
}
|
||||
setDemoLocating(true);
|
||||
setDemoLocationError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => applyCenter({ lat: position.coords.latitude, lon: position.coords.longitude }),
|
||||
() => {
|
||||
// In dev, geolocation is usually blocked (e.g. served over http), so fall
|
||||
// back to 10 Downing Street to keep the "data follows you" flow testable.
|
||||
if (__DEV__) {
|
||||
applyCenter({ lat: DEV_LOCATION.latitude, lon: DEV_LOCATION.longitude });
|
||||
return;
|
||||
}
|
||||
setDemoLocating(false);
|
||||
setDemoLocationError(t('locationSearch.geolocationFailed'));
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
|
||||
);
|
||||
}, [t, getMobileMapFlyToOptions]);
|
||||
|
||||
const handleUseDefaultLocation = useCallback(() => {
|
||||
persistDemoChoice('declined');
|
||||
setDemoPromptOpen(false);
|
||||
}, []);
|
||||
|
||||
// Never show the demo prompt to licensed/admin users — e.g. if the session
|
||||
// hydrated as free then refreshed to licensed, or they signed in in another tab.
|
||||
useEffect(() => {
|
||||
if (filtersUnlimited) setDemoPromptOpen(false);
|
||||
}, [filtersUnlimited]);
|
||||
|
||||
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
// Disabling POIs (clearing every category) must remove all POI cards/markers
|
||||
// immediately, not on the next fetch tick — gate on the selection itself so a
|
||||
|
|
@ -631,7 +585,6 @@ export default function MapPage({
|
|||
dataLength: mapData.data.length,
|
||||
postcodeDataLength: mapData.postcodeData.length,
|
||||
usePostcodeView: mapData.usePostcodeView,
|
||||
licenseRequired: mapData.licenseRequired,
|
||||
});
|
||||
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
|
|
@ -650,7 +603,7 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
areaStats
|
||||
);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
||||
|
|
@ -725,11 +678,7 @@ export default function MapPage({
|
|||
}, [dashboardParams, onDashboardParamsChange]);
|
||||
|
||||
const dashboardReady =
|
||||
!initialLoading &&
|
||||
!mapData.loading &&
|
||||
!mapData.licenseRequired &&
|
||||
mapData.bounds != null &&
|
||||
mapData.currentView != null;
|
||||
!initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null;
|
||||
|
||||
useEffect(() => {
|
||||
onDashboardReadyChange?.(dashboardReady);
|
||||
|
|
@ -741,16 +690,6 @@ export default function MapPage({
|
|||
};
|
||||
}, [onDashboardReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) {
|
||||
trackEvent('Upgrade Modal Shown');
|
||||
} else {
|
||||
// Back in a viewable area — re-arm so the modal shows again the next time
|
||||
// the user pans into a gated area.
|
||||
setUpgradeModalDismissed(false);
|
||||
}
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
// Clear the filter-cap upsell once it's no longer relevant: the user became
|
||||
// licensed/admin, or dropped back under the cap by removing filters. Prevents a
|
||||
// stale flag from resurfacing the modal spuriously.
|
||||
|
|
@ -909,7 +848,7 @@ export default function MapPage({
|
|||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={entries}
|
||||
onTravelTimeAddEntry={handleAddEntry}
|
||||
onTravelTimeAddEntry={handleAddTravelTimeEntry}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||
|
|
@ -956,7 +895,7 @@ export default function MapPage({
|
|||
features,
|
||||
filterCounts.impacts,
|
||||
filters,
|
||||
handleAddEntry,
|
||||
handleAddTravelTimeEntry,
|
||||
handleAddFilter,
|
||||
handleAiFilterSubmit,
|
||||
handleClearAll,
|
||||
|
|
@ -1082,51 +1021,25 @@ export default function MapPage({
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
// The upgrade modal serves two cases: panning outside the free zone
|
||||
// (licenseRequired) and hitting the demo filter cap (filterLimitHit). The zone
|
||||
// case takes precedence; for the filter case, dismissing just closes the modal
|
||||
// (no camera move).
|
||||
const showZoneUpgradeModal = mapData.licenseRequired && !upgradeModalDismissed;
|
||||
const showFilterUpgradeModal = !showZoneUpgradeModal && filterLimitHit && !filtersUnlimited;
|
||||
const upgradeModal =
|
||||
showZoneUpgradeModal || showFilterUpgradeModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick
|
||||
? onCheckoutRegisterClick(checkoutReturnPath)
|
||||
: onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={
|
||||
showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
|
||||
}
|
||||
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
const demoLocationPrompt = demoPromptOpen ? (
|
||||
<DemoLocationPrompt
|
||||
locating={demoLocating}
|
||||
error={demoLocationError}
|
||||
onUseLocation={handleUseMyLocation}
|
||||
onUseDefault={handleUseDefaultLocation}
|
||||
/>
|
||||
// The upgrade modal is shown when a non-paying user hits the filter cap.
|
||||
// Dismissing it just closes the modal (the filters they have stay applied).
|
||||
const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
|
||||
const upgradeModal = showFilterUpgradeModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onClose={() => setFilterLimitHit(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
// Both overlays render where the page components place {upgradeModal}.
|
||||
const overlayModals = (
|
||||
<>
|
||||
{upgradeModal}
|
||||
{demoLocationPrompt}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileMapPage
|
||||
|
|
@ -1178,7 +1091,7 @@ export default function MapPage({
|
|||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
upgradeModal={overlayModals}
|
||||
upgradeModal={upgradeModal}
|
||||
editingBar={editingBar}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1238,7 +1151,7 @@ export default function MapPage({
|
|||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
upgradeModal={overlayModals}
|
||||
upgradeModal={upgradeModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,9 @@ function renderSchoolMetadata(school: SchoolMetadata, t: TFunction) {
|
|||
)}
|
||||
{school.local_authority && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.localAuthority')}</dt>
|
||||
<dt className="text-warm-500 dark:text-warm-400">
|
||||
{t('poiPopup.school.localAuthority')}
|
||||
</dt>
|
||||
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,9 @@ export function VariantFilterCard({
|
|||
if (!feature) return null;
|
||||
return {
|
||||
value: optionName,
|
||||
label: ts(config.getOptionLabelSource ? config.getOptionLabelSource(optionName) : optionName),
|
||||
label: ts(
|
||||
config.getOptionLabelSource ? config.getOptionLabelSource(optionName) : optionName
|
||||
),
|
||||
feature,
|
||||
};
|
||||
})
|
||||
|
|
@ -92,9 +94,7 @@ export function VariantFilterCard({
|
|||
const windowOptions =
|
||||
windowConfig && selectedFeatureName
|
||||
? windowConfig.options.filter((option) =>
|
||||
features.some(
|
||||
(f) => f.name === windowConfig.withWindow(selectedFeatureName, option.id)
|
||||
)
|
||||
features.some((f) => f.name === windowConfig.withWindow(selectedFeatureName, option.id))
|
||||
)
|
||||
: [];
|
||||
|
||||
|
|
|
|||
|
|
@ -236,8 +236,12 @@ export function DesktopMapPage({
|
|||
onClick={onToggleActualListings}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-busy={actualListingsLoading}
|
||||
aria-label={actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')}
|
||||
title={actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')}
|
||||
aria-label={
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
title={
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
>
|
||||
{actualListingsLoading ? (
|
||||
|
|
@ -246,7 +250,8 @@ export function DesktopMapPage({
|
|||
<HouseIcon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{t('map.actualListings.label')}{actualListingsEnabled ? ` (${actualListings.length})` : ''}
|
||||
{t('map.actualListings.label')}
|
||||
{actualListingsEnabled ? ` (${actualListings.length})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -207,14 +207,10 @@ export function MobileMapPage({
|
|||
aria-pressed={actualListingsEnabled}
|
||||
aria-busy={actualListingsLoading}
|
||||
aria-label={
|
||||
actualListingsEnabled
|
||||
? t('map.actualListings.hide')
|
||||
: t('map.actualListings.show')
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
title={
|
||||
actualListingsEnabled
|
||||
? t('map.actualListings.hide')
|
||||
: t('map.actualListings.show')
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
>
|
||||
{actualListingsLoading ? (
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ interface UseScreenshotReadySignalOptions {
|
|||
dataLength: number;
|
||||
postcodeDataLength: number;
|
||||
usePostcodeView: boolean;
|
||||
licenseRequired: boolean;
|
||||
}
|
||||
|
||||
export function useScreenshotReadySignal({
|
||||
|
|
@ -125,13 +124,12 @@ export function useScreenshotReadySignal({
|
|||
dataLength,
|
||||
postcodeDataLength,
|
||||
usePostcodeView,
|
||||
licenseRequired,
|
||||
}: UseScreenshotReadySignalOptions) {
|
||||
useEffect(() => {
|
||||
if (!screenshotMode || loading || !boundsReady) return;
|
||||
|
||||
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
||||
if (!hasData && !licenseRequired) return;
|
||||
if (!hasData) return;
|
||||
|
||||
let cancelled = false;
|
||||
let signalled = false;
|
||||
|
|
@ -170,13 +168,5 @@ export function useScreenshotReadySignal({
|
|||
if (timeoutId != null) window.clearTimeout(timeoutId);
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
screenshotMode,
|
||||
loading,
|
||||
boundsReady,
|
||||
dataLength,
|
||||
postcodeDataLength,
|
||||
usePostcodeView,
|
||||
licenseRequired,
|
||||
]);
|
||||
}, [screenshotMode, loading, boundsReady, dataLength, postcodeDataLength, usePostcodeView]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ export interface MapPageProps {
|
|||
poiCategoryGroups: POICategoryGroup[];
|
||||
initialFilters: FeatureFilters;
|
||||
initialViewState: ViewState;
|
||||
/** Offer the "check your area" GPS prompt (fresh demo visit, no URL view). */
|
||||
offerDemoLocation?: boolean;
|
||||
initialPOICategories: Set<string>;
|
||||
initialOverlays?: Set<OverlayId>;
|
||||
initialCrimeTypes?: Set<string>;
|
||||
|
|
|
|||
|
|
@ -10,11 +10,8 @@ interface UpgradeModalProps {
|
|||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onStartCheckout: () => Promise<void>;
|
||||
onZoomToFreeZone: () => void;
|
||||
/** When true, the user came in via a share link; relabel "Continue with demo"
|
||||
* to "Back to shared area" since clicking it returns to the share's coords,
|
||||
* not the central-London demo. */
|
||||
isShareReturn?: boolean;
|
||||
/** Dismiss the modal and keep exploring with the free filter allowance. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
|
|
@ -22,8 +19,7 @@ export default function UpgradeModal({
|
|||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onStartCheckout,
|
||||
onZoomToFreeZone,
|
||||
isShareReturn,
|
||||
onClose,
|
||||
}: UpgradeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -42,11 +38,11 @@ export default function UpgradeModal({
|
|||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onZoomToFreeZone();
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onZoomToFreeZone]);
|
||||
}, [onClose]);
|
||||
|
||||
const priceLabel =
|
||||
pricePence === null
|
||||
|
|
@ -84,7 +80,7 @@ export default function UpgradeModal({
|
|||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoomToFreeZone}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
>
|
||||
|
|
@ -96,9 +92,7 @@ export default function UpgradeModal({
|
|||
<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>
|
||||
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
|
@ -152,10 +146,10 @@ export default function UpgradeModal({
|
|||
)}
|
||||
|
||||
<button
|
||||
onClick={onZoomToFreeZone}
|
||||
onClick={onClose}
|
||||
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
||||
>
|
||||
{isShareReturn ? t('upgrade.backToSharedArea') : t('upgrade.continueWithDemo')}
|
||||
{t('upgrade.continueFree')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,63 +54,72 @@ export function useAuth() {
|
|||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: 'email' });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: 'email' });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const register = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
newsletter: true,
|
||||
});
|
||||
// Auto-login after registration
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Register');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
const register = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
newsletter: true,
|
||||
});
|
||||
// Auto-login after registration
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Register');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const loginWithOAuth = useCallback(async (provider: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithOAuth2({
|
||||
provider,
|
||||
createData: { newsletter: true },
|
||||
});
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: provider });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
const loginWithOAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithOAuth2({
|
||||
provider,
|
||||
createData: { newsletter: true },
|
||||
});
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: provider });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
trackEvent('Logout');
|
||||
|
|
@ -118,19 +127,22 @@ export function useAuth() {
|
|||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const requestPasswordReset = useCallback(async (email: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
const requestPasswordReset = useCallback(
|
||||
async (email: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,102 @@ describe('useFilters', () => {
|
|||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clamps the initial filter set to the limit for non-paying users', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
// A bookmarked or shared URL may carry more filters than the cap allows; the
|
||||
// initial set must start clamped so the first map request never exceeds the
|
||||
// server-side cap (which would 400 and blank the map).
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1], e: [0, 1] },
|
||||
features: sixFeatures,
|
||||
filterLimit: 3,
|
||||
})
|
||||
);
|
||||
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('does not clamp the initial filter set when filterLimit is null (licensed users)', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1], e: [0, 1] },
|
||||
features: sixFeatures,
|
||||
filterLimit: null,
|
||||
})
|
||||
);
|
||||
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('counts reserved slots (e.g. travel-time filters) toward the cap', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
const onFilterLimitReached = vi.fn();
|
||||
|
||||
// Limit 3 with 1 slot already reserved (e.g. a travel-time filter) leaves room
|
||||
// for only 2 feature filters.
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: {},
|
||||
features: sixFeatures,
|
||||
filterLimit: 3,
|
||||
reservedFilterSlots: 1,
|
||||
onFilterLimitReached,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
['a', 'b'].forEach((name) => result.current.handleAddFilter(name));
|
||||
});
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
||||
expect(onFilterLimitReached).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddFilter('c');
|
||||
});
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
||||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clamps the initial filter set accounting for reserved slots', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
// 4 initial feature filters, limit 3, 1 reserved slot → keep only 2 features.
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1] },
|
||||
features: sixFeatures,
|
||||
filterLimit: 3,
|
||||
reservedFilterSlots: 1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not cap filters when filterLimit is null (licensed users)', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,14 @@ interface UseFiltersOptions {
|
|||
features: FeatureMeta[];
|
||||
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
|
||||
filterLimit?: number | null;
|
||||
/** Called when an add is blocked because `filterLimit` is already reached. */
|
||||
/** Filter slots already consumed outside this hook (e.g. travel-time entries),
|
||||
* which share the same cap. Subtracted from `filterLimit` when gating adds. */
|
||||
reservedFilterSlots?: number;
|
||||
/** When true, block adding any *new* filter outright (existing filters can still
|
||||
* be tweaked/replaced). Used so a non-paying user on a shared link can view and
|
||||
* adjust the shared set but not build on top of it. Triggers `onFilterLimitReached`. */
|
||||
lockAdds?: boolean;
|
||||
/** Called when an add is blocked because `filterLimit` is reached or `lockAdds` is set. */
|
||||
onFilterLimitReached?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -146,11 +153,22 @@ export function useFilters({
|
|||
initialFilters,
|
||||
features,
|
||||
filterLimit,
|
||||
reservedFilterSlots = 0,
|
||||
lockAdds = false,
|
||||
onFilterLimitReached,
|
||||
}: UseFiltersOptions) {
|
||||
const initialFiltersRef = useRef<FeatureFilters | null>(null);
|
||||
if (!initialFiltersRef.current) {
|
||||
initialFiltersRef.current = normalizeFilters(initialFilters);
|
||||
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
|
||||
// user's remaining budget — the cap minus slots already taken by travel-time
|
||||
// filters — so a non-paying user never starts above the cap, which the server
|
||||
// would reject (400), blanking the map on first load.
|
||||
const normalized = normalizeFilters(initialFilters);
|
||||
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
|
||||
initialFiltersRef.current =
|
||||
budget != null && Object.keys(normalized).length > budget
|
||||
? Object.fromEntries(Object.entries(normalized).slice(0, budget))
|
||||
: normalized;
|
||||
}
|
||||
|
||||
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
||||
|
|
@ -159,6 +177,10 @@ export function useFilters({
|
|||
filtersRef.current = filters;
|
||||
const filterLimitRef = useRef(filterLimit);
|
||||
filterLimitRef.current = filterLimit;
|
||||
const reservedSlotsRef = useRef(reservedFilterSlots);
|
||||
reservedSlotsRef.current = reservedFilterSlots;
|
||||
const lockAddsRef = useRef(lockAdds);
|
||||
lockAddsRef.current = lockAdds;
|
||||
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
|
||||
onFilterLimitReachedRef.current = onFilterLimitReached;
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
|
|
@ -238,11 +260,14 @@ export function useFilters({
|
|||
return;
|
||||
}
|
||||
|
||||
// Demo (unlicensed) users are capped at `filterLimit` simultaneous filters.
|
||||
// Non-paying users are capped at `filterLimit` simultaneous filters, with
|
||||
// travel-time entries (reservedFilterSlots) counting toward the same cap.
|
||||
// Re-applying an existing regular feature replaces it (no new key), so it's
|
||||
// always allowed; the special/generated filters always add a new key.
|
||||
// `lockAdds` (a non-paying user on a shared link) blocks every new key
|
||||
// regardless of count — they can adjust the shared set but not build on it.
|
||||
const limit = filterLimitRef.current;
|
||||
if (limit != null) {
|
||||
if (limit != null || lockAddsRef.current) {
|
||||
const current = filtersRef.current;
|
||||
const addsNewKey =
|
||||
name === SCHOOL_FILTER_NAME ||
|
||||
|
|
@ -254,7 +279,9 @@ export function useFilters({
|
|||
name === TENURE_FILTER_NAME ||
|
||||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
|
||||
!(name in current);
|
||||
if (addsNewKey && Object.keys(current).length >= limit) {
|
||||
const overLimit =
|
||||
limit != null && Object.keys(current).length + reservedSlotsRef.current >= limit;
|
||||
if (addsNewKey && (lockAddsRef.current || overLimit)) {
|
||||
onFilterLimitReachedRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,32 +8,35 @@ export function useLicense() {
|
|||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startCheckout = useCallback(async (returnPath?: string) => {
|
||||
trackEvent('Checkout Start', { has_referral: 'false' });
|
||||
setCheckingOut(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(apiUrl('checkout'), {
|
||||
method: 'POST',
|
||||
...authHeaders({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(returnPath ? { return_path: returnPath } : {}),
|
||||
}),
|
||||
});
|
||||
assertOk(res, 'Checkout');
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
trackEvent('Checkout Redirect');
|
||||
window.location.href = data.url;
|
||||
const startCheckout = useCallback(
|
||||
async (returnPath?: string) => {
|
||||
trackEvent('Checkout Start', { has_referral: 'false' });
|
||||
setCheckingOut(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(apiUrl('checkout'), {
|
||||
method: 'POST',
|
||||
...authHeaders({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(returnPath ? { return_path: returnPath } : {}),
|
||||
}),
|
||||
});
|
||||
assertOk(res, 'Checkout');
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
trackEvent('Checkout Redirect');
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('upgrade.checkoutFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setCheckingOut(false);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('upgrade.checkoutFailed');
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setCheckingOut(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return { startCheckout, checkingOut, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ interface UseMapDataOptions {
|
|||
pinnedFeature: string | null;
|
||||
filterRange?: [number, number] | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
/** Share-link code from the URL; appended to data fetches so the backend
|
||||
* grants bbox-scoped access for unlicensed recipients. */
|
||||
/** Legacy share-link code from the URL; still echoed on data fetches but no
|
||||
* longer grants any special access (the region lock was removed). */
|
||||
shareCode?: string;
|
||||
}
|
||||
|
||||
|
|
@ -100,9 +100,6 @@ export function useMapData({
|
|||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||||
|
||||
// Drag preview state
|
||||
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||||
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
||||
|
|
@ -392,19 +389,8 @@ export function useMapData({
|
|||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
if (res.status === 403) {
|
||||
const errBody = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||
setLicenseRequired(true);
|
||||
setFreeZone(errBody.free_zone);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assertOk(res, 'postcodes');
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setLicenseRequired(false);
|
||||
const json: { features: PostcodeFeature[] } = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setPostcodeData(json.features);
|
||||
|
|
@ -431,19 +417,8 @@ export function useMapData({
|
|||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
);
|
||||
if (res.status === 403) {
|
||||
const errBody = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||
setLicenseRequired(true);
|
||||
setFreeZone(errBody.free_zone);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assertOk(res, 'hexagons');
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setLicenseRequired(false);
|
||||
const json: ApiResponse = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setRawData(json.features);
|
||||
|
|
@ -733,8 +708,7 @@ export function useMapData({
|
|||
// Treat the map as loading whenever the rendered hexagons don't match the
|
||||
// current request — covers the brief window between a slider release and
|
||||
// the main fetch effect actually firing setLoading(true).
|
||||
const isLoading =
|
||||
loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
|
||||
const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
|
@ -753,7 +727,5 @@ export function useMapData({
|
|||
handleResetPreviewScale,
|
||||
handleViewChange,
|
||||
setInitialView,
|
||||
licenseRequired,
|
||||
freeZone,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,23 +105,26 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: `${info.object.count} ${t('common.places')}`,
|
||||
category: t('map.poi.zoomInToSeeDetails'),
|
||||
group: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
isCluster: true,
|
||||
clusterCount: info.object.count,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, [t]);
|
||||
const handleClusterHover = useCallback(
|
||||
(info: PickingInfo<ClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: `${info.object.count} ${t('common.places')}`,
|
||||
category: t('map.poi.zoomInToSeeDetails'),
|
||||
group: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
isCluster: true,
|
||||
clusterCount: info.object.count,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
|
|
|
|||
|
|
@ -181,34 +181,43 @@ export function useSavedSearches(userId: string | null) {
|
|||
[userId, fetchSearches, t]
|
||||
);
|
||||
|
||||
const deleteSearch = useCallback(async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
trackEvent('Search Delete');
|
||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
|
||||
}
|
||||
}, [t]);
|
||||
const deleteSearch = useCallback(
|
||||
async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
trackEvent('Search Delete');
|
||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
|
||||
try {
|
||||
await pb.collection('saved_searches').update(id, { notes });
|
||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
|
||||
}
|
||||
}, [t]);
|
||||
const updateSearchNotes = useCallback(
|
||||
async (id: string, notes: string) => {
|
||||
try {
|
||||
await pb.collection('saved_searches').update(id, { notes });
|
||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const updateSearchName = useCallback(async (id: string, name: string) => {
|
||||
try {
|
||||
await pb.collection('saved_searches').update(id, { name });
|
||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.updateNameFailed'));
|
||||
}
|
||||
}, [t]);
|
||||
const updateSearchName = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
try {
|
||||
await pb.collection('saved_searches').update(id, { name });
|
||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.updateNameFailed'));
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const updateSearchParams = useCallback(
|
||||
async (id: string, params: string) => {
|
||||
|
|
|
|||
|
|
@ -94,8 +94,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Part des ménages propriétaires de leur logement, intégralement ou avec un crédit',
|
||||
'% Social rent':
|
||||
"Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social",
|
||||
'% Private rent':
|
||||
'Part des ménages locataires dans le privé ou logés à titre gratuit',
|
||||
'% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit',
|
||||
'% White': 'Part de la population s’identifiant comme blanche',
|
||||
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
||||
'% Black': 'Part de la population s’identifiant comme noire',
|
||||
|
|
@ -204,8 +203,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'Anteil der Haushalte, die ihre Wohnung besitzen, schuldenfrei oder mit Hypothek',
|
||||
'% Social rent':
|
||||
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
|
||||
'% Private rent':
|
||||
'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
|
||||
'% Private rent': 'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
|
||||
'% White': 'Anteil der Personen, die sich als weiß identifizieren',
|
||||
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
|
||||
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
|
||||
|
|
@ -379,12 +377,9 @@ const descriptions: Record<string, Record<string, string>> = {
|
|||
'% Degree or higher': 'डिग्री स्तर या उससे ऊंची योग्यता वाले निवासियों (16+) का हिस्सा',
|
||||
'% Other qualifications':
|
||||
'अन्य योग्यताओं वाले निवासियों (16+) का हिस्सा, जिनमें व्यावसायिक या विदेश में प्राप्त योग्यताएं शामिल हैं',
|
||||
'% Owner occupied':
|
||||
'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
|
||||
'% Social rent':
|
||||
'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
|
||||
'% Private rent':
|
||||
'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
|
||||
'% Owner occupied': 'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
|
||||
'% Social rent': 'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
|
||||
'% Private rent': 'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
|
||||
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||
|
|
|
|||
|
|
@ -632,9 +632,9 @@ const de: Translations = {
|
|||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'Jeden passenden Postcode finden',
|
||||
title: 'Unbegrenzte Filter freischalten',
|
||||
description:
|
||||
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jedem Postcode, jedem Filter und jeder Nachbarschaft in England. Einmal zahlen, dauerhaft nutzen.',
|
||||
'Kostenlose Konten können bis zu 3 Filter gleichzeitig kombinieren. Erhalte lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode und jede Nachbarschaft in England hinweg zu kombinieren. Einmal zahlen, für immer nutzen.',
|
||||
free: 'Kostenlos',
|
||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
||||
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
||||
|
|
@ -643,10 +643,7 @@ const de: Translations = {
|
|||
upgradeFor: 'Upgrade für {{price}}',
|
||||
registerAndUpgrade: 'Registrieren & upgraden',
|
||||
alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
|
||||
continueWithDemo: 'Mit Demo fortfahren',
|
||||
backToSharedArea: 'Zurück zum geteilten Gebiet',
|
||||
sharedAreaDescription:
|
||||
'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu suchen, sichere dir lebenslangen Zugriff auf jeden Postcode, jeden Filter und jede Nachbarschaft in England.',
|
||||
continueFree: 'Mit 3 Filtern fortfahren',
|
||||
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
||||
},
|
||||
|
||||
|
|
@ -983,14 +980,6 @@ const de: Translations = {
|
|||
geolocationFailed: 'Standort konnte nicht ermittelt werden',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: 'Ihre Umgebung erkunden',
|
||||
body: 'Möchten Sie Immobiliendaten in Ihrer Nähe sehen? Verwenden Sie Ihren Standort oder starten Sie die Demo in Canary Wharf.',
|
||||
useLocation: 'Meinen Standort verwenden',
|
||||
locating: 'Standort wird ermittelt…',
|
||||
useDefault: 'Canary Wharf anzeigen',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Drawer schließen',
|
||||
|
|
@ -1408,7 +1397,7 @@ const de: Translations = {
|
|||
'Lebenslanger Zugang bedeutet, dass eine Zahlung deinem Konto laufenden Zugriff auf die kostenpflichtige Perfect-Postcode-Karte für die Lebensdauer des Dienstes gibt. Es ist kein Monats- oder Jahresabo, und normale Datenaktualisierungen sind enthalten. Du kannst es während dieser Suche nutzen, später zurückkommen und weiterhin Zugriff haben, wenn du erneut umziehst.',
|
||||
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
|
||||
faqPricing3A:
|
||||
'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigst du den lebenslangen Zugang.',
|
||||
'Kostenlose Nutzer erhalten die vollständige Perfect-Postcode-Karte für ganz England – jeden Postcode und jede Funktion – und können bis zu 3 Filter gleichzeitig anwenden. Der lebenslange Zugang hebt diese Grenze auf, sodass du unbegrenzt Filter kombinieren kannst.',
|
||||
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'Wie sehe ich eine Filtervorschau auf der Karte?',
|
||||
|
|
@ -1728,10 +1717,8 @@ const de: Translations = {
|
|||
'Serious crime (/yr, 2y)': 'Schwere Straftaten (pro Jahr, 2 J.)',
|
||||
'Minor crime (/yr, 7y)': 'Leichte Straftaten (pro Jahr, 7 J.)',
|
||||
'Minor crime (/yr, 2y)': 'Leichte Straftaten (pro Jahr, 2 J.)',
|
||||
'Violence and sexual offences (/yr, 7y)':
|
||||
'Gewalt- und Sexualdelikte (pro Jahr, 7 J.)',
|
||||
'Violence and sexual offences (/yr, 2y)':
|
||||
'Gewalt- und Sexualdelikte (pro Jahr, 2 J.)',
|
||||
'Violence and sexual offences (/yr, 7y)': 'Gewalt- und Sexualdelikte (pro Jahr, 7 J.)',
|
||||
'Violence and sexual offences (/yr, 2y)': 'Gewalt- und Sexualdelikte (pro Jahr, 2 J.)',
|
||||
'Burglary (/yr, 7y)': 'Einbrüche (pro Jahr, 7 J.)',
|
||||
'Burglary (/yr, 2y)': 'Einbrüche (pro Jahr, 2 J.)',
|
||||
'Robbery (/yr, 7y)': 'Raubüberfälle (pro Jahr, 7 J.)',
|
||||
|
|
@ -1740,10 +1727,8 @@ const de: Translations = {
|
|||
'Vehicle crime (/yr, 2y)': 'Fahrzeugkriminalität (pro Jahr, 2 J.)',
|
||||
'Anti-social behaviour (/yr, 7y)': 'Antisoziales Verhalten (pro Jahr, 7 J.)',
|
||||
'Anti-social behaviour (/yr, 2y)': 'Antisoziales Verhalten (pro Jahr, 2 J.)',
|
||||
'Criminal damage and arson (/yr, 7y)':
|
||||
'Sachbeschädigung und Brandstiftung (pro Jahr, 7 J.)',
|
||||
'Criminal damage and arson (/yr, 2y)':
|
||||
'Sachbeschädigung und Brandstiftung (pro Jahr, 2 J.)',
|
||||
'Criminal damage and arson (/yr, 7y)': 'Sachbeschädigung und Brandstiftung (pro Jahr, 7 J.)',
|
||||
'Criminal damage and arson (/yr, 2y)': 'Sachbeschädigung und Brandstiftung (pro Jahr, 2 J.)',
|
||||
'Other theft (/yr, 7y)': 'Sonstiger Diebstahl (pro Jahr, 7 J.)',
|
||||
'Other theft (/yr, 2y)': 'Sonstiger Diebstahl (pro Jahr, 2 J.)',
|
||||
'Theft from the person (/yr, 7y)': 'Diebstahl von der Person (pro Jahr, 7 J.)',
|
||||
|
|
@ -1862,6 +1847,7 @@ const de: Translations = {
|
|||
'Bus station': 'Busbahnhof',
|
||||
'Taxi rank': 'Taxistand',
|
||||
'Tube station': 'U-Bahn-Station',
|
||||
'DLR station': 'DLR-Station',
|
||||
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
|
|
|
|||
|
|
@ -620,9 +620,9 @@ const en = {
|
|||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'Find every matching postcode',
|
||||
title: 'Unlock unlimited filters',
|
||||
description:
|
||||
'You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, and every neighbourhood in England. One payment, forever.',
|
||||
'Free accounts can combine up to 3 filters at a time. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.',
|
||||
free: 'Free',
|
||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
||||
oneTimePayment: 'One-time payment. Lifetime access.',
|
||||
|
|
@ -631,10 +631,7 @@ const en = {
|
|||
upgradeFor: 'Upgrade for {{price}}',
|
||||
registerAndUpgrade: 'Register & Upgrade',
|
||||
alreadyHaveAccount: 'Already have an account? Log in',
|
||||
continueWithDemo: 'Continue with demo',
|
||||
backToSharedArea: 'Back to shared area',
|
||||
sharedAreaDescription:
|
||||
'You’re viewing a shared area. To explore beyond it, get lifetime access to every postcode, every filter, and every neighbourhood in England.',
|
||||
continueFree: 'Continue with 3 filters',
|
||||
checkoutFailed: 'Checkout failed',
|
||||
},
|
||||
|
||||
|
|
@ -972,15 +969,6 @@ const en = {
|
|||
geolocationFailed: 'Couldn’t determine your location',
|
||||
},
|
||||
|
||||
// ── Demo location prompt ───────────────────────────
|
||||
demoLocation: {
|
||||
title: 'Explore your area',
|
||||
body: 'Want to see property data around you? Use your location, or start the demo at Canary Wharf.',
|
||||
useLocation: 'Use my location',
|
||||
locating: 'Locating…',
|
||||
useDefault: 'Show London',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Close drawer',
|
||||
|
|
@ -1392,7 +1380,7 @@ const en = {
|
|||
'Lifetime access means one payment gives your account ongoing access to the paid Perfect Postcode map for as long as the service runs. It isn’t a monthly or annual subscription, and normal data updates are included. You can use it during this search, come back later, and still have access if you move again.',
|
||||
faqPricing3Q: 'What can I access on the free tier?',
|
||||
faqPricing3A:
|
||||
'Free users can explore all features within the demo area: inner London, roughly zones 1 to 2. To access data for the rest of England, you need lifetime access.',
|
||||
'Free users get the full Perfect Postcode map across all of England — every postcode and every feature — and can apply up to 3 filters at a time. Lifetime access removes that limit so you can stack unlimited filters.',
|
||||
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'How do I preview a filter on the map?',
|
||||
|
|
@ -1713,10 +1701,8 @@ const en = {
|
|||
'Serious crime (/yr, 2y)': 'Serious crime (per year, 2yr)',
|
||||
'Minor crime (/yr, 7y)': 'Minor crime (per year, 7yr)',
|
||||
'Minor crime (/yr, 2y)': 'Minor crime (per year, 2yr)',
|
||||
'Violence and sexual offences (/yr, 7y)':
|
||||
'Violence & sexual offences (per year, 7yr)',
|
||||
'Violence and sexual offences (/yr, 2y)':
|
||||
'Violence & sexual offences (per year, 2yr)',
|
||||
'Violence and sexual offences (/yr, 7y)': 'Violence & sexual offences (per year, 7yr)',
|
||||
'Violence and sexual offences (/yr, 2y)': 'Violence & sexual offences (per year, 2yr)',
|
||||
'Burglary (/yr, 7y)': 'Burglary (per year, 7yr)',
|
||||
'Burglary (/yr, 2y)': 'Burglary (per year, 2yr)',
|
||||
'Robbery (/yr, 7y)': 'Robbery (per year, 7yr)',
|
||||
|
|
@ -1845,6 +1831,7 @@ const en = {
|
|||
'Bus station': 'Bus station',
|
||||
'Taxi rank': 'Taxi rank',
|
||||
'Tube station': 'Tube station',
|
||||
'DLR station': 'DLR station',
|
||||
'Tram & Metro stop': 'Tram & Metro stop',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
|
|
|
|||
|
|
@ -645,9 +645,9 @@ const fr: Translations = {
|
|||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'Trouvez tous les codes postaux compatibles',
|
||||
title: 'Débloquez les filtres illimités',
|
||||
description:
|
||||
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à tous les codes postaux, tous les filtres et tous les quartiers d’Angleterre. Un seul paiement, pour toujours.',
|
||||
'Les comptes gratuits peuvent combiner jusqu’à 3 filtres à la fois. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur tous les codes postaux et tous les quartiers d’Angleterre. Un seul paiement, pour toujours.',
|
||||
free: 'Gratuit',
|
||||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
||||
oneTimePayment: 'Paiement unique. Accès à vie.',
|
||||
|
|
@ -656,10 +656,7 @@ const fr: Translations = {
|
|||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
||||
continueWithDemo: 'Continuer avec la démo',
|
||||
backToSharedArea: 'Retour au secteur partagé',
|
||||
sharedAreaDescription:
|
||||
'Vous consultez un secteur partagé. Pour explorer au-delà, obtenez un accès à vie à tous les codes postaux, tous les filtres et tous les quartiers d’Angleterre.',
|
||||
continueFree: 'Continuer avec 3 filtres',
|
||||
checkoutFailed: 'Le paiement a échoué',
|
||||
},
|
||||
|
||||
|
|
@ -996,14 +993,6 @@ const fr: Translations = {
|
|||
geolocationFailed: 'Impossible de déterminer votre position',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: 'Explorez votre quartier',
|
||||
body: 'Envie de voir les données immobilières autour de vous ? Utilisez votre position, ou commencez la démo à Canary Wharf.',
|
||||
useLocation: 'Utiliser ma position',
|
||||
locating: 'Localisation…',
|
||||
useDefault: 'Afficher Canary Wharf',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Fermer le tiroir',
|
||||
|
|
@ -1424,7 +1413,7 @@ const fr: Translations = {
|
|||
'L’accès à vie signifie qu’un paiement donne à votre compte un accès continu à la carte payante Perfect Postcode pendant la durée de vie du service. Ce n’est pas un abonnement mensuel ou annuel, et les mises à jour normales des données sont incluses. Vous pouvez l’utiliser pendant cette recherche, revenir plus tard et conserver l’accès si vous déménagez à nouveau.',
|
||||
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
|
||||
faqPricing3A:
|
||||
'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de l’Angleterre, il faut l’accès à vie.',
|
||||
'Les utilisateurs gratuits bénéficient de la carte Perfect Postcode complète sur toute l’Angleterre — tous les codes postaux et toutes les fonctionnalités — et peuvent appliquer jusqu’à 3 filtres à la fois. L’accès à vie supprime cette limite pour vous permettre de superposer un nombre illimité de filtres.',
|
||||
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'Comment prévisualiser un filtre sur la carte ?',
|
||||
|
|
@ -1751,48 +1740,32 @@ const fr: Translations = {
|
|||
'Serious crime (/yr, 2y)': 'Infractions graves (par an, 2 ans)',
|
||||
'Minor crime (/yr, 7y)': 'Infractions mineures (par an, 7 ans)',
|
||||
'Minor crime (/yr, 2y)': 'Infractions mineures (par an, 2 ans)',
|
||||
'Violence and sexual offences (/yr, 7y)':
|
||||
'Violences et infractions sexuelles (par an, 7 ans)',
|
||||
'Violence and sexual offences (/yr, 2y)':
|
||||
'Violences et infractions sexuelles (par an, 2 ans)',
|
||||
'Violence and sexual offences (/yr, 7y)': 'Violences et infractions sexuelles (par an, 7 ans)',
|
||||
'Violence and sexual offences (/yr, 2y)': 'Violences et infractions sexuelles (par an, 2 ans)',
|
||||
'Burglary (/yr, 7y)': 'Cambriolages (par an, 7 ans)',
|
||||
'Burglary (/yr, 2y)': 'Cambriolages (par an, 2 ans)',
|
||||
'Robbery (/yr, 7y)': 'Vols avec violence (par an, 7 ans)',
|
||||
'Robbery (/yr, 2y)': 'Vols avec violence (par an, 2 ans)',
|
||||
'Vehicle crime (/yr, 7y)':
|
||||
'Infractions liées aux véhicules (par an, 7 ans)',
|
||||
'Vehicle crime (/yr, 2y)':
|
||||
'Infractions liées aux véhicules (par an, 2 ans)',
|
||||
'Anti-social behaviour (/yr, 7y)':
|
||||
'Comportements antisociaux (par an, 7 ans)',
|
||||
'Anti-social behaviour (/yr, 2y)':
|
||||
'Comportements antisociaux (par an, 2 ans)',
|
||||
'Criminal damage and arson (/yr, 7y)':
|
||||
'Dégradations et incendies criminels (par an, 7 ans)',
|
||||
'Criminal damage and arson (/yr, 2y)':
|
||||
'Dégradations et incendies criminels (par an, 2 ans)',
|
||||
'Vehicle crime (/yr, 7y)': 'Infractions liées aux véhicules (par an, 7 ans)',
|
||||
'Vehicle crime (/yr, 2y)': 'Infractions liées aux véhicules (par an, 2 ans)',
|
||||
'Anti-social behaviour (/yr, 7y)': 'Comportements antisociaux (par an, 7 ans)',
|
||||
'Anti-social behaviour (/yr, 2y)': 'Comportements antisociaux (par an, 2 ans)',
|
||||
'Criminal damage and arson (/yr, 7y)': 'Dégradations et incendies criminels (par an, 7 ans)',
|
||||
'Criminal damage and arson (/yr, 2y)': 'Dégradations et incendies criminels (par an, 2 ans)',
|
||||
'Other theft (/yr, 7y)': 'Autres vols (par an, 7 ans)',
|
||||
'Other theft (/yr, 2y)': 'Autres vols (par an, 2 ans)',
|
||||
'Theft from the person (/yr, 7y)':
|
||||
'Vols à la personne (par an, 7 ans)',
|
||||
'Theft from the person (/yr, 2y)':
|
||||
'Vols à la personne (par an, 2 ans)',
|
||||
'Theft from the person (/yr, 7y)': 'Vols à la personne (par an, 7 ans)',
|
||||
'Theft from the person (/yr, 2y)': 'Vols à la personne (par an, 2 ans)',
|
||||
'Shoplifting (/yr, 7y)': 'Vols à l’étalage (par an, 7 ans)',
|
||||
'Shoplifting (/yr, 2y)': 'Vols à l’étalage (par an, 2 ans)',
|
||||
'Bicycle theft (/yr, 7y)': 'Vols de vélos (par an, 7 ans)',
|
||||
'Bicycle theft (/yr, 2y)': 'Vols de vélos (par an, 2 ans)',
|
||||
'Drugs (/yr, 7y)':
|
||||
'Infractions liées aux stupéfiants (par an, 7 ans)',
|
||||
'Drugs (/yr, 2y)':
|
||||
'Infractions liées aux stupéfiants (par an, 2 ans)',
|
||||
'Possession of weapons (/yr, 7y)':
|
||||
'Possession d’armes (par an, 7 ans)',
|
||||
'Possession of weapons (/yr, 2y)':
|
||||
'Possession d’armes (par an, 2 ans)',
|
||||
'Public order (/yr, 7y)':
|
||||
'Troubles à l’ordre public (par an, 7 ans)',
|
||||
'Public order (/yr, 2y)':
|
||||
'Troubles à l’ordre public (par an, 2 ans)',
|
||||
'Drugs (/yr, 7y)': 'Infractions liées aux stupéfiants (par an, 7 ans)',
|
||||
'Drugs (/yr, 2y)': 'Infractions liées aux stupéfiants (par an, 2 ans)',
|
||||
'Possession of weapons (/yr, 7y)': 'Possession d’armes (par an, 7 ans)',
|
||||
'Possession of weapons (/yr, 2y)': 'Possession d’armes (par an, 2 ans)',
|
||||
'Public order (/yr, 7y)': 'Troubles à l’ordre public (par an, 7 ans)',
|
||||
'Public order (/yr, 2y)': 'Troubles à l’ordre public (par an, 2 ans)',
|
||||
'Other crime (/yr, 7y)': 'Autres infractions (par an, 7 ans)',
|
||||
'Other crime (/yr, 2y)': 'Autres infractions (par an, 2 ans)',
|
||||
// Libellés de type d’infraction seuls (ventilation par type + liste des enregistrements).
|
||||
|
|
@ -1897,6 +1870,7 @@ const fr: Translations = {
|
|||
'Bus station': 'Gare routière',
|
||||
'Taxi rank': 'Station de taxi',
|
||||
'Tube station': 'Station de métro londonien',
|
||||
'DLR station': 'Station DLR',
|
||||
'Tram & Metro stop': 'Arrêt de tramway et métro',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
|
|
|
|||
|
|
@ -617,9 +617,9 @@ const hi: Translations = {
|
|||
},
|
||||
|
||||
upgrade: {
|
||||
title: 'हर मेल खाने वाला पोस्टकोड खोजें',
|
||||
title: 'असीमित फ़िल्टर अनलॉक करें',
|
||||
description:
|
||||
'आप अभी डेमो क्षेत्र देख रहे हैं. इंग्लैंड के हर पोस्टकोड, हर फ़िल्टर और हर पड़ोस की आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
||||
'मुफ्त खाते एक बार में 3 फ़िल्टर तक जोड़ सकते हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
||||
free: 'मुफ्त',
|
||||
freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.',
|
||||
oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.',
|
||||
|
|
@ -628,10 +628,7 @@ const hi: Translations = {
|
|||
upgradeFor: '{{price}} में अपग्रेड करें',
|
||||
registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें',
|
||||
alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें',
|
||||
continueWithDemo: 'डेमो जारी रखें',
|
||||
backToSharedArea: 'साझा क्षेत्र पर वापस जाएं',
|
||||
sharedAreaDescription:
|
||||
'आप एक साझा क्षेत्र देख रहे हैं. इससे आगे देखने के लिए इंग्लैंड के हर पोस्टकोड, हर फ़िल्टर और हर पड़ोस की आजीवन पहुँच लें.',
|
||||
continueFree: '3 फ़िल्टर के साथ जारी रखें',
|
||||
checkoutFailed: 'चेकआउट विफल रहा',
|
||||
},
|
||||
|
||||
|
|
@ -951,14 +948,6 @@ const hi: Translations = {
|
|||
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: 'अपना क्षेत्र देखें',
|
||||
body: 'क्या आप अपने आसपास की संपत्ति डेटा देखना चाहते हैं? अपना स्थान उपयोग करें, या Canary Wharf से डेमो शुरू करें।',
|
||||
useLocation: 'मेरा स्थान उपयोग करें',
|
||||
locating: 'स्थान खोजा जा रहा है…',
|
||||
useDefault: 'Canary Wharf दिखाएं',
|
||||
},
|
||||
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'ड्रॉअर बंद करें',
|
||||
},
|
||||
|
|
@ -1354,7 +1343,7 @@ const hi: Translations = {
|
|||
'आजीवन पहुँच का मतलब है कि एक भुगतान आपके खाते को सेवा चलने तक सशुल्क Perfect Postcode मानचित्र की लगातार पहुँच देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर स्थान बदलने पर भी पहुँच रहेगी.',
|
||||
faqPricing3Q: 'मुफ्त स्तर पर मैं क्या उपयोग कर सकता हूं?',
|
||||
faqPricing3A:
|
||||
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (इनर लंदन, लगभग जोन 1 से 2) के अंदर सभी सुविधाएं देख सकते हैं. इंग्लैंड के बाकी डेटा के लिए आजीवन पहुँच चाहिए.',
|
||||
'मुफ्त उपयोगकर्ताओं को पूरे इंग्लैंड में पूरा Perfect Postcode मानचित्र मिलता है — हर पोस्टकोड और हर सुविधा — और वे एक बार में 3 फ़िल्टर तक लगा सकते हैं. आजीवन पहुँच यह सीमा हटा देती है, ताकि आप असीमित फ़िल्टर लगा सकें.',
|
||||
faqTips1Q: 'मानचित्र पर फ़िल्टर पूर्वावलोकन कैसे करें?',
|
||||
faqTips1A:
|
||||
'किसी फ़िल्टर या सुविधा के पास रंगें पर क्लिक करें ताकि मानचित्र उसी मद से रंग जाए. आपके सक्रिय फ़िल्टर वैसे ही रहते हैं, इसलिए आप कीमत, आवागमन समय, स्कूल, अपराध या शोर जैसी एक चीज सूची बदले बिना तुलना कर सकते हैं.',
|
||||
|
|
@ -1650,10 +1639,8 @@ const hi: Translations = {
|
|||
'Serious crime (/yr, 2y)': 'गंभीर अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Minor crime (/yr, 7y)': 'मामूली अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
'Minor crime (/yr, 2y)': 'मामूली अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Violence and sexual offences (/yr, 7y)':
|
||||
'हिंसा और यौन अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
'Violence and sexual offences (/yr, 2y)':
|
||||
'हिंसा और यौन अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Violence and sexual offences (/yr, 7y)': 'हिंसा और यौन अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
'Violence and sexual offences (/yr, 2y)': 'हिंसा और यौन अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Burglary (/yr, 7y)': 'सेंधमारी (प्रति वर्ष, 7 वर्ष)',
|
||||
'Burglary (/yr, 2y)': 'सेंधमारी (प्रति वर्ष, 2 वर्ष)',
|
||||
'Robbery (/yr, 7y)': 'लूट (प्रति वर्ष, 7 वर्ष)',
|
||||
|
|
@ -1662,10 +1649,8 @@ const hi: Translations = {
|
|||
'Vehicle crime (/yr, 2y)': 'वाहन अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Anti-social behaviour (/yr, 7y)': 'असामाजिक व्यवहार (प्रति वर्ष, 7 वर्ष)',
|
||||
'Anti-social behaviour (/yr, 2y)': 'असामाजिक व्यवहार (प्रति वर्ष, 2 वर्ष)',
|
||||
'Criminal damage and arson (/yr, 7y)':
|
||||
'आपराधिक क्षति और आगजनी (प्रति वर्ष, 7 वर्ष)',
|
||||
'Criminal damage and arson (/yr, 2y)':
|
||||
'आपराधिक क्षति और आगजनी (प्रति वर्ष, 2 वर्ष)',
|
||||
'Criminal damage and arson (/yr, 7y)': 'आपराधिक क्षति और आगजनी (प्रति वर्ष, 7 वर्ष)',
|
||||
'Criminal damage and arson (/yr, 2y)': 'आपराधिक क्षति और आगजनी (प्रति वर्ष, 2 वर्ष)',
|
||||
'Other theft (/yr, 7y)': 'अन्य चोरी (प्रति वर्ष, 7 वर्ष)',
|
||||
'Other theft (/yr, 2y)': 'अन्य चोरी (प्रति वर्ष, 2 वर्ष)',
|
||||
'Theft from the person (/yr, 7y)': 'व्यक्ति से चोरी (प्रति वर्ष, 7 वर्ष)',
|
||||
|
|
@ -1676,10 +1661,8 @@ const hi: Translations = {
|
|||
'Bicycle theft (/yr, 2y)': 'साइकिल चोरी (प्रति वर्ष, 2 वर्ष)',
|
||||
'Drugs (/yr, 7y)': 'ड्रग्स (प्रति वर्ष, 7 वर्ष)',
|
||||
'Drugs (/yr, 2y)': 'ड्रग्स (प्रति वर्ष, 2 वर्ष)',
|
||||
'Possession of weapons (/yr, 7y)':
|
||||
'हथियार रखने के अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
'Possession of weapons (/yr, 2y)':
|
||||
'हथियार रखने के अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Possession of weapons (/yr, 7y)': 'हथियार रखने के अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
'Possession of weapons (/yr, 2y)': 'हथियार रखने के अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Public order (/yr, 7y)': 'सार्वजनिक व्यवस्था अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
'Public order (/yr, 2y)': 'सार्वजनिक व्यवस्था अपराध (प्रति वर्ष, 2 वर्ष)',
|
||||
'Other crime (/yr, 7y)': 'अन्य अपराध (प्रति वर्ष, 7 वर्ष)',
|
||||
|
|
@ -1771,6 +1754,7 @@ const hi: Translations = {
|
|||
'Bus station': 'बस स्टेशन',
|
||||
'Taxi rank': 'टैक्सी स्टैंड',
|
||||
'Tube station': 'ट्यूब स्टेशन',
|
||||
'DLR station': 'DLR स्टेशन',
|
||||
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
|
||||
Café: 'कैफे',
|
||||
Restaurant: 'रेस्तरां',
|
||||
|
|
|
|||
|
|
@ -636,9 +636,9 @@ const hu: Translations = {
|
|||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'Találd meg az összes megfelelő irányítószámot',
|
||||
title: 'Oldd fel a korlátlan szűrőket',
|
||||
description:
|
||||
'Jelenleg a demóterületet fedezed fel. Szerezz élethosszig tartó hozzáférést Anglia minden irányítószámához, minden szűrőjéhez és minden környékéhez. Egyetlen fizetés, örökre.',
|
||||
'Az ingyenes fiókok egyszerre legfeljebb 3 szűrőt kombinálhatnak. Szerezz élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán és környékén. Egyetlen fizetés, örökre.',
|
||||
free: 'Ingyenes',
|
||||
freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.',
|
||||
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.',
|
||||
|
|
@ -647,10 +647,7 @@ const hu: Translations = {
|
|||
upgradeFor: 'Teljes hozzáférés {{price}} áron',
|
||||
registerAndUpgrade: 'Regisztráció és teljes hozzáférés',
|
||||
alreadyHaveAccount: 'Már van fiókod? Jelentkezz be',
|
||||
continueWithDemo: 'Folytatás demóval',
|
||||
backToSharedArea: 'Vissza a megosztott területre',
|
||||
sharedAreaDescription:
|
||||
'Egy megosztott területet nézel. Ha tovább szeretnél felfedezni, szerezz élethosszig tartó hozzáférést Anglia minden irányítószámához, szűrőjéhez és környékéhez.',
|
||||
continueFree: 'Folytatás 3 szűrővel',
|
||||
checkoutFailed: 'A fizetés sikertelen',
|
||||
},
|
||||
|
||||
|
|
@ -984,14 +981,6 @@ const hu: Translations = {
|
|||
geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: 'Fedezd fel a környékedet',
|
||||
body: 'Szeretnél ingatlanadatokat látni a környékeden? Használd a helyzeted, vagy indítsd a demót a Canary Wharfnál.',
|
||||
useLocation: 'Saját helyzet használata',
|
||||
locating: 'Helymeghatározás…',
|
||||
useDefault: 'Canary Wharf megjelenítése',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Panel bezárása',
|
||||
|
|
@ -1406,7 +1395,7 @@ const hu: Translations = {
|
|||
'Az élethosszig tartó hozzáférés azt jelenti, hogy egy fizetéssel a fiókod folyamatos hozzáférést kap a fizetős Perfect Postcode térképhez a szolgáltatás élettartamára. Ez nem havi vagy éves előfizetés, és a szokásos adatfrissítések benne vannak. Használhatod a mostani kereséshez, később visszatérhetsz, és akkor is hozzáférsz, ha újra költözöl.',
|
||||
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
|
||||
faqPricing3A:
|
||||
'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
|
||||
'Az ingyenes felhasználók a teljes Perfect Postcode térképet megkapják egész Angliában — minden irányítószámot és minden funkciót —, és egyszerre legfeljebb 3 szűrőt alkalmazhatnak. Az élethosszig tartó hozzáférés feloldja ezt a korlátot, így korlátlanul rétegezheted a szűrőket.',
|
||||
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'Hogyan nézhetek meg egy szűrőt a térképen?',
|
||||
|
|
@ -1730,24 +1719,18 @@ const hu: Translations = {
|
|||
'Serious crime (/yr, 2y)': 'Súlyos bűncselekmény (évente, 2 év)',
|
||||
'Minor crime (/yr, 7y)': 'Kisebb bűncselekmény (évente, 7 év)',
|
||||
'Minor crime (/yr, 2y)': 'Kisebb bűncselekmény (évente, 2 év)',
|
||||
'Violence and sexual offences (/yr, 7y)':
|
||||
'Erőszak és szexuális bűncselekmények (évente, 7 év)',
|
||||
'Violence and sexual offences (/yr, 2y)':
|
||||
'Erőszak és szexuális bűncselekmények (évente, 2 év)',
|
||||
'Violence and sexual offences (/yr, 7y)': 'Erőszak és szexuális bűncselekmények (évente, 7 év)',
|
||||
'Violence and sexual offences (/yr, 2y)': 'Erőszak és szexuális bűncselekmények (évente, 2 év)',
|
||||
'Burglary (/yr, 7y)': 'Betörés (évente, 7 év)',
|
||||
'Burglary (/yr, 2y)': 'Betörés (évente, 2 év)',
|
||||
'Robbery (/yr, 7y)': 'Rablás (évente, 7 év)',
|
||||
'Robbery (/yr, 2y)': 'Rablás (évente, 2 év)',
|
||||
'Vehicle crime (/yr, 7y)':
|
||||
'Járművel kapcsolatos bűncselekmények (évente, 7 év)',
|
||||
'Vehicle crime (/yr, 2y)':
|
||||
'Járművel kapcsolatos bűncselekmények (évente, 2 év)',
|
||||
'Vehicle crime (/yr, 7y)': 'Járművel kapcsolatos bűncselekmények (évente, 7 év)',
|
||||
'Vehicle crime (/yr, 2y)': 'Járművel kapcsolatos bűncselekmények (évente, 2 év)',
|
||||
'Anti-social behaviour (/yr, 7y)': 'Közösségellenes magatartás (évente, 7 év)',
|
||||
'Anti-social behaviour (/yr, 2y)': 'Közösségellenes magatartás (évente, 2 év)',
|
||||
'Criminal damage and arson (/yr, 7y)':
|
||||
'Rongálás és gyújtogatás (évente, 7 év)',
|
||||
'Criminal damage and arson (/yr, 2y)':
|
||||
'Rongálás és gyújtogatás (évente, 2 év)',
|
||||
'Criminal damage and arson (/yr, 7y)': 'Rongálás és gyújtogatás (évente, 7 év)',
|
||||
'Criminal damage and arson (/yr, 2y)': 'Rongálás és gyújtogatás (évente, 2 év)',
|
||||
'Other theft (/yr, 7y)': 'Egyéb lopás (évente, 7 év)',
|
||||
'Other theft (/yr, 2y)': 'Egyéb lopás (évente, 2 év)',
|
||||
'Theft from the person (/yr, 7y)': 'Személytől történő lopás (évente, 7 év)',
|
||||
|
|
@ -1866,6 +1849,7 @@ const hu: Translations = {
|
|||
'Bus station': 'Buszpályaudvar',
|
||||
'Taxi rank': 'Taxiállomás',
|
||||
'Tube station': 'Londoni metróállomás',
|
||||
'DLR station': 'DLR-állomás',
|
||||
'Tram & Metro stop': 'Villamos- és metrómegálló',
|
||||
Café: 'Kávézó',
|
||||
Restaurant: 'Étterem',
|
||||
|
|
|
|||
|
|
@ -584,9 +584,9 @@ const zh: Translations = {
|
|||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: '找到每一个匹配的邮编',
|
||||
title: '解锁无限筛选',
|
||||
description:
|
||||
'您目前正在浏览演示区域。获取终身访问权限后,可查看英格兰每个邮编、每项筛选条件和每个社区。一次付款,永久使用。',
|
||||
'免费账户每次最多可同时组合 3 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。',
|
||||
free: '免费',
|
||||
freeForEarly: '早期用户免费。无需信用卡。',
|
||||
oneTimePayment: '一次性付款。终身访问。',
|
||||
|
|
@ -595,10 +595,7 @@ const zh: Translations = {
|
|||
upgradeFor: '以 {{price}} 升级',
|
||||
registerAndUpgrade: '注册并升级',
|
||||
alreadyHaveAccount: '已有账户?请登录',
|
||||
continueWithDemo: '继续使用演示版',
|
||||
backToSharedArea: '返回共享区域',
|
||||
sharedAreaDescription:
|
||||
'您正在查看一个共享区域。若要继续探索更大范围,请获取终身访问权限,覆盖英格兰每个邮编、每项筛选条件和每个社区。',
|
||||
continueFree: '继续使用 3 个筛选条件',
|
||||
checkoutFailed: '结账失败',
|
||||
},
|
||||
|
||||
|
|
@ -922,14 +919,6 @@ const zh: Translations = {
|
|||
geolocationFailed: '无法确定您的位置',
|
||||
},
|
||||
|
||||
demoLocation: {
|
||||
title: '探索您的区域',
|
||||
body: '想查看您周围的房产数据吗?使用您的位置,或从金丝雀码头开始演示。',
|
||||
useLocation: '使用我的位置',
|
||||
locating: '正在定位…',
|
||||
useDefault: '显示金丝雀码头',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: '关闭侧栏',
|
||||
|
|
@ -1329,7 +1318,7 @@ const zh: Translations = {
|
|||
'终身访问就是一次付款后,您的账户在 Perfect Postcode 服务存续期间都能持续访问付费地图。它不是按月或按年订阅,且涵盖后续数据更新。本次找房可以用,事后回来再看也行;将来再次搬家时,访问权限依然有效。',
|
||||
faqPricing3Q: '免费版能用哪些功能?',
|
||||
faqPricing3A:
|
||||
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内体验全部功能。要访问英格兰其他地区的数据,则需获取终身访问权限。',
|
||||
'免费用户可以使用覆盖整个英格兰的完整 Perfect Postcode 地图——每个邮编、每项功能——并且每次最多可应用 3 个筛选条件。获取终身访问权限即可解除该限制,让您叠加无限多个筛选条件。',
|
||||
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: '如何在地图上预览筛选条件?',
|
||||
|
|
@ -1775,6 +1764,7 @@ const zh: Translations = {
|
|||
'Bus station': '公交枢纽',
|
||||
'Taxi rank': '出租车站',
|
||||
'Tube station': '伦敦地铁站',
|
||||
'DLR station': 'DLR 轻轨站',
|
||||
'Tram & Metro stop': '有轨电车与城市轨道站',
|
||||
Café: '咖啡馆',
|
||||
Restaurant: '餐厅',
|
||||
|
|
|
|||
|
|
@ -41,79 +41,9 @@ export function authHeaders(init?: RequestInit): RequestInit {
|
|||
return { ...init, headers: { ...existing, ...headers } };
|
||||
}
|
||||
|
||||
// --- Demo location -----------------------------------------------------------
|
||||
// The visitor's chosen demo centre (their GPS location, with consent) is sent to
|
||||
// the server on every API request as demoLat/demoLon so the server can build the
|
||||
// free-zone box around it. Held module-side and injected by apiUrl(), so every
|
||||
// gated data call carries it without threading it through each hook. Unset when
|
||||
// the visitor declines (the server then defaults to Canary Wharf).
|
||||
|
||||
const DEMO_CENTER_KEY = 'demoCenter';
|
||||
|
||||
export type DemoCenter = { lat: number; lon: number };
|
||||
|
||||
let demoCenter: DemoCenter | null = null;
|
||||
|
||||
/** Endpoints whose access is gated by the demo free zone — only these need the
|
||||
* visitor's demo centre, so we keep the coarse location out of every other URL. */
|
||||
const DEMO_GATED_ENDPOINTS = new Set([
|
||||
'hexagons',
|
||||
'postcodes',
|
||||
'filter-counts',
|
||||
'hexagon-stats',
|
||||
'postcode-stats',
|
||||
'postcode-properties',
|
||||
'hexagon-properties',
|
||||
'crime-records',
|
||||
'journey',
|
||||
'actual-listings',
|
||||
'developments',
|
||||
'export',
|
||||
]);
|
||||
|
||||
/** Set (or clear) the demo centre sent on subsequent gated API requests. */
|
||||
export function setDemoCenter(center: DemoCenter | null): void {
|
||||
demoCenter = center;
|
||||
}
|
||||
|
||||
/** Persist the visitor's demo-location choice for this browser session. */
|
||||
export function persistDemoChoice(value: DemoCenter | 'declined'): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
DEMO_CENTER_KEY,
|
||||
value === 'declined' ? 'declined' : JSON.stringify(value)
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / SSR) — re-prompting is fine.
|
||||
}
|
||||
}
|
||||
|
||||
/** Read this session's demo-location choice: coords, 'declined', or null (unset). */
|
||||
export function readDemoChoice(): DemoCenter | 'declined' | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DEMO_CENTER_KEY);
|
||||
if (!raw) return null;
|
||||
if (raw === 'declined') return 'declined';
|
||||
const parsed = JSON.parse(raw) as Partial<DemoCenter>;
|
||||
if (typeof parsed?.lat === 'number' && typeof parsed?.lon === 'number') {
|
||||
return { lat: parsed.lat, lon: parsed.lon };
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed/unavailable storage.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||
const merged = new URLSearchParams(params);
|
||||
if (demoCenter && DEMO_GATED_ENDPOINTS.has(endpoint)) {
|
||||
// Coarsen to ~1 km (the demo free-zone box is ~25 km wide) so a precise location
|
||||
// never reaches the server or its access logs.
|
||||
merged.set('demoLat', demoCenter.lat.toFixed(2));
|
||||
merged.set('demoLon', demoCenter.lon.toFixed(2));
|
||||
}
|
||||
const query = merged.toString();
|
||||
const query = params?.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,11 @@ export const MAP_MIN_ZOOM = 5.5;
|
|||
|
||||
export const BUFFER_MULTIPLIER = 1;
|
||||
|
||||
/**
|
||||
* Default demo centre (Canary Wharf). Used as the initial map view and the demo
|
||||
* free-zone centre when the visitor declines the "check your area" prompt. When
|
||||
* they consent, their GPS location is used instead (see lib/api `setDemoCenter`).
|
||||
* Keep in sync with FREE_ZONE_FALLBACK_LAT/LON in the Rust server.
|
||||
*/
|
||||
/** Default initial map view (Canary Wharf), used when no explicit view is in the URL. */
|
||||
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
|
||||
|
||||
/** 10 Downing Street — geolocation fallback in dev so the GPS flows stay testable
|
||||
* without a real fix (e.g. over http, where browsers deny geolocation). */
|
||||
/** 10 Downing Street — geolocation fallback in dev so the "use my location" flow
|
||||
* stays testable without a real fix (e.g. over http, where browsers deny geolocation). */
|
||||
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
|
||||
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
|
|
@ -32,8 +27,22 @@ export const INITIAL_VIEW_STATE: ViewState = {
|
|||
pitch: 0,
|
||||
};
|
||||
|
||||
/** Demo (unlicensed) users can apply at most this many filters simultaneously. */
|
||||
export const DEMO_MAX_FILTERS = 5;
|
||||
/** Anonymous (logged-out) users can apply at most this many filters at a time. The
|
||||
* full dashboard is otherwise available everywhere — there is no region lock.
|
||||
* Must match the server-side `DEMO_MAX_FILTERS`. */
|
||||
export const DEMO_MAX_FILTERS = 3;
|
||||
|
||||
/** Registered but non-paying users get a higher filter allowance than anonymous
|
||||
* visitors — the carrot for creating a free account. Paying/licensed users are
|
||||
* unlimited. Must match the server-side `REGISTERED_MAX_FILTERS`. */
|
||||
export const REGISTERED_MAX_FILTERS = 5;
|
||||
|
||||
/** The simultaneous-filter cap for a user, or `null` for unlimited (paying/admin).
|
||||
* Anonymous → 3, registered free → 5. */
|
||||
export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): number | null {
|
||||
if (filtersUnlimited) return null;
|
||||
return isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom to H3 resolution mapping thresholds.
|
||||
|
|
@ -205,6 +214,7 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
|||
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
|
||||
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
|
||||
'DLR station': '/assets/poi-icons/public_transport/london_dlr.svg',
|
||||
'Rail station': '/assets/twemoji/1f686.png',
|
||||
School: '/assets/twemoji/1f3eb.png',
|
||||
'Nursery school': '/assets/twemoji/1f9f8.png',
|
||||
|
|
|
|||
|
|
@ -37,12 +37,8 @@ describe('specific-crime window helpers', () => {
|
|||
});
|
||||
|
||||
it('swaps a feature name between windows and round-trips', () => {
|
||||
expect(withSpecificCrimeWindow('Burglary (/yr, 7y)', '2y')).toBe(
|
||||
'Burglary (/yr, 2y)'
|
||||
);
|
||||
expect(withSpecificCrimeWindow('Burglary (/yr, 2y)', '7y')).toBe(
|
||||
'Burglary (/yr, 7y)'
|
||||
);
|
||||
expect(withSpecificCrimeWindow('Burglary (/yr, 7y)', '2y')).toBe('Burglary (/yr, 2y)');
|
||||
expect(withSpecificCrimeWindow('Burglary (/yr, 2y)', '7y')).toBe('Burglary (/yr, 7y)');
|
||||
// Unrecognized names pass through untouched.
|
||||
expect(withSpecificCrimeWindow('Burglary', '2y')).toBe('Burglary');
|
||||
expect(specificCrimeFeatureName('Drugs', '2y')).toBe('Drugs (/yr, 2y)');
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ export function getSpecificCrimeType(featureName: string): string | null {
|
|||
}
|
||||
|
||||
/** The same crime type's feature name in a different window (no-op if unrecognized). */
|
||||
export function withSpecificCrimeWindow(
|
||||
featureName: string,
|
||||
window: SpecificCrimeWindow
|
||||
): string {
|
||||
export function withSpecificCrimeWindow(featureName: string, window: SpecificCrimeWindow): string {
|
||||
const type = getSpecificCrimeType(featureName);
|
||||
return type ? specificCrimeFeatureName(type, window) : featureName;
|
||||
}
|
||||
|
|
@ -182,7 +179,6 @@ export const SPECIFIC_CRIME_VARIANT_CONFIG: VariantFilterConfig = {
|
|||
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
|
||||
],
|
||||
getWindow: getSpecificCrimeWindow,
|
||||
withWindow: (name, windowId) =>
|
||||
withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
|
||||
withWindow: (name, windowId) => withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,10 +44,7 @@ describe('crime-severity filter keys', () => {
|
|||
});
|
||||
|
||||
it('defaults to the 7-year window when present', () => {
|
||||
const features = [
|
||||
numeric('Serious crime (/yr, 7y)'),
|
||||
numeric('Serious crime (/yr, 2y)'),
|
||||
];
|
||||
const features = [numeric('Serious crime (/yr, 7y)'), numeric('Serious crime (/yr, 2y)')];
|
||||
expect(getDefaultCrimeSeverityFeatureName(features, 'Serious crime')).toBe(
|
||||
'Serious crime (/yr, 7y)'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,14 +28,18 @@ export type CrimeSeverityFilterName = (typeof CRIME_SEVERITY_FILTER_NAMES)[numbe
|
|||
|
||||
const CRIME_SEVERITY_TYPE_SET = new Set<string>(CRIME_SEVERITY_FILTER_NAMES);
|
||||
|
||||
const CRIME_SEVERITY_DETAILS: Record<CrimeSeverityFilterName, { description: string; detail: string }> = {
|
||||
const CRIME_SEVERITY_DETAILS: Record<
|
||||
CrimeSeverityFilterName,
|
||||
{ description: string; detail: string }
|
||||
> = {
|
||||
[SERIOUS_CRIME_FILTER_NAME]: {
|
||||
description: 'Violence, robbery, burglary and weapons possession near the postcode',
|
||||
detail:
|
||||
'Combined count of the more serious street-level categories (violence and sexual offences, robbery, burglary, possession of weapons), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
||||
},
|
||||
[MINOR_CRIME_FILTER_NAME]: {
|
||||
description: 'Anti-social behaviour, theft, criminal damage, drugs and public order near the postcode',
|
||||
description:
|
||||
'Anti-social behaviour, theft, criminal damage, drugs and public order near the postcode',
|
||||
detail:
|
||||
'Combined count of the lower-severity street-level categories (anti-social behaviour, theft, criminal damage and arson, drugs, public order), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { POI } from '../types';
|
|||
import {
|
||||
formatStationDistance,
|
||||
selectNearbyStations,
|
||||
stationDisplayName,
|
||||
stationSearchBounds,
|
||||
} from './nearby-stations';
|
||||
|
||||
|
|
@ -53,6 +54,34 @@ describe('selectNearbyStations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('stationDisplayName', () => {
|
||||
it('strips the mode descriptor so the name matches the category subtitle', () => {
|
||||
// Interchange POIs categorised "Tube station" but named from the DLR node.
|
||||
expect(stationDisplayName('Bank DLR Station')).toBe('Bank');
|
||||
expect(stationDisplayName('Canning Town DLR Station')).toBe('Canning Town');
|
||||
expect(stationDisplayName('West Ham DLR Station')).toBe('West Ham');
|
||||
expect(stationDisplayName('Bank Underground Station')).toBe('Bank');
|
||||
});
|
||||
|
||||
it('drops the redundant mode word from single-mode station names', () => {
|
||||
expect(stationDisplayName('Beckton DLR Station')).toBe('Beckton');
|
||||
expect(stationDisplayName('Shadwell DLR')).toBe('Shadwell');
|
||||
expect(stationDisplayName('Greenwich Rail Station')).toBe('Greenwich');
|
||||
expect(stationDisplayName('Stratford Station')).toBe('Stratford');
|
||||
});
|
||||
|
||||
it('keeps disambiguating qualifiers and never empties the name', () => {
|
||||
expect(stationDisplayName('Cutty Sark (for Maritime Greenwich) DLR Station')).toBe(
|
||||
'Cutty Sark (for Maritime Greenwich)'
|
||||
);
|
||||
expect(stationDisplayName('Stratford International DLR Station')).toBe(
|
||||
'Stratford International'
|
||||
);
|
||||
expect(stationDisplayName('West Ham')).toBe('West Ham');
|
||||
expect(stationDisplayName('Station')).toBe('Station');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stationSearchBounds', () => {
|
||||
it('builds a box around the origin', () => {
|
||||
const bounds = stationSearchBounds({ lat: 51.5, lon: -0.1 });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Bounds, POI } from '../types';
|
||||
|
||||
export const STATION_CATEGORIES = ['Rail station', 'Tube station'] as const;
|
||||
export const STATION_CATEGORIES = ['Rail station', 'Tube station', 'DLR station'] as const;
|
||||
export const STATION_SEARCH_RADIUS_KM = 2;
|
||||
const PRIMARY_STATION_RADIUS_KM = 1;
|
||||
const FALLBACK_STATION_LIMIT = 3;
|
||||
|
|
@ -69,3 +69,26 @@ export function formatStationDistance(distanceKmValue: number): string {
|
|||
|
||||
return `${distanceKmValue.toFixed(1)}km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing transit-mode descriptor (e.g. " DLR Station", " Underground
|
||||
* Station", " Rail Station", " Station") from a NaPTAN station name so the row
|
||||
* reads as the bare place and the category subtitle alone conveys the mode.
|
||||
*
|
||||
* At Tube/DLR interchanges (Bank, Canning Town, Stratford, …) NaPTAN merges the
|
||||
* Underground and DLR nodes into one POI whose category resolves to "Tube
|
||||
* station" but whose name was taken from the DLR node — so the raw name "Bank
|
||||
* DLR Station" would otherwise sit under a "Tube station" label. Dropping the
|
||||
* mode word removes both that contradiction and the everyday redundancy of
|
||||
* "Beckton DLR Station" shown above a "DLR station" subtitle.
|
||||
*/
|
||||
export function stationDisplayName(name: string): string {
|
||||
const stripped = name
|
||||
.replace(
|
||||
/\s+(?:DLR|Underground|Tube|Overground|Railway|Rail|Metro|Tramlink|Tram)(?:\s+(?:Station|Stop))?$/i,
|
||||
''
|
||||
)
|
||||
.replace(/\s+(?:Station|Stop)$/i, '')
|
||||
.trim();
|
||||
return stripped || name;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ export const OVERLAYS: OverlayDefinition[] = [
|
|||
description: 'Approximate police.uk street-crime heatmap',
|
||||
detail:
|
||||
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'trees-outside-woodlands',
|
||||
|
|
@ -47,6 +46,7 @@ export const OVERLAYS: OverlayDefinition[] = [
|
|||
description: 'Individual freehold property/land-parcel boundaries',
|
||||
detail:
|
||||
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'new-developments',
|
||||
|
|
@ -54,6 +54,7 @@ export const OVERLAYS: OverlayDefinition[] = [
|
|||
description: 'Planned new-home sites (brownfield register + Homes England)',
|
||||
detail:
|
||||
'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const TRANSPORT_POI_CATEGORIES = new Set([
|
|||
'Airport',
|
||||
'Bus station',
|
||||
'Bus stop',
|
||||
'DLR station',
|
||||
'Ferry',
|
||||
'Rail station',
|
||||
'Taxi rank',
|
||||
|
|
|
|||
|
|
@ -438,11 +438,7 @@ describe('url-state', () => {
|
|||
});
|
||||
|
||||
it('round-trips serious/minor crime severity filters with dedicated URL params', () => {
|
||||
const serious = createCrimeSeverityFilterKey(
|
||||
'Serious crime',
|
||||
'Serious crime (/yr, 7y)',
|
||||
0
|
||||
);
|
||||
const serious = createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0);
|
||||
const minor = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1);
|
||||
|
||||
const params = stateToParams(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue