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>
|
||||
|
|
@ -743,8 +747,8 @@ export default function AreaPane({
|
|||
{(() => {
|
||||
const stackedFeatureNames = new Set<string>(
|
||||
stackedCharts?.flatMap((c) =>
|
||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
||||
Boolean(s)
|
||||
[c.feature, c.rateFeature, ...c.components].filter(
|
||||
(s): s is string => Boolean(s)
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
|
|
@ -883,7 +887,10 @@ export default function AreaPane({
|
|||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -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,14 +1021,10 @@ 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 ? (
|
||||
// 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}
|
||||
|
|
@ -1097,36 +1032,14 @@ export default function MapPage({
|
|||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick
|
||||
? onCheckoutRegisterClick(checkoutReturnPath)
|
||||
: onRegisterClick()
|
||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={
|
||||
showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
|
||||
}
|
||||
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
|
||||
onClose={() => setFilterLimitHit(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
const demoLocationPrompt = demoPromptOpen ? (
|
||||
<DemoLocationPrompt
|
||||
locating={demoLocating}
|
||||
error={demoLocationError}
|
||||
onUseLocation={handleUseMyLocation}
|
||||
onUseDefault={handleUseDefaultLocation}
|
||||
/>
|
||||
) : 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,7 +54,8 @@ export function useAuth() {
|
|||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -68,9 +69,12 @@ export function useAuth() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const register = useCallback(async (email: string, password: string) => {
|
||||
const register = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -91,9 +95,12 @@ export function useAuth() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const loginWithOAuth = useCallback(async (provider: string) => {
|
||||
const loginWithOAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -110,7 +117,9 @@ export function useAuth() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
trackEvent('Logout');
|
||||
|
|
@ -118,7 +127,8 @@ export function useAuth() {
|
|||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const requestPasswordReset = useCallback(async (email: string) => {
|
||||
const requestPasswordReset = useCallback(
|
||||
async (email: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -130,7 +140,9 @@ export function useAuth() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[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,7 +8,8 @@ export function useLicense() {
|
|||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startCheckout = useCallback(async (returnPath?: string) => {
|
||||
const startCheckout = useCallback(
|
||||
async (returnPath?: string) => {
|
||||
trackEvent('Checkout Start', { has_referral: 'false' });
|
||||
setCheckingOut(true);
|
||||
setError(null);
|
||||
|
|
@ -33,7 +34,9 @@ export function useLicense() {
|
|||
} 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,7 +105,8 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
const handleClusterHover = useCallback(
|
||||
(info: PickingInfo<ClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
|
|
@ -121,7 +122,9 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ export function useSavedSearches(userId: string | null) {
|
|||
[userId, fetchSearches, t]
|
||||
);
|
||||
|
||||
const deleteSearch = useCallback(async (id: string) => {
|
||||
const deleteSearch = useCallback(
|
||||
async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
|
|
@ -190,25 +191,33 @@ export function useSavedSearches(userId: string | null) {
|
|||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
|
||||
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]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const updateSearchName = useCallback(async (id: string, name: string) => {
|
||||
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]);
|
||||
},
|
||||
[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(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import polars as pl
|
|||
|
||||
NAPTAN_CSV_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
|
||||
TUBE_STATION_CATEGORY = "Tube station"
|
||||
DLR_STATION_CATEGORY = "DLR station"
|
||||
TRAM_METRO_CATEGORY = "Tram & Metro stop"
|
||||
TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
|
||||
|
||||
|
|
@ -24,6 +25,13 @@ TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
|
|||
# WM Metro, Blackpool Tramway, heritage railways, ...).
|
||||
LONDON_UNDERGROUND_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZLU"
|
||||
|
||||
# The Docklands Light Railway uses the analogous "ZZDL" system code (e.g.
|
||||
# "9400ZZDLBEC" for Beckton). Like ZZLU it is unique to one network, so a
|
||||
# TMU/MET stop carrying a ZZDL code is reclassified from the tram/metro family
|
||||
# to its own "DLR station" category — restoring DLR to the train/tube station
|
||||
# list and giving it the DLR roundel rather than a generic tram icon.
|
||||
LONDON_DLR_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZDL"
|
||||
|
||||
|
||||
STOP_TYPES = {
|
||||
"AIR": "Airport",
|
||||
|
|
@ -48,10 +56,11 @@ STOP_TYPES = {
|
|||
"TXR": "Taxi rank",
|
||||
# Tram/Metro/Underground: TMU is an entrance node, MET the station access
|
||||
# area. Both start as "Tram & Metro stop"; merged stations whose ATCO codes
|
||||
# mark them as London Underground (ZZLU) are reclassified to "Tube station"
|
||||
# after dedup (see _deduplicate_station_areas). Heritage railways (RHDR,
|
||||
# Severn Valley, ...) are TMU/MET in NaPTAN with no machine-readable
|
||||
# "heritage" flag, so they remain in "Tram & Metro stop".
|
||||
# mark them as London Underground (ZZLU) or Docklands Light Railway (ZZDL)
|
||||
# are reclassified to "Tube station" / "DLR station" after dedup (see
|
||||
# _deduplicate_station_areas). Heritage railways (RHDR, Severn Valley, ...)
|
||||
# are TMU/MET in NaPTAN with no machine-readable "heritage" flag, so they
|
||||
# remain in "Tram & Metro stop".
|
||||
"TMU": TRAM_METRO_CATEGORY,
|
||||
"MET": TRAM_METRO_CATEGORY,
|
||||
}
|
||||
|
|
@ -68,6 +77,7 @@ ENTRANCE_STOP_TYPES = {"RSE", "FTD", "TMU", "BCE"}
|
|||
STATION_MERGE_CATEGORIES = {
|
||||
TRAM_METRO_CATEGORY,
|
||||
TUBE_STATION_CATEGORY,
|
||||
DLR_STATION_CATEGORY,
|
||||
"Rail station",
|
||||
"Ferry",
|
||||
"Bus station",
|
||||
|
|
@ -266,6 +276,7 @@ class StationAccumulator:
|
|||
lng_sum: float
|
||||
entrance: bool = False
|
||||
is_lu: bool = False
|
||||
is_dlr: bool = False
|
||||
count: int = 1
|
||||
qualifier: str = ""
|
||||
|
||||
|
|
@ -292,6 +303,7 @@ class StationAccumulator:
|
|||
self.lng_sum += float(row["lng"])
|
||||
self.count += 1
|
||||
self.is_lu = self.is_lu or bool(row.get("is_lu"))
|
||||
self.is_dlr = self.is_dlr or bool(row.get("is_dlr"))
|
||||
|
||||
name = str(row["name"] or "")
|
||||
row_qualifier = station_name_qualifier(name)
|
||||
|
|
@ -318,12 +330,19 @@ class StationAccumulator:
|
|||
|
||||
@property
|
||||
def output_category(self) -> str:
|
||||
# A merged tram/metro station is a genuine Tube station when ANY of its
|
||||
# constituent nodes carries a London Underground ATCO code. Checking
|
||||
# the whole group (not just the winning node) matters because LU
|
||||
# entrance nodes often carry non-ZZLU codes (e.g. 4900VICT...).
|
||||
# A merged tram/metro station is a genuine Tube/DLR station when ANY of
|
||||
# its constituent nodes carries the matching ATCO system code. Checking
|
||||
# the whole group (not just the winning node) matters because LU/DLR
|
||||
# entrance nodes often carry non-ZZLU/ZZDL codes (e.g. 4900VICT...).
|
||||
# A single node is never both (ZZLU vs ZZDL), but a co-located
|
||||
# interchange (Bank, Stratford, Canning Town, West Ham) merges its LU
|
||||
# and DLR halves into one group carrying both flags; Tube is checked
|
||||
# first so these resolve to "Tube station" — their primary identity —
|
||||
# leaving "DLR station" for the DLR-only stops the fix targets.
|
||||
if self.category == TRAM_METRO_CATEGORY and self.is_lu:
|
||||
return TUBE_STATION_CATEGORY
|
||||
if self.category == TRAM_METRO_CATEGORY and self.is_dlr:
|
||||
return DLR_STATION_CATEGORY
|
||||
return self.category
|
||||
|
||||
|
||||
|
|
@ -336,6 +355,7 @@ def _station_from_row(row: dict[str, object]) -> StationAccumulator:
|
|||
lng_sum=float(row["lng"]),
|
||||
entrance=bool(row.get("entrance")),
|
||||
is_lu=bool(row.get("is_lu")),
|
||||
is_dlr=bool(row.get("is_dlr")),
|
||||
qualifier=station_name_qualifier(str(row["name"] or "")),
|
||||
)
|
||||
|
||||
|
|
@ -423,7 +443,8 @@ def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
|
|||
station by normalized name + area, with the primary station/terminal node
|
||||
(e.g. RLY, FER, MET, BST) winning over an entrance node (RSE, FTD, TMU,
|
||||
BCE). Merged tram/metro stations with a London Underground ATCO code in
|
||||
the group become "Tube station". Other stops are deduplicated by exact
|
||||
the group become "Tube station"; those with a Docklands Light Railway code
|
||||
become "DLR station". Other stops are deduplicated by exact
|
||||
name+category+locality.
|
||||
"""
|
||||
station = df.filter(pl.col("category").is_in(list(STATION_MERGE_CATEGORIES)))
|
||||
|
|
@ -490,6 +511,10 @@ def download_naptan(output: Path) -> None:
|
|||
.str.contains(LONDON_UNDERGROUND_ATCO_PATTERN)
|
||||
.fill_null(False)
|
||||
.alias("is_lu"),
|
||||
pl.col("ATCOCode")
|
||||
.str.contains(LONDON_DLR_ATCO_PATTERN)
|
||||
.fill_null(False)
|
||||
.alias("is_dlr"),
|
||||
)
|
||||
|
||||
before = len(df)
|
||||
|
|
|
|||
|
|
@ -651,7 +651,12 @@ def _naptan_dlr_stations(naptan_path: Path) -> list[dict]:
|
|||
match = _DLR_CODE_RE.search(atco_id)
|
||||
if not match:
|
||||
continue
|
||||
if row["category"] not in {"Tube station", "Tram & Metro stop", "Rail station"}:
|
||||
if row["category"] not in {
|
||||
"Tube station",
|
||||
"DLR station",
|
||||
"Tram & Metro stop",
|
||||
"Rail station",
|
||||
}:
|
||||
continue
|
||||
|
||||
code = match.group(1)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import polars as pl
|
|||
import pytest
|
||||
|
||||
from pipeline.download.naptan import (
|
||||
DLR_STATION_CATEGORY,
|
||||
TRAM_METRO_CATEGORY,
|
||||
TUBE_STATION_CATEGORY,
|
||||
canonical_station_name,
|
||||
|
|
@ -131,6 +132,48 @@ def test_deduplicate_naptan_splits_london_underground_from_tram_metro():
|
|||
assert tram["id"][0] == "9400ZZMAWST"
|
||||
|
||||
|
||||
def test_deduplicate_naptan_splits_dlr_from_tram_metro():
|
||||
# DLR stations arrive as the tram/metro family (MET station node + TMU
|
||||
# entrance). The Beckton group carries a 9400ZZDL station node, so the
|
||||
# merged POI is reclassified to "DLR station" even though its entrance
|
||||
# carries a non-ZZDL ATCO code; the Metrolink group stays "Tram & Metro
|
||||
# stop".
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"id": [
|
||||
"9400ZZDLBEC",
|
||||
"490000254S",
|
||||
"9400ZZMAWST",
|
||||
],
|
||||
"name": [
|
||||
"Beckton DLR Station",
|
||||
"Beckton",
|
||||
"Weaste (Manchester Metrolink)",
|
||||
],
|
||||
"category": [TRAM_METRO_CATEGORY] * 3,
|
||||
"lat": [51.5148, 51.5148, 53.4826],
|
||||
"lng": [0.0613, 0.0614, -2.3087],
|
||||
"locality": [None, None, None],
|
||||
"entrance": [False, True, False],
|
||||
"is_lu": [False, False, False],
|
||||
"is_dlr": [True, False, False],
|
||||
}
|
||||
)
|
||||
|
||||
result = deduplicate_naptan(df).sort("category")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result["category"].to_list() == [
|
||||
DLR_STATION_CATEGORY,
|
||||
TRAM_METRO_CATEGORY,
|
||||
]
|
||||
dlr = result.filter(pl.col("category") == DLR_STATION_CATEGORY)
|
||||
# The station node (not the entrance) represents the merged POI.
|
||||
assert dlr["id"][0] == "9400ZZDLBEC"
|
||||
tram = result.filter(pl.col("category") == TRAM_METRO_CATEGORY)
|
||||
assert tram["id"][0] == "9400ZZMAWST"
|
||||
|
||||
|
||||
def test_deduplicate_naptan_merges_bus_station_bays_and_entrances():
|
||||
# BCS bays and a BCE entrance of one bus station collapse to a single POI
|
||||
# represented by a non-entrance node; a different bus station in another
|
||||
|
|
|
|||
|
|
@ -1221,6 +1221,7 @@ NAPTAN_EMOJIS: dict[str, str] = {
|
|||
"Bus station": "🚌",
|
||||
"Taxi rank": "🚕",
|
||||
"Tube station": "🚇",
|
||||
"DLR station": "🚈",
|
||||
"Tram & Metro stop": "🚊",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,22 +32,16 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
|
|||
/// Timeout for outbound HTTP service calls (seconds).
|
||||
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
|
||||
|
||||
/// Half-spans of the demo free zone box, in degrees. The box is built this wide
|
||||
/// around the visitor's approximate (IP-derived) location — or the fallback
|
||||
/// centre below when the IP can't be geolocated. Sized to match the original
|
||||
/// inner-London "zone 1" box (≈0.15° lat × 0.36° lon).
|
||||
pub const FREE_ZONE_HALF_LAT: f64 = 0.075;
|
||||
pub const FREE_ZONE_HALF_LON: f64 = 0.18;
|
||||
|
||||
/// Fallback demo free-zone centre (Canary Wharf), used when the visitor hasn't
|
||||
/// supplied a demo centre — i.e. they declined the "check your area" prompt or no
|
||||
/// GPS fix was available. When they consent, the browser sends their own centre.
|
||||
pub const FREE_ZONE_FALLBACK_LAT: f64 = 51.5054;
|
||||
pub const FREE_ZONE_FALLBACK_LON: f64 = -0.0235;
|
||||
|
||||
/// Demo (unlicensed) users may apply at most this many filters. Enforced both
|
||||
/// client-side (UX) and server-side (anti-DoS); must match the frontend constant.
|
||||
pub const DEMO_MAX_FILTERS: usize = 5;
|
||||
/// Anonymous (logged-out) users may apply at most this many filters at a time. This
|
||||
/// is the only gate on the demo: there is no region lock — they get the full
|
||||
/// dashboard everywhere, just capped on simultaneous filters. Enforced both
|
||||
/// client-side (UX) and server-side (defense-in-depth); must match the frontend
|
||||
/// constant `DEMO_MAX_FILTERS`.
|
||||
pub const DEMO_MAX_FILTERS: usize = 3;
|
||||
/// Registered but non-paying accounts get a higher filter allowance than anonymous
|
||||
/// visitors — the incentive to create a free account. Paying/licensed users are
|
||||
/// uncapped. Must match the frontend constant `REGISTERED_MAX_FILTERS`.
|
||||
pub const REGISTERED_MAX_FILTERS: usize = 5;
|
||||
/// Sliding-window rate limit for unlicensed traffic to `/api/` endpoints, keyed by
|
||||
/// account id (preferred) or client IP. Generous enough for active demo use, tight
|
||||
/// enough to blunt tile-by-tile scraping. Licensed users/admins are exempt.
|
||||
|
|
@ -55,10 +49,3 @@ pub const DEMO_RATE_LIMIT_MAX: usize = 600;
|
|||
pub const DEMO_RATE_LIMIT_WINDOW_SECS: u64 = 60;
|
||||
/// Soft cap on tracked rate-limit keys before stale ones are pruned.
|
||||
pub const DEMO_RATE_LIMIT_MAX_KEYS: usize = 50_000;
|
||||
|
||||
pub const SHARE_CACHE_TTL_SECS: u64 = 300;
|
||||
pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
|
||||
pub const MIN_SHARE_ZOOM: f64 = 11.0;
|
||||
pub const MAX_SHARE_ZOOM: f64 = 20.0;
|
||||
pub const MAX_SHARE_LAT_SPAN: f64 = 1.2;
|
||||
pub const MAX_SHARE_LON_SPAN: f64 = 2.0;
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ impl CrimeRecords {
|
|||
continue;
|
||||
};
|
||||
for i in start..start + count {
|
||||
if since_month.map_or(true, |s| month[i as usize] >= s) {
|
||||
if since_month.is_none_or(|s| month[i as usize] >= s) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
|
|
@ -518,7 +518,7 @@ mod tests {
|
|||
rss_after,
|
||||
);
|
||||
|
||||
assert!(recs.by_postcode.len() > 0, "expected at least one postcode");
|
||||
assert!(!recs.by_postcode.is_empty(), "expected at least one postcode");
|
||||
assert!(total > 0, "expected at least one record");
|
||||
// The old `.collect()` decoded all rows' string columns at once (tens of
|
||||
// GB). Streaming must keep the peak growth far below that; a generous 20GiB
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const DASHBOARD_POI_GROUPS: &[(&str, &[&str])] = &[
|
|||
&[
|
||||
"Rail station",
|
||||
"Tube station",
|
||||
"DLR station",
|
||||
"Tram & Metro stop",
|
||||
"Bus station",
|
||||
"Bus stop",
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ impl PostcodePopulation {
|
|||
|
||||
let mut by_postcode: FxHashMap<String, u32> = FxHashMap::default();
|
||||
by_postcode.reserve(df.height());
|
||||
for (postcode, population) in postcode_col.into_iter().zip(population_col.into_iter()) {
|
||||
for (postcode, population) in postcode_col.into_iter().zip(population_col) {
|
||||
let (Some(postcode), Some(population)) = (postcode, population) else {
|
||||
continue;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,127 +0,0 @@
|
|||
//! Per-visitor demo "free zone": the small area an unlicensed visitor may explore.
|
||||
//!
|
||||
//! The visitor's browser chooses the centre — their GPS location (with consent) or
|
||||
//! a Canary Wharf default — and sends it on each request as `demoLat`/`demoLon`
|
||||
//! query params. We build a fixed-size box around that centre for the licensing
|
||||
//! check. Absent/invalid params fall back to Canary Wharf.
|
||||
//!
|
||||
//! The centre is client-supplied and therefore spoofable: this is a soft demo gate
|
||||
//! backed by the rate-limit / filter-cap guards in `ratelimit.rs`, not a hard
|
||||
//! security boundary. (We deliberately removed IP-based geolocation in favour of
|
||||
//! this explicit, consent-based flow.)
|
||||
|
||||
use axum::extract::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use serde::Serialize;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::consts::{
|
||||
FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON, FREE_ZONE_HALF_LAT, FREE_ZONE_HALF_LON,
|
||||
};
|
||||
|
||||
/// A demo free-zone bounding box (degrees).
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct FreeZone {
|
||||
pub south: f64,
|
||||
pub west: f64,
|
||||
pub north: f64,
|
||||
pub east: f64,
|
||||
}
|
||||
|
||||
/// A visitor's resolved demo zone, inserted into request extensions by
|
||||
/// [`demo_zone_middleware`] for every request.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct DemoZone {
|
||||
pub free_zone: FreeZone,
|
||||
}
|
||||
|
||||
/// Build the demo free-zone box centred on `(lat, lon)`, keeping a fixed size and
|
||||
/// clamping to valid lat/lon ranges.
|
||||
pub fn free_zone_around(lat: f64, lon: f64) -> FreeZone {
|
||||
FreeZone {
|
||||
south: (lat - FREE_ZONE_HALF_LAT).max(-90.0),
|
||||
north: (lat + FREE_ZONE_HALF_LAT).min(90.0),
|
||||
west: (lon - FREE_ZONE_HALF_LON).max(-180.0),
|
||||
east: (lon + FREE_ZONE_HALF_LON).min(180.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the visitor's chosen demo centre from the `demoLat`/`demoLon` query params.
|
||||
fn demo_center_from_query(query: Option<&str>) -> Option<(f64, f64)> {
|
||||
let query = query?;
|
||||
let mut lat: Option<f64> = None;
|
||||
let mut lon: Option<f64> = None;
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"demoLat" => lat = value.parse().ok(),
|
||||
"demoLon" => lon = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (lat, lon) = (lat?, lon?);
|
||||
if lat.is_finite()
|
||||
&& lon.is_finite()
|
||||
&& (-90.0..=90.0).contains(&lat)
|
||||
&& (-180.0..=180.0).contains(&lon)
|
||||
{
|
||||
Some((lat, lon))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a request's demo zone from its query params, falling back to Canary Wharf.
|
||||
pub fn resolve_demo_zone(query: Option<&str>) -> DemoZone {
|
||||
let (lat, lon) =
|
||||
demo_center_from_query(query).unwrap_or((FREE_ZONE_FALLBACK_LAT, FREE_ZONE_FALLBACK_LON));
|
||||
DemoZone {
|
||||
free_zone: free_zone_around(lat, lon),
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware that resolves the visitor's [`DemoZone`] and stashes it in request
|
||||
/// extensions, mirroring how `auth_middleware` provides `OptionalUser`.
|
||||
pub async fn demo_zone_middleware(req: Request, next: Next) -> Response {
|
||||
let zone = resolve_demo_zone(req.uri().query());
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.extensions.insert(zone);
|
||||
next.run(Request::from_parts(parts, body)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fallback_box_is_canary_wharf_sized_and_centred() {
|
||||
let fz = resolve_demo_zone(None).free_zone;
|
||||
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
|
||||
assert!((((fz.west + fz.east) / 2.0) - FREE_ZONE_FALLBACK_LON).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_client_supplied_centre() {
|
||||
let fz = resolve_demo_zone(Some("demoLat=53.4808&demoLon=-2.2426&foo=bar")).free_zone;
|
||||
assert!((((fz.south + fz.north) / 2.0) - 53.4808).abs() < 1e-9);
|
||||
assert!((((fz.west + fz.east) / 2.0) - (-2.2426)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_or_missing_coords() {
|
||||
// Out of range → fallback.
|
||||
let fz = resolve_demo_zone(Some("demoLat=999&demoLon=0")).free_zone;
|
||||
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
|
||||
// Only one coord → fallback.
|
||||
let fz = resolve_demo_zone(Some("demoLat=53.0")).free_zone;
|
||||
assert!((((fz.south + fz.north) / 2.0) - FREE_ZONE_FALLBACK_LAT).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_keeps_a_constant_size_when_relocated() {
|
||||
let a = free_zone_around(51.5, -0.1);
|
||||
let b = free_zone_around(55.0, -3.0);
|
||||
assert!(((a.north - a.south) - (b.north - b.south)).abs() < 1e-9);
|
||||
assert!(((a.east - a.west) - (b.east - b.west)).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use parking_lot::RwLock;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::auth::PocketBaseUser;
|
||||
use crate::consts::{
|
||||
MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
|
||||
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
|
||||
};
|
||||
use crate::demo_zone::FreeZone;
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShareBounds {
|
||||
pub south: f64,
|
||||
pub west: f64,
|
||||
pub north: f64,
|
||||
pub east: f64,
|
||||
}
|
||||
|
||||
/// Cache: code → resolved share bounds. We cache `None` too so an invalid
|
||||
/// code doesn't keep hammering PocketBase on every request from a malicious
|
||||
/// or stale client.
|
||||
pub struct ShareBoundsCache {
|
||||
entries: RwLock<FxHashMap<String, (Option<ShareBounds>, Instant)>>,
|
||||
}
|
||||
|
||||
impl ShareBoundsCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: RwLock::new(FxHashMap::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, code: &str) -> Option<Option<ShareBounds>> {
|
||||
let map = self.entries.read();
|
||||
if let Some((bounds, created)) = map.get(code) {
|
||||
if created.elapsed().as_secs() < SHARE_CACHE_TTL_SECS {
|
||||
return Some(*bounds);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn insert(&self, code: String, bounds: Option<ShareBounds>) {
|
||||
let mut map = self.entries.write();
|
||||
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
|
||||
let now = Instant::now();
|
||||
map.retain(|_, (_, created)| {
|
||||
now.duration_since(*created).as_secs() < SHARE_CACHE_TTL_SECS
|
||||
});
|
||||
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
|
||||
let mut ages: Vec<Instant> = map.values().map(|(_, c)| *c).collect();
|
||||
ages.sort();
|
||||
let median = ages[ages.len() / 2];
|
||||
map.retain(|_, (_, created)| *created >= median);
|
||||
}
|
||||
}
|
||||
map.insert(code, (bounds, Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShareBoundsCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a share code to the bbox the share grants access to.
|
||||
/// Looks up an explicit server-created grant on the short URL record. Legacy
|
||||
/// records that only stored raw params intentionally grant no access.
|
||||
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
|
||||
if !is_valid_share_code(code) {
|
||||
return None;
|
||||
}
|
||||
if let Some(cached) = state.share_cache.get(code) {
|
||||
return cached;
|
||||
}
|
||||
let resolved = fetch_share_bounds(state, code).await;
|
||||
state.share_cache.insert(code.to_string(), resolved);
|
||||
resolved
|
||||
}
|
||||
|
||||
/// Convenience: resolve `Option<&str>` share code → `Option<ShareBounds>`.
|
||||
/// Skips the lookup entirely (and never touches the cache) when no code is
|
||||
/// supplied or the supplied code is empty.
|
||||
pub async fn resolve_share_code(state: &AppState, code: Option<&str>) -> Option<ShareBounds> {
|
||||
match code {
|
||||
Some(c) if !c.is_empty() => lookup_share_bounds(state, c).await,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_share_code(code: &str) -> bool {
|
||||
!code.is_empty() && code.len() <= 20 && code.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
|
||||
let token = match get_superuser_token(state).await {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("share bounds lookup: superuser auth failed: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("code=\"{code}\"");
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let json: Value = resp.json().await.ok()?;
|
||||
let item = json["items"].as_array()?.first()?;
|
||||
let bounds = ShareBounds {
|
||||
south: number_field(item, "share_south")?,
|
||||
west: number_field(item, "share_west")?,
|
||||
north: number_field(item, "share_north")?,
|
||||
east: number_field(item, "share_east")?,
|
||||
};
|
||||
is_valid_share_bounds(bounds).then_some(bounds)
|
||||
}
|
||||
|
||||
fn number_field(item: &Value, field: &str) -> Option<f64> {
|
||||
item.get(field)?.as_f64().filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
/// Build share params and bounds for a new share code. If the source view is
|
||||
/// broader than a share grant may cover, clamp the stored zoom around the same
|
||||
/// center so recipients open inside the created grant instead of being blocked
|
||||
/// on first load.
|
||||
pub fn share_params_and_bounds_from_params(params: &str) -> Option<(String, ShareBounds)> {
|
||||
let mut lat: Option<f64> = None;
|
||||
let mut lon: Option<f64> = None;
|
||||
let mut zoom: Option<f64> = None;
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"lat" => lat = value.parse().ok(),
|
||||
"lon" => lon = value.parse().ok(),
|
||||
"zoom" => zoom = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
pairs.push((key.into_owned(), value.into_owned()));
|
||||
}
|
||||
|
||||
let lat = lat?;
|
||||
let lon = lon?;
|
||||
let zoom = zoom?;
|
||||
if !lat.is_finite()
|
||||
|| !lon.is_finite()
|
||||
|| !zoom.is_finite()
|
||||
|| !(-90.0..=90.0).contains(&lat)
|
||||
|| !(-180.0..=180.0).contains(&lon)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
|
||||
let bounds = bounds_from_view(lat, lon, zoom);
|
||||
if !is_valid_share_bounds(bounds) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in pairs {
|
||||
if key == "zoom" {
|
||||
out.append_pair(&key, &format!("{zoom:.1}"));
|
||||
} else {
|
||||
out.append_pair(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
Some((out.finish(), bounds))
|
||||
}
|
||||
|
||||
/// Derive the share bbox from the share's center lat/lon and zoom.
|
||||
///
|
||||
/// A viewport W pixels wide at zoom z covers `W * 360 / (256 * 2^z)` degrees
|
||||
/// of longitude. For a typical 1280px-wide desktop viewport that's roughly
|
||||
/// `1800 / 2^z` degrees — we use that as the half-width, so the bbox is
|
||||
/// ~2 viewports per side (~4 viewports total area). Lat is scaled by 0.6
|
||||
/// to roughly match the latitude compression at UK latitudes.
|
||||
fn bounds_from_view(lat: f64, lon: f64, zoom: f64) -> ShareBounds {
|
||||
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
|
||||
let half_lon = (1800.0 / 2.0_f64.powf(zoom))
|
||||
.min(MAX_SHARE_LON_SPAN / 2.0)
|
||||
.min(180.0);
|
||||
let half_lat = (half_lon * 0.6).min(MAX_SHARE_LAT_SPAN / 2.0).min(85.0);
|
||||
ShareBounds {
|
||||
south: (lat - half_lat).max(-90.0),
|
||||
north: (lat + half_lat).min(90.0),
|
||||
west: (lon - half_lon).max(-180.0),
|
||||
east: (lon + half_lon).min(180.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid_share_bounds(bounds: ShareBounds) -> bool {
|
||||
let values = [bounds.south, bounds.west, bounds.north, bounds.east];
|
||||
values.iter().all(|value| value.is_finite())
|
||||
&& bounds.south >= -90.0
|
||||
&& bounds.north <= 90.0
|
||||
&& bounds.west >= -180.0
|
||||
&& bounds.east <= 180.0
|
||||
&& bounds.south <= bounds.north
|
||||
&& bounds.west <= bounds.east
|
||||
}
|
||||
|
||||
/// Check whether the user is allowed to query data at the given bounds.
|
||||
/// Licensed users and admins bypass the check entirely.
|
||||
/// Free/anonymous users get 403 unless the bounds fall inside their free zone
|
||||
/// (a box centred on their IP-approximate location) or inside the bbox granted
|
||||
/// by a valid share code.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_license_bounds(
|
||||
user: &Option<PocketBaseUser>,
|
||||
bounds: (f64, f64, f64, f64),
|
||||
free_zone: FreeZone,
|
||||
share_bounds: Option<ShareBounds>,
|
||||
) -> Result<(), axum::response::Response> {
|
||||
if let Some(u) = user {
|
||||
if u.is_admin || u.subscription == "licensed" {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let (south, west, north, east) = bounds;
|
||||
|
||||
if south >= free_zone.south
|
||||
&& west >= free_zone.west
|
||||
&& north <= free_zone.north
|
||||
&& east <= free_zone.east
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(sb) = share_bounds {
|
||||
if south >= sb.south && west >= sb.west && north <= sb.north && east <= sb.east {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"error": "license_required",
|
||||
"message": "A license is required to view data outside the demo area",
|
||||
"free_zone": {
|
||||
"south": free_zone.south,
|
||||
"west": free_zone.west,
|
||||
"north": free_zone.north,
|
||||
"east": free_zone.east,
|
||||
}
|
||||
});
|
||||
|
||||
Err((StatusCode::FORBIDDEN, axum::Json(body)).into_response())
|
||||
}
|
||||
|
||||
/// Convenience wrapper that takes a point (lat, lon) instead of bounds.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_license_point(
|
||||
user: &Option<PocketBaseUser>,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
free_zone: FreeZone,
|
||||
share_bounds: Option<ShareBounds>,
|
||||
) -> Result<(), axum::response::Response> {
|
||||
check_license_bounds(user, (lat, lon, lat, lon), free_zone, share_bounds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_close(actual: f64, expected: f64) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-9,
|
||||
"expected {actual} to be close to {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_clamps_over_broad_view_to_center() {
|
||||
let (params, bounds) =
|
||||
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=4.2&filter=price%3A1%3A2")
|
||||
.unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
assert!(parsed.contains(&("zoom".to_string(), "11.0".to_string())));
|
||||
assert!(parsed.contains(&("filter".to_string(), "price:1:2".to_string())));
|
||||
assert_close((bounds.south + bounds.north) / 2.0, 51.5);
|
||||
assert_close((bounds.west + bounds.east) / 2.0, -0.1);
|
||||
assert!(bounds.north - bounds.south <= MAX_SHARE_LAT_SPAN);
|
||||
assert!(bounds.east - bounds.west <= MAX_SHARE_LON_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_keeps_specific_zoom_inside_limit() {
|
||||
let (params, bounds) =
|
||||
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=13.3").unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
assert!(parsed.contains(&("zoom".to_string(), "13.3".to_string())));
|
||||
assert!(bounds.north - bounds.south < MAX_SHARE_LAT_SPAN);
|
||||
assert!(bounds.east - bounds.west < MAX_SHARE_LON_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_consumption_accepts_bounds_larger_than_creation_limit() {
|
||||
assert!(is_valid_share_bounds(ShareBounds {
|
||||
south: 40.0,
|
||||
west: -10.0,
|
||||
north: 60.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_consumption_still_rejects_malformed_bounds() {
|
||||
assert!(!is_valid_share_bounds(ShareBounds {
|
||||
south: 60.0,
|
||||
west: -10.0,
|
||||
north: 40.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
assert!(!is_valid_share_bounds(ShareBounds {
|
||||
south: 40.0,
|
||||
west: -181.0,
|
||||
north: 60.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,10 +7,8 @@ mod bugsink;
|
|||
mod checkout_sessions;
|
||||
mod consts;
|
||||
mod data;
|
||||
mod demo_zone;
|
||||
mod features;
|
||||
mod language;
|
||||
mod licensing;
|
||||
mod metrics;
|
||||
mod og_middleware;
|
||||
pub mod parsing;
|
||||
|
|
@ -701,7 +699,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let token_cache = Arc::new(auth::TokenCache::new());
|
||||
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
|
||||
let share_cache = Arc::new(licensing::ShareBoundsCache::new());
|
||||
|
||||
let actual_listings = {
|
||||
let path = &cli.actual_listings_path;
|
||||
|
|
@ -806,7 +803,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
area_crime_averages,
|
||||
token_cache,
|
||||
superuser_token_cache,
|
||||
share_cache,
|
||||
ai_filters_system_prompt,
|
||||
google_maps_api_key: cli.google_maps_api_key,
|
||||
stripe_secret_key: cli.stripe_secret_key,
|
||||
|
|
@ -1102,7 +1098,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
.layer(middleware::from_fn(metrics::track_metrics))
|
||||
.layer(middleware::from_fn(ratelimit::demo_guard_middleware))
|
||||
.layer(middleware::from_fn(demo_zone::demo_zone_middleware))
|
||||
.layer(middleware::from_fn(auth::auth_middleware))
|
||||
.layer(middleware::from_fn(
|
||||
move |req: axum::extract::Request, next: middleware::Next| {
|
||||
|
|
|
|||
|
|
@ -774,12 +774,6 @@ async fn ensure_short_urls_fields(
|
|||
"click_count",
|
||||
serde_json::json!({ "name": "click_count", "type": "number" }),
|
||||
);
|
||||
for field in ["share_south", "share_west", "share_north", "share_east"] {
|
||||
add_field(
|
||||
field,
|
||||
serde_json::json!({ "name": field, "type": "number" }),
|
||||
);
|
||||
}
|
||||
|
||||
if new_fields.len() == fields.len() {
|
||||
return Ok(());
|
||||
|
|
@ -1229,10 +1223,6 @@ pub async fn ensure_collections(
|
|||
Field::text("params", true),
|
||||
Field::text("created_by", false),
|
||||
Field::number("click_count"),
|
||||
Field::number("share_south"),
|
||||
Field::number("share_west"),
|
||||
Field::number("share_north"),
|
||||
Field::number("share_east"),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
//! admins are exempt.
|
||||
//!
|
||||
//! This is a soft anti-abuse measure, not a hard security boundary: the rate-limit
|
||||
//! key falls back to the (spoofable) client IP for anonymous users, and the demo
|
||||
//! gate is best-effort by design (see demo_zone.rs). Logged-in free accounts are keyed
|
||||
//! by their stable account id, which spoofing the IP header does not evade.
|
||||
//! key falls back to the (spoofable) client IP for anonymous users. Logged-in free
|
||||
//! accounts are keyed by their stable account id, which spoofing the IP header does
|
||||
//! not evade.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
|
@ -26,6 +26,7 @@ use url::form_urlencoded;
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{
|
||||
DEMO_MAX_FILTERS, DEMO_RATE_LIMIT_MAX, DEMO_RATE_LIMIT_MAX_KEYS, DEMO_RATE_LIMIT_WINDOW_SECS,
|
||||
REGISTERED_MAX_FILTERS,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
|
|
@ -123,26 +124,30 @@ fn is_internal_request(headers: &HeaderMap, peer: Option<IpAddr>) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Count non-empty `;;`-separated entries in the `filters` query parameter.
|
||||
/// Count a request's active filters: non-empty `;;`-separated entries in the
|
||||
/// `filters` parameter PLUS non-empty `|`-separated entries in the `travel`
|
||||
/// parameter. A travel-time destination restricts which properties match, so it
|
||||
/// counts toward the cap just like any feature filter.
|
||||
fn filter_count(query: &str) -> usize {
|
||||
let mut count = 0;
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
if key == "filters" {
|
||||
return value
|
||||
match key.as_ref() {
|
||||
"filters" => {
|
||||
count += value
|
||||
.split(";;")
|
||||
.filter(|entry| !entry.trim().is_empty())
|
||||
.count();
|
||||
}
|
||||
"travel" => {
|
||||
count += value
|
||||
.split('|')
|
||||
.filter(|entry| !entry.trim().is_empty())
|
||||
.count();
|
||||
}
|
||||
0
|
||||
_ => {}
|
||||
}
|
||||
|
||||
/// Whether the request carries a non-empty `share` code. Such requests view a shared
|
||||
/// dashboard whose saved filter set may legitimately exceed the demo cap, so they're
|
||||
/// exempt from the filter cap — the actual share-bounds authorization still runs in
|
||||
/// the handler.
|
||||
fn has_share_code(query: &str) -> bool {
|
||||
form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(key, value)| key == "share" && !value.trim().is_empty())
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Middleware applying the demo filter cap and rate limit to unlicensed `/api/`
|
||||
|
|
@ -180,16 +185,27 @@ pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
|
|||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// Server-side filter cap (mirrors the client demo limit; blunts oversized
|
||||
// aggregation requests). Share recipients are exempt — their saved view may
|
||||
// legitimately carry >5 filters and the share grant is checked in the handler.
|
||||
// Server-side filter cap (mirrors the client-side limit; blunts oversized
|
||||
// aggregation requests). With the region lock removed this is the only gate on
|
||||
// non-paying users, so it applies to every unlicensed data request. Registered
|
||||
// (logged-in) free accounts get a higher allowance than anonymous visitors.
|
||||
let filter_limit = if user.is_some() {
|
||||
REGISTERED_MAX_FILTERS
|
||||
} else {
|
||||
DEMO_MAX_FILTERS
|
||||
};
|
||||
if let Some(query) = req.uri().query() {
|
||||
if !has_share_code(query) && filter_count(query) > DEMO_MAX_FILTERS {
|
||||
if filter_count(query) > filter_limit {
|
||||
let message = if user.is_some() {
|
||||
format!("Free accounts are limited to {filter_limit} filters at a time — upgrade for unlimited")
|
||||
} else {
|
||||
format!("Create a free account for up to {REGISTERED_MAX_FILTERS} filters (currently limited to {filter_limit})")
|
||||
};
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(json!({
|
||||
"error": "filter_limit",
|
||||
"message": format!("The demo is limited to {DEMO_MAX_FILTERS} filters"),
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
|
|
@ -237,15 +253,14 @@ mod tests {
|
|||
);
|
||||
// Trailing/empty entries are ignored.
|
||||
assert_eq!(filter_count("filters=price%3A1%3A2%3B%3B"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_share_code() {
|
||||
assert!(!has_share_code(""));
|
||||
assert!(!has_share_code("filters=a%3B%3Bb"));
|
||||
assert!(!has_share_code("share=")); // empty value doesn't count
|
||||
assert!(has_share_code("share=abc123"));
|
||||
assert!(has_share_code("bounds=1,2,3,4&share=xyz&filters=a"));
|
||||
// Travel-time destinations (`|`-separated) count as filters too, and add
|
||||
// to the feature-filter count.
|
||||
assert_eq!(filter_count("travel=car%3Abank"), 1);
|
||||
assert_eq!(filter_count("travel=car%3Abank%7Ctransit%3Akings"), 2);
|
||||
assert_eq!(
|
||||
filter_count("filters=price%3A1%3A2&travel=car%3Abank%7Ctransit%3Akings"),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ use crate::api_error::ApiError;
|
|||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::ActualListing;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
parse_filters_with_poi, require_bounds, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
|
||||
};
|
||||
|
|
@ -28,8 +27,6 @@ pub struct ActualListingsParams {
|
|||
travel: Option<String>,
|
||||
/// Number of results to skip. Defaults to 0.
|
||||
offset: Option<usize>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -55,7 +52,6 @@ const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
|
|||
pub async fn get_actual_listings(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<ActualListingsParams>,
|
||||
) -> Result<Json<ActualListingsResponse>, Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -74,14 +70,6 @@ pub async fn get_actual_listings(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(
|
||||
&user.0,
|
||||
(south, west, north, east),
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||
|
|
|
|||
|
|
@ -64,12 +64,22 @@ fn backend_filter_name(name: &str) -> Option<String> {
|
|||
return Some(feature_name.to_string());
|
||||
}
|
||||
|
||||
// These must stay in lock-step with the frontend's synthetic filter-key
|
||||
// prefixes (the `*_FILTER_KEY_PREFIX` / `*_FILTER_NAME` constants in
|
||||
// frontend/src/lib/*-filter.ts). A missing or stale prefix means a filter
|
||||
// of that kind in the conversation `context` is sent to the model as an
|
||||
// opaque key instead of its real feature name, so the model can't see it
|
||||
// (and silently drops it if it echoes the key back).
|
||||
for prefix in [
|
||||
"Specific crimes:",
|
||||
"Serious crime:",
|
||||
"Minor crime:",
|
||||
"Political vote share:",
|
||||
"Ethnicities:",
|
||||
"Qualifications:",
|
||||
"Tenure:",
|
||||
"Amenity distance:",
|
||||
"Transport distance:",
|
||||
"Closest transport option:",
|
||||
"Amenities within 2km:",
|
||||
"Amenities within 5km:",
|
||||
] {
|
||||
|
|
@ -343,12 +353,32 @@ mod tests {
|
|||
canonical_filter_name("Political vote share:%25%20Labour:0"),
|
||||
"% Labour"
|
||||
);
|
||||
// POI distance for transport keys on the "Closest transport option"
|
||||
// filter name, not the long-gone "Transport distance".
|
||||
assert_eq!(
|
||||
canonical_filter_name(
|
||||
"Transport distance:Distance%20to%20nearest%20amenity%20%28Bus%20stop%29%20%28km%29:0"
|
||||
"Closest transport option:Distance%20to%20nearest%20amenity%20%28Bus%20stop%29%20%28km%29:0"
|
||||
),
|
||||
"Distance to nearest amenity (Bus stop) (km)"
|
||||
);
|
||||
// Census composition filters (qualifications, tenure) and the crime
|
||||
// severity rollups fold into variant cards too and must round-trip.
|
||||
assert_eq!(
|
||||
canonical_filter_name("Qualifications:%25%20Degree%20or%20higher:0"),
|
||||
"% Degree or higher"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_filter_name("Tenure:%25%20Owner%20occupied:0"),
|
||||
"% Owner occupied"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_filter_name("Serious crime:Serious%20crime%20(%2Fyr%2C%207y):0"),
|
||||
"Serious crime (/yr, 7y)"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_filter_name("Minor crime:Minor%20crime%20(%2Fyr%2C%202y):0"),
|
||||
"Minor crime (/yr, 2y)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ pub fn build_system_prompt(
|
|||
- Politics/elections are normal filters in the Neighbours group. Use exact vote share \
|
||||
features such as % Labour, % Conservative, % Liberal Democrat, % Reform UK, % Green, \
|
||||
% Other parties, or Voter turnout (%) when the user asks for political character.\n\
|
||||
- Housing tenure is a normal filter in the Neighbours group. \"lots of homeowners\" / \
|
||||
\"owner-occupied area\" = high % Owner occupied; \"rental area\" / \"lots of renters\" = \
|
||||
high % Private rent; \"social housing\" / \"council housing\" = high % Social rent.\n\
|
||||
- Education level is a normal filter in the Neighbours group. \"well-educated\" / \
|
||||
\"graduate\" / \"professional\" / \"university-educated area\" = high % Degree or higher; \
|
||||
\"few qualifications\" = high % No qualifications.\n\
|
||||
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
|
||||
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
|
||||
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
||||
|
|
@ -243,6 +249,17 @@ pub fn build_system_prompt(
|
|||
.to_string(),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"well-educated owner-occupier area, low crime, under 600k\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
{\"name\": \"Estimated current price\", \"bound\": \"max\", \"value\": 600000}, \
|
||||
{\"name\": \"% Degree or higher\", \"bound\": \"min\", \"value\": 40}, \
|
||||
{\"name\": \"% Owner occupied\", \"bound\": \"min\", \"value\": 60}, \
|
||||
{\"name\": \"Serious crime (/yr, 7y)\", \"bound\": \"max\", \"value\": 5}], \
|
||||
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
// Examples showing rent and price features
|
||||
parts.push(
|
||||
"\nUser: \"2 bed flat with rent under £1500/month\"\n\
|
||||
|
|
|
|||
|
|
@ -9,13 +9,10 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::licensing::{check_license_bounds, check_license_point, resolve_share_code};
|
||||
use crate::parsing::{cell_for_row_cached, h3_cell_bounds, needs_parent, validate_h3_resolution};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
|
@ -36,8 +33,6 @@ pub struct CrimeRecordsParams {
|
|||
/// Lower bound on `month_index` (`year*12 + month0`) to restrict to a recent
|
||||
/// window; omitted = all stored records (last 7 years).
|
||||
pub since: Option<u32>,
|
||||
/// Share-link code; grants scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -70,18 +65,14 @@ fn format_month(month_index: u32) -> String {
|
|||
|
||||
pub async fn get_crime_records(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<CrimeRecordsParams>,
|
||||
) -> Result<Json<CrimeRecordsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
let since = params.since;
|
||||
|
||||
// Resolve the selection to a set of postcodes, after a license check scoped
|
||||
// to the selection's geometry (bounds for a hexagon, point for a postcode).
|
||||
// Resolve the selection to a set of postcodes.
|
||||
enum Selection {
|
||||
Hexagon { cell: u64, resolution: u8 },
|
||||
Postcode(String),
|
||||
|
|
@ -96,23 +87,20 @@ pub async fn get_crime_records(
|
|||
(StatusCode::BAD_REQUEST, "resolution is required with h3").into_response()
|
||||
})?;
|
||||
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
|
||||
let bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, bounds, geo.free_zone, share_bounds)?;
|
||||
Selection::Hexagon {
|
||||
cell: cell.into(),
|
||||
resolution,
|
||||
}
|
||||
} else if let Some(postcode) = params.postcode.clone() {
|
||||
let normalized = normalize_postcode(&postcode);
|
||||
let &pc_idx = state
|
||||
// Validate the postcode exists (404 otherwise).
|
||||
state
|
||||
.postcode_data
|
||||
.postcode_to_idx
|
||||
.get(&normalized)
|
||||
.ok_or_else(|| {
|
||||
(StatusCode::NOT_FOUND, format!("Postcode not found: {normalized}")).into_response()
|
||||
})?;
|
||||
let (lat, lon) = state.postcode_data.centroids[pc_idx];
|
||||
check_license_point(&user.0, lat as f64, lon as f64, geo.free_zone, share_bounds)?;
|
||||
Selection::Postcode(normalized)
|
||||
} else {
|
||||
return Err((StatusCode::BAD_REQUEST, "h3 or postcode is required").into_response());
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::Extension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::data::DevelopmentSite;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::require_bounds;
|
||||
use crate::state::SharedState;
|
||||
|
||||
|
|
@ -19,8 +16,6 @@ const DEVELOPMENTS_LIMIT: usize = 4000;
|
|||
#[derive(Deserialize)]
|
||||
pub struct DevelopmentsParams {
|
||||
bounds: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -32,12 +27,9 @@ pub struct DevelopmentsResponse {
|
|||
|
||||
/// Forward-looking "where new homes are coming" layer: planned/pipeline
|
||||
/// development sites (MHCLG Brownfield Land register + Homes England Land Hub),
|
||||
/// served as points within a viewport. Public OGL data, but still gated by the
|
||||
/// normal demo/licence bounds check so unlicensed users only see their free zone.
|
||||
/// served as points within a viewport. Public OGL data.
|
||||
pub async fn get_developments(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<DevelopmentsParams>,
|
||||
) -> Result<Json<DevelopmentsResponse>, Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -45,14 +37,6 @@ pub async fn get_developments(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(
|
||||
&user.0,
|
||||
(south, west, north, east),
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let developments = state.developments.clone();
|
||||
let (sites, total, truncated) =
|
||||
developments.query_bounds(south, west, north, east, DEVELOPMENTS_LIMIT);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ use crate::consts::NAN_U16;
|
|||
use crate::data::travel_time::TravelData;
|
||||
use crate::data::{PostcodePoiMetrics, QuantRef};
|
||||
use crate::features;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
parse_bounds, parse_field_indices_with_poi, parse_filters_with_poi, row_passes_filters,
|
||||
row_passes_poi_filters, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
|
||||
|
|
@ -36,9 +35,8 @@ const IMAGE_ROW_HEIGHT: f64 = 225.0;
|
|||
|
||||
/// Hard cap on the bounding-box area (in degrees²) that may be exported.
|
||||
/// All of England fits inside ~6° × ~10° ≈ 60 deg². Anything substantially
|
||||
/// larger is rejected to keep aggregation bounded for non-licensed users
|
||||
/// who supply share grants outside their expected region, and to avoid
|
||||
/// minutes-long requests that fan out to millions of rows.
|
||||
/// larger is rejected to keep aggregation bounded and to avoid minutes-long
|
||||
/// requests that fan out to millions of rows.
|
||||
const MAX_EXPORT_BBOX_AREA_DEG2: f64 = 80.0;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -47,7 +45,6 @@ pub struct ExportParams {
|
|||
filters: Option<String>,
|
||||
travel: Option<String>,
|
||||
fields: Option<String>,
|
||||
share: Option<String>,
|
||||
/// Comma-separated list of postcodes for list-mode export. When supplied,
|
||||
/// the bounds / filters / travel parameters are ignored.
|
||||
postcodes: Option<String>,
|
||||
|
|
@ -230,7 +227,6 @@ fn build_frontend_params(
|
|||
filters_str: Option<&str>,
|
||||
travel_params: &[String],
|
||||
overlay_params: &[String],
|
||||
share: Option<&str>,
|
||||
) -> String {
|
||||
let mut parts = vec![
|
||||
format!("lat={:.4}", center_lat),
|
||||
|
|
@ -256,9 +252,6 @@ fn build_frontend_params(
|
|||
parts.push(format!("overlay={}", urlencoding::encode(entry.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(share) = share.filter(|value| !value.is_empty()) {
|
||||
parts.push(format!("share={}", urlencoding::encode(share)));
|
||||
}
|
||||
parts.join("&")
|
||||
}
|
||||
|
||||
|
|
@ -446,7 +439,6 @@ pub async fn get_export(
|
|||
State(shared): State<Arc<SharedState>>,
|
||||
headers: HeaderMap,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
uri: Uri,
|
||||
Query(params): Query<ExportParams>,
|
||||
) -> Result<impl IntoResponse, axum::response::Response> {
|
||||
|
|
@ -501,14 +493,6 @@ pub async fn get_export(
|
|||
}
|
||||
}
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(
|
||||
&user.0,
|
||||
(south, west, north, east),
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters): (
|
||||
|
|
@ -556,7 +540,6 @@ pub async fn get_export(
|
|||
collect_overlay_state_params(uri.query())
|
||||
};
|
||||
let fields_str = params.fields;
|
||||
let share_code = params.share;
|
||||
|
||||
let public_url = state.public_url.clone();
|
||||
|
||||
|
|
@ -576,7 +559,6 @@ pub async fn get_export(
|
|||
filters_str.as_deref(),
|
||||
&travel_state_params,
|
||||
&overlay_state_params,
|
||||
share_code.as_deref(),
|
||||
);
|
||||
|
||||
// Screenshot only makes sense for the spatial / filter mode. In list mode the
|
||||
|
|
|
|||
|
|
@ -3,15 +3,12 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{parse_filters_with_poi, require_bounds};
|
||||
use crate::routes::travel_time::parse_optional_travel;
|
||||
use crate::state::SharedState;
|
||||
|
|
@ -21,7 +18,6 @@ pub struct FilterCountsParams {
|
|||
bounds: Option<String>,
|
||||
filters: Option<String>,
|
||||
travel: Option<String>,
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -32,21 +28,12 @@ pub struct FilterCountsResponse {
|
|||
|
||||
pub async fn get_filter_counts(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<FilterCountsParams>,
|
||||
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(
|
||||
&user.0,
|
||||
(south, west, north, east),
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -7,17 +7,14 @@ static OUT_OF_RANGE_WARN: Once = Once::new();
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::data::PropertyData;
|
||||
use crate::features::{Feature, FEATURE_GROUPS};
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters_with_poi,
|
||||
row_passes_filters, row_passes_poi_filters, validate_h3_resolution, ParsedEnumFilter,
|
||||
|
|
@ -197,8 +194,6 @@ pub struct HexagonStatsParams {
|
|||
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
|
||||
/// Optional min:max applies as a filter (exclude properties outside range).
|
||||
pub travel: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
fn default_area_stat_field_set() -> HashSet<String> {
|
||||
|
|
@ -475,8 +470,6 @@ pub(super) fn top_filter_exclusions(
|
|||
|
||||
pub async fn get_hexagon_stats(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<HexagonStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -493,11 +486,6 @@ pub async fn get_hexagon_stats(
|
|||
let resolution = params.resolution;
|
||||
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
|
||||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, h3_bounds, geo.free_zone, share_bounds)?;
|
||||
|
||||
let h3_str = params.h3;
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use metrics::histogram;
|
||||
use rayon::prelude::*;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
|
@ -12,10 +11,8 @@ use serde_json::{Map, Value};
|
|||
use tracing::info;
|
||||
|
||||
use crate::aggregation::{Aggregator, EnumDistConfig, PoiAggregator};
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, needs_parent, parse_enum_dist, parse_field_indices_with_poi,
|
||||
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
|
||||
|
|
@ -71,8 +68,6 @@ pub struct HexagonParams {
|
|||
/// Feature name for enum distribution counting (pie chart visualization).
|
||||
/// When set, each cell includes `dist_{name}: [count_val0, count_val1, ...]`.
|
||||
enum_dist: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
/// Build feature maps from aggregated cell data, filtering to only cells whose
|
||||
|
|
@ -251,8 +246,6 @@ fn build_feature_maps(
|
|||
|
||||
pub async fn get_hexagons(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<HexagonParams>,
|
||||
) -> Result<Json<HexagonsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -262,14 +255,6 @@ pub async fn get_hexagons(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(
|
||||
&user.0,
|
||||
(south, west, north, east),
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ use axum::extract::State;
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::Json;
|
||||
use axum::Extension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::data::{slugify, PlaceData};
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
|
|
@ -18,7 +15,6 @@ pub struct JourneyQuery {
|
|||
postcode: String,
|
||||
mode: String,
|
||||
slug: String,
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -56,24 +52,17 @@ fn destination_coordinates(place_data: &PlaceData, slug: &str) -> Option<(f32, f
|
|||
|
||||
pub async fn get_journey(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
query: axum::extract::Query<JourneyQuery>,
|
||||
) -> Result<Json<JourneyResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
let store = &state.travel_time_store;
|
||||
let postcode = normalize_postcode(&query.postcode);
|
||||
|
||||
let pc_idx = state
|
||||
.postcode_data
|
||||
.postcode_to_idx
|
||||
.get(&postcode)
|
||||
.copied()
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Postcode not found").into_response())?;
|
||||
let (lat, lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
|
||||
check_license_point(&user.0, lat as f64, lon as f64, geo.free_zone, share_bounds)?;
|
||||
// Validate the postcode exists (404 otherwise); the journey is keyed by
|
||||
// mode/slug and the postcode string, not the centroid.
|
||||
if !state.postcode_data.postcode_to_idx.contains_key(&postcode) {
|
||||
return Err((StatusCode::NOT_FOUND, "Postcode not found").into_response());
|
||||
}
|
||||
|
||||
if !store.has_destination(&query.mode, &query.slug) {
|
||||
return Err((
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{POSTCODE_SEARCH_OFFSET, PROPERTIES_LIMIT};
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
parse_field_indices_with_poi, parse_filters_with_poi, row_passes_filters,
|
||||
row_passes_poi_filters,
|
||||
|
|
@ -34,14 +31,10 @@ pub struct PostcodePropertiesParams {
|
|||
pub fields: Option<String>,
|
||||
/// Exact address to rank first when opening properties from address search.
|
||||
pub focus_address: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_postcode_properties(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<PostcodePropertiesParams>,
|
||||
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -60,15 +53,6 @@ pub async fn get_postcode_properties(
|
|||
};
|
||||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_point(
|
||||
&user.0,
|
||||
centroid_lat as f64,
|
||||
centroid_lon as f64,
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::POSTCODE_SEARCH_OFFSET;
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
|
@ -31,14 +28,10 @@ pub struct PostcodeStatsParams {
|
|||
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
|
||||
/// Optional min:max applies as a filter (exclude properties outside range).
|
||||
pub travel: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_postcode_stats(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<PostcodeStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -58,16 +51,6 @@ pub async fn get_postcode_stats(
|
|||
};
|
||||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
// License check using postcode centroid
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_point(
|
||||
&user.0,
|
||||
centroid_lat as f64,
|
||||
centroid_lon as f64,
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ use tracing::info;
|
|||
use crate::aggregation::{Aggregator, EnumDistConfig, PoiAggregator};
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
bounds_intersect, parse_enum_dist, parse_field_indices_with_poi, parse_filters_with_poi,
|
||||
require_bounds, row_passes_filters, row_passes_poi_filters,
|
||||
|
|
@ -47,28 +46,16 @@ pub struct PostcodeParams {
|
|||
travel: Option<String>,
|
||||
/// Feature name for enum distribution counting (pie chart visualization).
|
||||
enum_dist: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_postcodes(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<PostcodeParams>,
|
||||
) -> Result<Json<PostcodesResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(
|
||||
&user.0,
|
||||
(south, west, north, east),
|
||||
geo.free_zone,
|
||||
share_bounds,
|
||||
)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||
|
|
|
|||
|
|
@ -4,15 +4,12 @@ use std::sync::Arc;
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::Extension;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::PROPERTIES_LIMIT;
|
||||
use crate::data::{HistoricalPrice, RenovationEvent, TenureEvent};
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_indices_with_poi,
|
||||
parse_filters_with_poi, row_passes_filters, row_passes_poi_filters, validate_h3_resolution,
|
||||
|
|
@ -35,8 +32,6 @@ pub struct HexagonPropertiesParams {
|
|||
/// If absent, keeps the legacy behavior and returns all numeric features.
|
||||
/// If empty, returns only the fixed property card fields.
|
||||
pub fields: Option<String>,
|
||||
/// Share-link code; grants bbox-scoped access for unlicensed users.
|
||||
pub share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -273,8 +268,6 @@ pub fn build_property(
|
|||
|
||||
pub async fn get_hexagon_properties(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(geo): Extension<crate::demo_zone::DemoZone>,
|
||||
Query(params): Query<HexagonPropertiesParams>,
|
||||
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
|
@ -291,11 +284,6 @@ pub async fn get_hexagon_properties(
|
|||
let resolution = params.resolution;
|
||||
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
|
||||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||
check_license_bounds(&user.0, h3_bounds, geo.free_zone, share_bounds)?;
|
||||
|
||||
let h3_str = params.h3;
|
||||
let quant = state.data.quant_ref();
|
||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ use url::form_urlencoded;
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::licensing::{is_valid_share_bounds, share_params_and_bounds_from_params, ShareBounds};
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::SharedState;
|
||||
|
||||
|
|
@ -48,14 +47,6 @@ struct PbRecord {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
created_by: Option<String>,
|
||||
click_count: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
share_south: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
share_west: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
share_north: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
share_east: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -85,7 +76,7 @@ fn json_number_as_u64(value: &serde_json::Value) -> u64 {
|
|||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sanitized_query_params(params: &str, keep_share: bool) -> Result<String, &'static str> {
|
||||
fn sanitized_query_params(params: &str) -> Result<String, &'static str> {
|
||||
let params = params.trim_start_matches('?');
|
||||
if params.len() > MAX_QUERY_LEN {
|
||||
return Err("query string is too long");
|
||||
|
|
@ -96,7 +87,8 @@ fn sanitized_query_params(params: &str, keep_share: bool) -> Result<String, &'st
|
|||
if idx >= MAX_QUERY_PAIRS {
|
||||
return Err("query string has too many parameters");
|
||||
}
|
||||
if key == "share" && !keep_share {
|
||||
// `share` was a region-grant code; it no longer carries meaning, so drop it.
|
||||
if key == "share" {
|
||||
continue;
|
||||
}
|
||||
if !is_allowed_param_key(&key) {
|
||||
|
|
@ -145,7 +137,6 @@ fn is_allowed_param_key(key: &str) -> bool {
|
|||
| "pc"
|
||||
| "tt"
|
||||
| "lang"
|
||||
| "share"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -158,42 +149,7 @@ fn escape_attr(value: &str) -> String {
|
|||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn user_can_create_share_grant(user: &OptionalUser) -> bool {
|
||||
user.0
|
||||
.as_ref()
|
||||
.is_some_and(|u| u.is_admin || u.subscription == "licensed")
|
||||
}
|
||||
|
||||
fn share_fields(
|
||||
bounds: Option<ShareBounds>,
|
||||
) -> (Option<f64>, Option<f64>, Option<f64>, Option<f64>) {
|
||||
match bounds {
|
||||
Some(bounds) => (
|
||||
Some(bounds.south),
|
||||
Some(bounds.west),
|
||||
Some(bounds.north),
|
||||
Some(bounds.east),
|
||||
),
|
||||
None => (None, None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_share_bounds(item: &serde_json::Value) -> Option<ShareBounds> {
|
||||
let bounds = ShareBounds {
|
||||
south: item.get("share_south")?.as_f64()?,
|
||||
west: item.get("share_west")?.as_f64()?,
|
||||
north: item.get("share_north")?.as_f64()?,
|
||||
east: item.get("share_east")?.as_f64()?,
|
||||
};
|
||||
is_valid_share_bounds(bounds).then_some(bounds)
|
||||
}
|
||||
|
||||
fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> String {
|
||||
let params = match include_share {
|
||||
true => params_with_share(params, code),
|
||||
false => params.to_string(),
|
||||
};
|
||||
|
||||
fn dashboard_redirect_url(params: &str) -> String {
|
||||
if params.is_empty() {
|
||||
"/dashboard".to_string()
|
||||
} else {
|
||||
|
|
@ -201,29 +157,8 @@ fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> Stri
|
|||
}
|
||||
}
|
||||
|
||||
fn params_with_share(params: &str, code: &str) -> String {
|
||||
let mut out = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
|
||||
if key == "share" {
|
||||
continue;
|
||||
}
|
||||
out.append_pair(&key, &value);
|
||||
}
|
||||
out.append_pair("share", code);
|
||||
out.finish()
|
||||
}
|
||||
|
||||
fn og_image_url(
|
||||
public_url: &str,
|
||||
params: &str,
|
||||
language: &str,
|
||||
share_code: Option<&str>,
|
||||
) -> String {
|
||||
fn og_image_url(public_url: &str, params: &str, language: &str) -> String {
|
||||
let params = query_string_with_language(params, language);
|
||||
let params = match share_code {
|
||||
Some(code) => params_with_share(¶ms, code),
|
||||
None => params,
|
||||
};
|
||||
|
||||
if params.is_empty() {
|
||||
format!("{}/api/screenshot?og=1", public_url.trim_end_matches('/'))
|
||||
|
|
@ -243,8 +178,7 @@ pub async fn post_shorten(
|
|||
let state = shared.load_state();
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let can_create_share_grant = user_can_create_share_grant(&user);
|
||||
let mut params = match sanitized_query_params(&req.params, !can_create_share_grant) {
|
||||
let params = match sanitized_query_params(&req.params) {
|
||||
Ok(params) => params,
|
||||
Err(reason) => {
|
||||
warn!("Rejected short URL params: {reason}");
|
||||
|
|
@ -261,25 +195,11 @@ pub async fn post_shorten(
|
|||
};
|
||||
|
||||
let code = generate_code();
|
||||
let share_bounds = if can_create_share_grant {
|
||||
share_params_and_bounds_from_params(¶ms).map(|(share_params, share_bounds)| {
|
||||
params = share_params;
|
||||
share_bounds
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (share_south, share_west, share_north, share_east) = share_fields(share_bounds);
|
||||
|
||||
let record = PbRecord {
|
||||
code: code.clone(),
|
||||
params,
|
||||
created_by: user.0.as_ref().map(|u| u.id.clone()),
|
||||
click_count: 0,
|
||||
share_south,
|
||||
share_west,
|
||||
share_north,
|
||||
share_east,
|
||||
};
|
||||
|
||||
let res = state
|
||||
|
|
@ -380,13 +300,7 @@ pub async fn get_share_links(
|
|||
.map(|item| {
|
||||
let code = item["code"].as_str().unwrap_or("").to_string();
|
||||
let params = item["params"].as_str().unwrap_or("").to_string();
|
||||
let has_share_grant = record_share_bounds(item).is_some();
|
||||
let og_image_url = og_image_url(
|
||||
public_url,
|
||||
¶ms,
|
||||
language,
|
||||
has_share_grant.then_some(code.as_str()),
|
||||
);
|
||||
let og_image_url = og_image_url(public_url, ¶ms, language);
|
||||
ShareLinkListItem {
|
||||
url: format!("{public_url}/s/{code}"),
|
||||
code,
|
||||
|
|
@ -459,7 +373,7 @@ pub async fn get_short_url(
|
|||
let record_id = item["id"].as_str().unwrap_or("").to_string();
|
||||
let next_click_count =
|
||||
json_number_as_u64(&item["click_count"]).saturating_add(1);
|
||||
let params = match sanitized_query_params(params, true) {
|
||||
let params = match sanitized_query_params(params) {
|
||||
Ok(params) => params,
|
||||
Err(reason) => {
|
||||
warn!("Stored short URL params rejected for {code}: {reason}");
|
||||
|
|
@ -486,14 +400,8 @@ pub async fn get_short_url(
|
|||
Err(err) => warn!("PocketBase click count update failed: {err}"),
|
||||
}
|
||||
}
|
||||
let has_share_grant = record_share_bounds(item).is_some();
|
||||
let redirect_url = dashboard_redirect_url(¶ms, &code, has_share_grant);
|
||||
let og_image_url = og_image_url(
|
||||
&state.public_url,
|
||||
¶ms,
|
||||
language,
|
||||
has_share_grant.then_some(code.as_str()),
|
||||
);
|
||||
let redirect_url = dashboard_redirect_url(¶ms);
|
||||
let og_image_url = og_image_url(&state.public_url, ¶ms, language);
|
||||
let og_url = format!("{}/s/{code}", state.public_url.trim_end_matches('/'));
|
||||
let og_title = "Perfect Postcode | Every neighbourhood in England";
|
||||
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
|
||||
|
|
@ -555,10 +463,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn sanitizes_short_url_params_and_drops_share() {
|
||||
let params = sanitized_query_params(
|
||||
"lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2&share=oldcode",
|
||||
false,
|
||||
)
|
||||
let params =
|
||||
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2&share=oldcode")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2");
|
||||
|
|
@ -566,22 +472,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn rejects_html_in_unsupported_params() {
|
||||
assert!(sanitized_query_params("lat=51&x=%22%3E%3Cscript%3E", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_preserve_existing_share_grant() {
|
||||
let params =
|
||||
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&share=oldcode", true).unwrap();
|
||||
|
||||
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&share=oldcode");
|
||||
assert!(sanitized_query_params("lat=51&x=%22%3E%3Cscript%3E").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_overlay_params_for_share_links() {
|
||||
let params = sanitized_query_params(
|
||||
"lat=51.5&lon=-0.1&zoom=12&overlay=noise&overlay=crime-hotspots",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -593,8 +490,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn preserves_basemap_for_share_links() {
|
||||
let params =
|
||||
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&basemap=satellite", false).unwrap();
|
||||
let params = sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&basemap=satellite").unwrap();
|
||||
|
||||
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&basemap=satellite");
|
||||
}
|
||||
|
|
@ -610,7 +506,7 @@ mod tests {
|
|||
&tenure=Owner%3A30%3A90\
|
||||
&crimeType=burglary\
|
||||
&colorOpacity=60";
|
||||
let params = sanitized_query_params(query, false).unwrap();
|
||||
let params = sanitized_query_params(query).unwrap();
|
||||
|
||||
assert_eq!(params, query);
|
||||
}
|
||||
|
|
@ -621,32 +517,19 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn og_image_url_includes_language_and_share_grant() {
|
||||
fn og_image_url_includes_language() {
|
||||
assert_eq!(
|
||||
og_image_url(
|
||||
"http://localhost:3001/",
|
||||
"lat=51.5&lon=-0.1&zoom=12",
|
||||
"de",
|
||||
Some("abc123")
|
||||
),
|
||||
"http://localhost:3001/api/screenshot?og=1&lat=51.5&lon=-0.1&zoom=12&lang=de&share=abc123"
|
||||
og_image_url("http://localhost:3001/", "lat=51.5&lon=-0.1&zoom=12", "de"),
|
||||
"http://localhost:3001/api/screenshot?og=1&lat=51.5&lon=-0.1&zoom=12&lang=de"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_grant_replaces_existing_share_param() {
|
||||
fn dashboard_redirect_url_wraps_params() {
|
||||
assert_eq!(
|
||||
dashboard_redirect_url("lat=51.5&share=oldcode&zoom=12", "newcode", true),
|
||||
"/dashboard?lat=51.5&zoom=12&share=newcode"
|
||||
);
|
||||
assert_eq!(
|
||||
og_image_url(
|
||||
"https://perfect-postcodes.co.uk",
|
||||
"lat=51.5&share=oldcode&zoom=12",
|
||||
"en",
|
||||
Some("newcode")
|
||||
),
|
||||
"https://perfect-postcodes.co.uk/api/screenshot?og=1&lat=51.5&zoom=12&lang=en&share=newcode"
|
||||
dashboard_redirect_url("lat=51.5&zoom=12"),
|
||||
"/dashboard?lat=51.5&zoom=12"
|
||||
);
|
||||
assert_eq!(dashboard_redirect_url(""), "/dashboard");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ use rustc_hash::FxHashMap;
|
|||
use crate::auth::TokenCache;
|
||||
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
|
||||
use crate::data::{
|
||||
ActualListingData, AreaCrimeAverages, CrimeByYearData, CrimeRecords,
|
||||
DevelopmentData, OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData,
|
||||
PostcodePopulation, PropertyData, TravelTimeStore,
|
||||
ActualListingData, AreaCrimeAverages, CrimeByYearData, CrimeRecords, DevelopmentData,
|
||||
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PostcodePopulation,
|
||||
PropertyData, TravelTimeStore,
|
||||
};
|
||||
use crate::licensing::ShareBoundsCache;
|
||||
use crate::pocketbase::SuperuserTokenCache;
|
||||
use crate::routes::FeaturesResponse;
|
||||
use crate::utils::GridIndex;
|
||||
|
|
@ -65,9 +64,6 @@ pub struct AppState {
|
|||
pub token_cache: Arc<TokenCache>,
|
||||
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
|
||||
pub superuser_token_cache: Arc<SuperuserTokenCache>,
|
||||
/// Cached share-link bbox lookups (5min TTL); used to grant unlicensed
|
||||
/// users access to the area their share link references.
|
||||
pub share_cache: Arc<ShareBoundsCache>,
|
||||
|
||||
// --- Config (cheap to clone) ---
|
||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||
|
|
@ -186,7 +182,6 @@ impl AppState {
|
|||
area_crime_averages: Arc::new(AreaCrimeAverages::empty()),
|
||||
token_cache: Arc::new(TokenCache::new()),
|
||||
superuser_token_cache: Arc::new(SuperuserTokenCache::new()),
|
||||
share_cache: Arc::new(ShareBoundsCache::new()),
|
||||
screenshot_url: "http://127.0.0.1:1/screenshot".to_string(),
|
||||
public_url: "https://test.example".to_string(),
|
||||
is_dev: false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue