Fix FE
This commit is contained in:
parent
1241132095
commit
54a5c3ca9a
28 changed files with 826 additions and 422 deletions
|
|
@ -15,6 +15,7 @@ import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup
|
||||||
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
|
import pb from './lib/pocketbase';
|
||||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
|
|
@ -40,6 +41,7 @@ const SavedPage = lazy(() =>
|
||||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||||
);
|
);
|
||||||
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
||||||
|
const LegalPage = lazy(() => import('./components/legal/LegalPage'));
|
||||||
const MapPage = lazy(() => import('./components/map/MapPage'));
|
const MapPage = lazy(() => import('./components/map/MapPage'));
|
||||||
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
||||||
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
|
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
|
||||||
|
|
@ -77,19 +79,42 @@ function currentRelativePath(): string {
|
||||||
return `${window.location.pathname}${window.location.search}`;
|
return `${window.location.pathname}${window.location.search}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LAST_DASHBOARD_PARAMS_KEY = 'pp_last_dashboard_params';
|
||||||
|
|
||||||
|
function persistLastDashboardParams(params: string) {
|
||||||
|
try {
|
||||||
|
if (params) window.localStorage.setItem(LAST_DASHBOARD_PARAMS_KEY, params);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable (private mode/quota) — session restore is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastDashboardSearch(): string {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(LAST_DASHBOARD_PARAMS_KEY);
|
||||||
|
return saved ? `?${saved.replace(/^\?/, '')}` : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters and map view live only in the URL. When the dashboard is opened bare
|
||||||
|
* (no query), restore the last session's params so users pick up where they
|
||||||
|
* left off. Explicit params and shared links always win.
|
||||||
|
*/
|
||||||
|
function restoreLastDashboardSession() {
|
||||||
|
const pathname = window.location.pathname.replace(/\/+$/, '');
|
||||||
|
if (pathname !== '/dashboard' || window.location.search) return;
|
||||||
|
const saved = readLastDashboardSearch();
|
||||||
|
if (!saved) return;
|
||||||
|
window.history.replaceState(window.history.state, '', `/dashboard${saved}`);
|
||||||
|
}
|
||||||
|
|
||||||
function isProtectedPage(page: Page): boolean {
|
function isProtectedPage(page: Page): boolean {
|
||||||
return page === 'account' || page === 'saved';
|
return page === 'account' || page === 'saved';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSharedDashboardUrl(): boolean {
|
|
||||||
const share = new URLSearchParams(window.location.search).get('share');
|
|
||||||
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAuthRequiredRoute(page: Page): boolean {
|
|
||||||
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
|
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
|
||||||
const normalizedHash = normalizeHash(hash);
|
const normalizedHash = normalizeHash(hash);
|
||||||
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
|
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
|
||||||
|
|
@ -126,6 +151,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
case 'methodology':
|
case 'methodology':
|
||||||
case 'privacy-security':
|
case 'privacy-security':
|
||||||
return SEO_CONTENT_PATHS[page];
|
return SEO_CONTENT_PATHS[page];
|
||||||
|
case 'terms':
|
||||||
|
return '/terms';
|
||||||
|
case 'privacy':
|
||||||
|
return '/privacy';
|
||||||
case 'saved':
|
case 'saved':
|
||||||
return '/saved';
|
return '/saved';
|
||||||
case 'account':
|
case 'account':
|
||||||
|
|
@ -140,7 +169,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathToPage(pathname: string): RouteMatch | null {
|
function pathToPage(rawPathname: string): RouteMatch | null {
|
||||||
|
// Proxies 307-redirect /learn -> /learn/; treat trailing slashes as equivalent.
|
||||||
|
const pathname =
|
||||||
|
rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname;
|
||||||
if (pathname === '/dashboard') return { page: 'dashboard' };
|
if (pathname === '/dashboard') return { page: 'dashboard' };
|
||||||
if (pathname === '/saved') return { page: 'saved' };
|
if (pathname === '/saved') return { page: 'saved' };
|
||||||
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
|
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
|
||||||
|
|
@ -152,6 +184,8 @@ function pathToPage(pathname: string): RouteMatch | null {
|
||||||
if (seoContentPage) return { page: seoContentPage };
|
if (seoContentPage) return { page: seoContentPage };
|
||||||
if (pathname === '/account') return { page: 'account' };
|
if (pathname === '/account') return { page: 'account' };
|
||||||
if (pathname === '/support') return { page: 'learn' };
|
if (pathname === '/support') return { page: 'learn' };
|
||||||
|
if (pathname === '/terms') return { page: 'terms' };
|
||||||
|
if (pathname === '/privacy') return { page: 'privacy' };
|
||||||
if (pathname.startsWith('/invite/')) {
|
if (pathname.startsWith('/invite/')) {
|
||||||
const code = pathname.slice('/invite/'.length);
|
const code = pathname.slice('/invite/'.length);
|
||||||
return { page: 'invite', inviteCode: code };
|
return { page: 'invite', inviteCode: code };
|
||||||
|
|
@ -169,7 +203,11 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const urlState = useMemo(() => parseUrlState(), []);
|
const urlState = useMemo(() => {
|
||||||
|
// Must run before any reads of window.location.search below.
|
||||||
|
restoreLastDashboardSession();
|
||||||
|
return parseUrlState();
|
||||||
|
}, []);
|
||||||
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||||
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
||||||
|
|
@ -276,7 +314,9 @@ export default function App() {
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
setPostAuthIntent(null);
|
setPostAuthIntent(null);
|
||||||
postAuthCheckoutReturnPathRef.current = null;
|
postAuthCheckoutReturnPathRef.current = null;
|
||||||
if (isAuthRequiredRoute(activePageRef.current)) {
|
// Only protected pages bounce home; the dashboard stays open in demo
|
||||||
|
// mode (server-enforced free zone) when the modal is dismissed.
|
||||||
|
if (isProtectedPage(activePageRef.current)) {
|
||||||
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||||
setRouteHash('');
|
setRouteHash('');
|
||||||
setActivePage('home');
|
setActivePage('home');
|
||||||
|
|
@ -300,8 +340,11 @@ export default function App() {
|
||||||
|
|
||||||
async function refreshOnStartup() {
|
async function refreshOnStartup() {
|
||||||
if (!returnedFromCheckout) {
|
if (!returnedFromCheckout) {
|
||||||
// Always refresh auth on startup to pick up server-side subscription changes.
|
// Refresh auth on startup to pick up server-side subscription changes,
|
||||||
refreshAuthRef.current().catch(() => {});
|
// but only when a token exists — logged-out visitors would just 401.
|
||||||
|
if (pb.authStore.token) {
|
||||||
|
refreshAuthRef.current().catch(() => {});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,8 +427,10 @@ export default function App() {
|
||||||
if (infoFeature) {
|
if (infoFeature) {
|
||||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||||
}
|
}
|
||||||
// Restore dashboard search params when navigating back
|
// Restore dashboard search params when navigating back, falling back to
|
||||||
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
|
// the last persisted session for first visits in this tab.
|
||||||
|
const search =
|
||||||
|
page === 'dashboard' ? dashboardSearchRef.current || readLastDashboardSearch() : '';
|
||||||
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
|
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
|
||||||
window.history.pushState({ page, hash: targetHash }, '', url);
|
window.history.pushState({ page, hash: targetHash }, '', url);
|
||||||
if (page === 'dashboard') {
|
if (page === 'dashboard') {
|
||||||
|
|
@ -527,19 +572,20 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [activePage, fetchSearches]);
|
}, [activePage, fetchSearches]);
|
||||||
|
|
||||||
const isAuthRequiredPage =
|
const isProtectedPageActive = isProtectedPage(activePage);
|
||||||
activePage === 'account' ||
|
// Only protected pages (account/saved) prompt for login on entry. The
|
||||||
activePage === 'saved' ||
|
// dashboard opens straight into demo mode (server-enforced free zone) so
|
||||||
(activePage === 'dashboard' && !mapUrlState.share);
|
// visitors can try it without logging in; the upgrade modal still appears
|
||||||
|
// when they pan outside the free zone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (isAuthRequiredPage && !user) {
|
if (isProtectedPageActive && !user) {
|
||||||
openAuthModal('login');
|
openAuthModal('login');
|
||||||
}
|
}
|
||||||
if (activePage === 'pricing' && hasFullAccess(user)) {
|
if (activePage === 'pricing' && hasFullAccess(user)) {
|
||||||
navigateTo('dashboard');
|
navigateTo('dashboard');
|
||||||
}
|
}
|
||||||
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
|
}, [activePage, authLoading, isProtectedPageActive, navigateTo, openAuthModal, user]);
|
||||||
|
|
||||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||||
|
|
||||||
|
|
@ -641,6 +687,8 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
) : activePage === 'learn' ? (
|
) : activePage === 'learn' ? (
|
||||||
<LearnPage />
|
<LearnPage />
|
||||||
|
) : activePage === 'terms' || activePage === 'privacy' ? (
|
||||||
|
<LegalPage kind={activePage} />
|
||||||
) : isSeoLandingPage(activePage) ? (
|
) : isSeoLandingPage(activePage) ? (
|
||||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||||
) : isSeoContentPage(activePage) ? (
|
) : isSeoContentPage(activePage) ? (
|
||||||
|
|
@ -662,7 +710,7 @@ export default function App() {
|
||||||
}}
|
}}
|
||||||
scrollTarget={routeHash}
|
scrollTarget={routeHash}
|
||||||
/>
|
/>
|
||||||
) : isAuthRequiredPage && !user ? (
|
) : isProtectedPageActive && !user ? (
|
||||||
<PageFallback />
|
<PageFallback />
|
||||||
) : activePage === 'invite' && inviteCode ? (
|
) : activePage === 'invite' && inviteCode ? (
|
||||||
<InvitePage
|
<InvitePage
|
||||||
|
|
@ -695,7 +743,10 @@ export default function App() {
|
||||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||||
onNavigateTo={navigateTo}
|
onNavigateTo={navigateTo}
|
||||||
onExportStateChange={setExportState}
|
onExportStateChange={setExportState}
|
||||||
onDashboardParamsChange={setDashboardParams}
|
onDashboardParamsChange={(params) => {
|
||||||
|
setDashboardParams(params);
|
||||||
|
if (!mapUrlState.share) persistLastDashboardParams(params);
|
||||||
|
}}
|
||||||
onDashboardReadyChange={setDashboardReady}
|
onDashboardReadyChange={setDashboardReady}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
initialTravelTime={mapUrlState.travelTime}
|
initialTravelTime={mapUrlState.travelTime}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import BottomIllustration from './BottomIllustration';
|
||||||
import { TickerValue } from '../ui/TickerValue';
|
import { TickerValue } from '../ui/TickerValue';
|
||||||
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
|
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
import { apiUrl } from '../../lib/api';
|
||||||
|
|
||||||
const BRAND_NAME = 'Perfect Postcode';
|
const BRAND_NAME = 'Perfect Postcode';
|
||||||
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
|
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
|
||||||
|
|
@ -163,11 +164,78 @@ function ProductDemoVideo() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PriceStripTier {
|
||||||
|
up_to: number | null;
|
||||||
|
price_pence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact pricing teaser under the hero CTAs: surfaces the current lifetime
|
||||||
|
* price and tier scarcity that otherwise hide behind the Pricing nav link.
|
||||||
|
*/
|
||||||
|
function PriceStrip({
|
||||||
|
onOpenPricing,
|
||||||
|
hidePricing,
|
||||||
|
}: {
|
||||||
|
onOpenPricing: () => void;
|
||||||
|
hidePricing?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [pricing, setPricing] = useState<{
|
||||||
|
licensed_count: number;
|
||||||
|
current_price_pence: number;
|
||||||
|
tiers: PriceStripTier[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hidePricing) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(apiUrl('pricing'), { signal: controller.signal })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then(setPricing)
|
||||||
|
.catch(() => {});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [hidePricing]);
|
||||||
|
|
||||||
|
if (hidePricing || !pricing) return null;
|
||||||
|
|
||||||
|
const price = `£${pricing.current_price_pence / 100}`;
|
||||||
|
const currentTier = pricing.tiers.find(
|
||||||
|
(tier) => tier.up_to === null || pricing.licensed_count < tier.up_to
|
||||||
|
);
|
||||||
|
const spotsRemaining =
|
||||||
|
currentTier?.up_to != null ? currentTier.up_to - pricing.licensed_count : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-warm-300 mb-2">
|
||||||
|
{pricing.current_price_pence === 0
|
||||||
|
? t('upgrade.freeForEarly')
|
||||||
|
: t('home.priceStrip', { price })}{' '}
|
||||||
|
{pricing.current_price_pence > 0 && spotsRemaining > 0 && (
|
||||||
|
<span className="font-semibold text-teal-300">
|
||||||
|
{spotsRemaining === 1
|
||||||
|
? t('home.priceStripSpots', { count: spotsRemaining })
|
||||||
|
: t('home.priceStripSpotsPlural', { count: spotsRemaining })}
|
||||||
|
</span>
|
||||||
|
)}{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent('CTA Click', { location: 'hero', label: 'price_strip' });
|
||||||
|
onOpenPricing();
|
||||||
|
}}
|
||||||
|
className="underline decoration-dotted underline-offset-2 text-teal-300 hover:text-teal-200"
|
||||||
|
>
|
||||||
|
{t('home.priceStripCta')}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage({
|
export default function HomePage({
|
||||||
onOpenDashboard,
|
onOpenDashboard,
|
||||||
onOpenPricing: _onOpenPricing,
|
onOpenPricing,
|
||||||
theme = 'light',
|
theme = 'light',
|
||||||
hidePricing: _hidePricing,
|
hidePricing,
|
||||||
}: {
|
}: {
|
||||||
onOpenDashboard: () => void;
|
onOpenDashboard: () => void;
|
||||||
onOpenPricing: () => void;
|
onOpenPricing: () => void;
|
||||||
|
|
@ -327,7 +395,7 @@ export default function HomePage({
|
||||||
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
|
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
|
||||||
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
|
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||||
|
|
@ -347,6 +415,8 @@ export default function HomePage({
|
||||||
{t('home.seeTheDifference')}
|
{t('home.seeTheDifference')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
|
||||||
|
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p>
|
||||||
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
|
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
|
||||||
<div className="home-hero-stat">
|
<div className="home-hero-stat">
|
||||||
<div className="home-hero-stat-value">
|
<div className="home-hero-stat-value">
|
||||||
|
|
@ -356,7 +426,7 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero-stat">
|
<div className="home-hero-stat">
|
||||||
<div className="home-hero-stat-value">
|
<div className="home-hero-stat-value">
|
||||||
<TickerValue text="56" active={statsActive} />
|
<TickerValue text="40+" active={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
|
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Good+ primary schools within 2km',
|
name: 'Good+ primary school catchments',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
group: 'Schools',
|
group: 'Schools',
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -300,7 +300,7 @@ function interpolateViewState(progress: number): ViewState {
|
||||||
function demoSliderStep(feature: FeatureMeta): number {
|
function demoSliderStep(feature: FeatureMeta): number {
|
||||||
if (feature.name === 'Estimated price') return 1000;
|
if (feature.name === 'Estimated price') return 1000;
|
||||||
if (feature.name === 'Noise (dB)') return 0.05;
|
if (feature.name === 'Noise (dB)') return 0.05;
|
||||||
if (feature.name === 'Good+ primary schools within 2km') return 0.01;
|
if (feature.name === 'Good+ primary school catchments') return 0.01;
|
||||||
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
|
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
|
||||||
return feature.step ?? 1;
|
return feature.step ?? 1;
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +350,7 @@ function FilterPreviewRow({
|
||||||
const shortLabelKeys = {
|
const shortLabelKeys = {
|
||||||
'Estimated price': 'home.showcaseFeaturePriceShort',
|
'Estimated price': 'home.showcaseFeaturePriceShort',
|
||||||
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
|
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
|
||||||
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
|
'Good+ primary school catchments': 'home.showcaseFeatureSchoolsShort',
|
||||||
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
|
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
|
||||||
} as const;
|
} as const;
|
||||||
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
|
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
|
||||||
|
|
@ -406,7 +406,7 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunc
|
||||||
if (feature.name === 'Noise (dB)') {
|
if (feature.name === 'Noise (dB)') {
|
||||||
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
|
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
|
||||||
}
|
}
|
||||||
if (feature.name === 'Good+ primary schools within 2km') {
|
if (feature.name === 'Good+ primary school catchments') {
|
||||||
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
|
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
|
||||||
}
|
}
|
||||||
if (feature.name === 'Travel time to nearest train or tube station (min)') {
|
if (feature.name === 'Travel time to nearest train or tube station (min)') {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,52 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { tDynamic } from '../../i18n';
|
import { tDynamic } from '../../i18n';
|
||||||
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
|
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
|
||||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||||
|
import { PlayIcon } from '../ui/icons';
|
||||||
import { SubNav } from '../ui/SubNav';
|
import { SubNav } from '../ui/SubNav';
|
||||||
|
import Footer from '../ui/Footer';
|
||||||
|
|
||||||
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
|
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'videos' | 'support';
|
||||||
|
|
||||||
|
// Social-media ad cuts rendered by the recorder pipeline (video/src/storyboard.ts).
|
||||||
|
// Each `<slug>.mp4` is a 9:16 clip with a matching `<slug>.jpg` poster in /public/video.
|
||||||
|
const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
|
||||||
|
{ slug: 'ad-01-say-it', titleKey: 'learnPage.video01Title', descKey: 'learnPage.video01Desc' },
|
||||||
|
{
|
||||||
|
slug: 'ad-02-twenty-minute-map',
|
||||||
|
titleKey: 'learnPage.video02Title',
|
||||||
|
descKey: 'learnPage.video02Desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ad-03-postcode-files',
|
||||||
|
titleKey: 'learnPage.video03Title',
|
||||||
|
descKey: 'learnPage.video03Desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ad-04-quiet-streets',
|
||||||
|
titleKey: 'learnPage.video04Title',
|
||||||
|
descKey: 'learnPage.video04Desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ad-05-school-run',
|
||||||
|
titleKey: 'learnPage.video05Title',
|
||||||
|
descKey: 'learnPage.video05Desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ad-06-waitrose-test',
|
||||||
|
titleKey: 'learnPage.video06Title',
|
||||||
|
descKey: 'learnPage.video06Desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ad-07-renters-map',
|
||||||
|
titleKey: 'learnPage.video07Title',
|
||||||
|
descKey: 'learnPage.video07Desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ad-08-cheap-insurance',
|
||||||
|
titleKey: 'learnPage.video08Title',
|
||||||
|
descKey: 'learnPage.video08Desc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface DataSourceDef {
|
interface DataSourceDef {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -176,6 +219,68 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SocialVideoCard({
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const videoSrc = `/video/${slug}.mp4`;
|
||||||
|
const posterSrc = `/video/${slug}.jpg`;
|
||||||
|
|
||||||
|
const playVideo = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
setIsLoaded(true);
|
||||||
|
if (!video) return;
|
||||||
|
if (video.getAttribute('src') !== videoSrc) {
|
||||||
|
video.src = videoSrc;
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
void video.play().catch(() => setIsPlaying(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={isLoaded ? videoSrc : undefined}
|
||||||
|
poster={posterSrc}
|
||||||
|
controls={isPlaying}
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
className="block aspect-[9/16] w-full bg-navy-950 object-contain"
|
||||||
|
aria-label={title}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
{!isPlaying && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={playVideo}
|
||||||
|
className="pointer-events-auto group flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75"
|
||||||
|
aria-label={t('home.playProductDemo')}
|
||||||
|
>
|
||||||
|
<PlayIcon className="h-8 w-8 -translate-x-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-base font-bold text-warm-900 dark:text-warm-100">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-warm-600 dark:text-warm-300">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LearnPage() {
|
export default function LearnPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [tab, setTab] = useState<LearnTab>('faq');
|
const [tab, setTab] = useState<LearnTab>('faq');
|
||||||
|
|
@ -197,6 +302,7 @@ export default function LearnPage() {
|
||||||
{ key: 'faq', label: t('learnPage.faq') },
|
{ key: 'faq', label: t('learnPage.faq') },
|
||||||
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
||||||
{ key: 'articles', label: t('learnPage.articles') },
|
{ key: 'articles', label: t('learnPage.articles') },
|
||||||
|
{ key: 'videos', label: t('learnPage.videos') },
|
||||||
{ key: 'support', label: t('learnPage.support') },
|
{ key: 'support', label: t('learnPage.support') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -268,6 +374,9 @@ export default function LearnPage() {
|
||||||
} else if (hash === 'articles') {
|
} else if (hash === 'articles') {
|
||||||
setTab('articles');
|
setTab('articles');
|
||||||
setHighlightedId(null);
|
setHighlightedId(null);
|
||||||
|
} else if (hash === 'videos') {
|
||||||
|
setTab('videos');
|
||||||
|
setHighlightedId(null);
|
||||||
} else if (hash === 'support') {
|
} else if (hash === 'support') {
|
||||||
setTab('support');
|
setTab('support');
|
||||||
setHighlightedId(null);
|
setHighlightedId(null);
|
||||||
|
|
@ -300,141 +409,163 @@ export default function LearnPage() {
|
||||||
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
|
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
|
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
|
||||||
{tab === 'data-sources' ? (
|
<div className="flex flex-1 flex-col">
|
||||||
<>
|
{tab === 'data-sources' ? (
|
||||||
<div className="flex-1">
|
<>
|
||||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||||
{t('learnPage.dataSources')}
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
</h1>
|
{t('learnPage.dataSources')}
|
||||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
</h1>
|
||||||
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||||
</p>
|
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{DATA_SOURCE_DEFS.map((source) => {
|
|
||||||
const keys = DS_KEYS[source.id];
|
|
||||||
const [nameKey, originKey, useKey] = keys;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={source.id}
|
|
||||||
id={source.id}
|
|
||||||
ref={(el) => {
|
|
||||||
cardRefs.current[source.id] = el;
|
|
||||||
}}
|
|
||||||
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
|
||||||
highlightedId === source.id
|
|
||||||
? 'border-teal-400 ring-2 ring-teal-400'
|
|
||||||
: 'border-warm-200 dark:border-warm-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
|
||||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
|
||||||
{tDynamic(nameKey)}
|
|
||||||
</h2>
|
|
||||||
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
|
||||||
{source.license}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
|
||||||
{t('learnPage.source')} {tDynamic(originKey)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
|
||||||
{tDynamic(useKey)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
|
||||||
<div className="max-w-5xl mx-auto">
|
|
||||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
|
||||||
{t('learnPage.attribution')}
|
|
||||||
</h2>
|
|
||||||
<ul className="space-y-1.5 text-sm">
|
|
||||||
<li>{t('learnPage.attrLandRegistry')}</li>
|
|
||||||
<li>
|
|
||||||
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
|
|
||||||
</li>
|
|
||||||
<li>{t('learnPage.attrOs')}</li>
|
|
||||||
<li>{t('learnPage.attrTfl')}</li>
|
|
||||||
<li>
|
|
||||||
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
|
|
||||||
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
) : tab === 'faq' ? (
|
|
||||||
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
|
||||||
{t('learnPage.faq')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
|
|
||||||
<div className="space-y-8">
|
|
||||||
{FAQ_SECTIONS.map((section) => (
|
|
||||||
<div key={section.title}>
|
|
||||||
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
|
|
||||||
{section.title}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{section.items.map((item, index) => (
|
|
||||||
<FAQItemCard key={index} question={item.question} answer={item.answer} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : tab === 'articles' ? (
|
|
||||||
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
|
||||||
{t('learnPage.articles')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{seoPageLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.path}
|
|
||||||
href={link.path}
|
|
||||||
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
|
||||||
>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
|
||||||
{link.eyebrow}
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
|
||||||
{link.title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
|
||||||
{link.description}
|
|
||||||
</p>
|
</p>
|
||||||
</a>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
))}
|
{DATA_SOURCE_DEFS.map((source) => {
|
||||||
|
const keys = DS_KEYS[source.id];
|
||||||
|
const [nameKey, originKey, useKey] = keys;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={source.id}
|
||||||
|
id={source.id}
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[source.id] = el;
|
||||||
|
}}
|
||||||
|
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
||||||
|
highlightedId === source.id
|
||||||
|
? 'border-teal-400 ring-2 ring-teal-400'
|
||||||
|
: 'border-warm-200 dark:border-warm-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||||
|
{tDynamic(nameKey)}
|
||||||
|
</h2>
|
||||||
|
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
|
||||||
|
{source.license}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||||
|
{t('learnPage.source')} {tDynamic(originKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
||||||
|
{tDynamic(useKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||||
|
{t('learnPage.attribution')}
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
<li>{t('learnPage.attrLandRegistry')}</li>
|
||||||
|
<li>
|
||||||
|
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
|
||||||
|
</li>
|
||||||
|
<li>{t('learnPage.attrOs')}</li>
|
||||||
|
<li>{t('learnPage.attrTfl')}</li>
|
||||||
|
<li>
|
||||||
|
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
|
||||||
|
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : tab === 'faq' ? (
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
|
{t('learnPage.faq')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{FAQ_SECTIONS.map((section) => (
|
||||||
|
<div key={section.title}>
|
||||||
|
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{section.items.map((item, index) => (
|
||||||
|
<FAQItemCard key={index} question={item.question} answer={item.answer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : tab === 'articles' ? (
|
||||||
) : (
|
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
{t('learnPage.articles')}
|
||||||
{t('learnPage.support')}
|
</h1>
|
||||||
</h1>
|
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
|
{t('learnPage.articlesIntro')}
|
||||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
|
||||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
|
||||||
<a
|
|
||||||
href="mailto:support@perfect-postcode.co.uk"
|
|
||||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
|
||||||
>
|
|
||||||
support@perfect-postcode.co.uk
|
|
||||||
</a>
|
|
||||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
|
||||||
{t('accountPage.responseTime')}
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{seoPageLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.path}
|
||||||
|
href={link.path}
|
||||||
|
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
||||||
|
{link.eyebrow}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
||||||
|
{link.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||||
|
{link.description}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : tab === 'videos' ? (
|
||||||
)}
|
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
|
{t('learnPage.videosTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.videosIntro')}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
|
||||||
|
{SOCIAL_VIDEOS.map((video) => (
|
||||||
|
<SocialVideoCard
|
||||||
|
key={video.slug}
|
||||||
|
slug={video.slug}
|
||||||
|
title={tDynamic(video.titleKey)}
|
||||||
|
description={tDynamic(video.descKey)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
|
{t('learnPage.support')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
|
||||||
|
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||||
|
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||||
|
<a
|
||||||
|
href="mailto:support@perfect-postcode.co.uk"
|
||||||
|
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||||
|
>
|
||||||
|
support@perfect-postcode.co.uk
|
||||||
|
</a>
|
||||||
|
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||||
|
{t('accountPage.responseTime')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1172,10 +1172,7 @@ export default memo(function Map({
|
||||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||||
: [countRange.min, countRange.max]
|
: [countRange.min, countRange.max]
|
||||||
}
|
}
|
||||||
totalCount={
|
totalCount={totalCountProp}
|
||||||
totalCountProp ??
|
|
||||||
(usePostcodeView ? postcodeCountRange.total : countRange.total)
|
|
||||||
}
|
|
||||||
showCancel={false}
|
showCancel={false}
|
||||||
onCancel={onCancelPin}
|
onCancel={onCancelPin}
|
||||||
mode="density"
|
mode="density"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||||
|
import { boundsToCenterZoom } from '../../lib/fit-bounds';
|
||||||
import type { OverlayId } from '../../lib/overlays';
|
import type { OverlayId } from '../../lib/overlays';
|
||||||
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||||
import type { BasemapId } from '../../lib/basemaps';
|
import type { BasemapId } from '../../lib/basemaps';
|
||||||
|
|
@ -257,6 +258,14 @@ export default function MapPage({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Move the camera to where the matches actually are — flying to the
|
||||||
|
// travel-time anchor often lands on a viewport with zero matches.
|
||||||
|
if (result.matchBounds) {
|
||||||
|
const target = boundsToCenterZoom(result.matchBounds);
|
||||||
|
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const firstTravelTime = representable[0]?.tt;
|
const firstTravelTime = representable[0]?.tt;
|
||||||
if (!firstTravelTime?.slug) return;
|
if (!firstTravelTime?.slug) return;
|
||||||
|
|
||||||
|
|
@ -482,7 +491,15 @@ export default function MapPage({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
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(() => {
|
const handleZoomToFreeZone = useCallback(() => {
|
||||||
|
setUpgradeModalDismissed(true);
|
||||||
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
|
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
|
||||||
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -576,7 +593,6 @@ export default function MapPage({
|
||||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||||
const densityLabel = t('mapLegend.historicalMatches');
|
const densityLabel = t('mapLegend.historicalMatches');
|
||||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
|
||||||
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
||||||
const mapViewFeature = useMapViewFeature(viewFeature);
|
const mapViewFeature = useMapViewFeature(viewFeature);
|
||||||
const mobileDensityRange = useMobileDensityRange(mapData);
|
const mobileDensityRange = useMobileDensityRange(mapData);
|
||||||
|
|
@ -661,7 +677,13 @@ export default function MapPage({
|
||||||
}, [onDashboardReadyChange]);
|
}, [onDashboardReadyChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
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]);
|
}, [mapData.licenseRequired]);
|
||||||
|
|
||||||
if (screenshotMode) {
|
if (screenshotMode) {
|
||||||
|
|
@ -856,22 +878,25 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const upgradeModal = mapData.licenseRequired ? (
|
const upgradeModal =
|
||||||
<Suspense fallback={null}>
|
mapData.licenseRequired && !upgradeModalDismissed ? (
|
||||||
<UpgradeModal
|
<Suspense fallback={null}>
|
||||||
isLoggedIn={!!user}
|
<UpgradeModal
|
||||||
onLoginClick={() =>
|
isLoggedIn={!!user}
|
||||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
onLoginClick={() =>
|
||||||
}
|
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||||
onRegisterClick={() =>
|
}
|
||||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
onRegisterClick={() =>
|
||||||
}
|
onCheckoutRegisterClick
|
||||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
? onCheckoutRegisterClick(checkoutReturnPath)
|
||||||
onZoomToFreeZone={handleZoomToFreeZone}
|
: onRegisterClick()
|
||||||
isShareReturn={!!shareReturnViewRef.current}
|
}
|
||||||
/>
|
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||||
</Suspense>
|
onZoomToFreeZone={handleZoomToFreeZone}
|
||||||
) : null;
|
isShareReturn={!!shareReturnViewRef.current}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -980,7 +1005,7 @@ export default function MapPage({
|
||||||
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
densityLabel={densityLabel}
|
densityLabel={densityLabel}
|
||||||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
totalCount={filterCounts.total ?? undefined}
|
||||||
poiPaneOpen={poiPaneOpen}
|
poiPaneOpen={poiPaneOpen}
|
||||||
onTogglePoiPane={handleTogglePoiPane}
|
onTogglePoiPane={handleTogglePoiPane}
|
||||||
poiPane={renderPOIPane()}
|
poiPane={renderPOIPane()}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,16 @@ interface PriceHistoryChartProps {
|
||||||
points: PricePoint[];
|
points: PricePoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
|
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
||||||
const HEIGHT = 120;
|
const HEIGHT = 120;
|
||||||
const PRICE_SCALE_TOP_PERCENTILE = 95;
|
const PRICE_SCALE_TOP_PERCENTILE = 95;
|
||||||
const priceFmt = { prefix: '£' };
|
const priceFmt = { prefix: '£' };
|
||||||
|
|
||||||
|
/** Ticks are nice round values; "£800.0k" both clips and reads worse than "£800k". */
|
||||||
|
function formatTick(tick: number): string {
|
||||||
|
return formatValue(tick, priceFmt).replace(/\.0(?=[kM])/, '');
|
||||||
|
}
|
||||||
|
|
||||||
interface PriceScale {
|
interface PriceScale {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
|
@ -148,7 +153,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||||
className="fill-warm-500 dark:fill-warm-400"
|
className="fill-warm-500 dark:fill-warm-400"
|
||||||
fontSize={10}
|
fontSize={10}
|
||||||
>
|
>
|
||||||
{formatValue(tick, priceFmt)}
|
{formatTick(tick)}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||||
import { formatFilterValue, formatNumber } from '../../lib/format';
|
import { SliderLabels } from './filters/SliderLabels';
|
||||||
|
import { formatNumber } from '../../lib/format';
|
||||||
|
import type { FeatureMeta } from '../../types';
|
||||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||||
import {
|
import {
|
||||||
MAX_TRAVEL_MINUTES,
|
MAX_TRAVEL_MINUTES,
|
||||||
|
|
@ -56,7 +58,7 @@ export function TravelTimeCard({
|
||||||
dragValue,
|
dragValue,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onSetDestination,
|
onSetDestination,
|
||||||
onTimeRangeChange: _onTimeRangeChange,
|
onTimeRangeChange,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragChange,
|
onDragChange,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
|
@ -86,6 +88,17 @@ export function TravelTimeCard({
|
||||||
const sliderMax = MAX_TRAVEL_MINUTES;
|
const sliderMax = MAX_TRAVEL_MINUTES;
|
||||||
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
||||||
|
|
||||||
|
// Synthetic feature so the time labels reuse the shared SliderLabels (matching
|
||||||
|
// every other filter card) — editable, thumb-following, with the minute unit.
|
||||||
|
const travelFeature: FeatureMeta = {
|
||||||
|
name: 'travelTime',
|
||||||
|
type: 'numeric',
|
||||||
|
min: sliderMin,
|
||||||
|
max: sliderMax,
|
||||||
|
suffix: ` ${t('common.minute')}`,
|
||||||
|
raw: true,
|
||||||
|
};
|
||||||
|
|
||||||
const ModeIcon = MODE_ICONS[mode];
|
const ModeIcon = MODE_ICONS[mode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -211,14 +224,17 @@ export function TravelTimeCard({
|
||||||
onPointerDown={() => onDragStart(displayRange)}
|
onPointerDown={() => onDragStart(displayRange)}
|
||||||
onPointerUp={() => onDragEnd()}
|
onPointerUp={() => onDragEnd()}
|
||||||
/>
|
/>
|
||||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
<SliderLabels
|
||||||
<span className="absolute left-0">
|
min={sliderMin}
|
||||||
{formatFilterValue(displayRange[0])} {t('common.minute')}
|
max={sliderMax}
|
||||||
</span>
|
value={[displayRange[0], displayRange[1]]}
|
||||||
<span className="absolute right-0">
|
isAtMin={displayRange[0] <= sliderMin}
|
||||||
{formatFilterValue(displayRange[1])} {t('common.minute')}
|
isAtMax={displayRange[1] >= sliderMax}
|
||||||
</span>
|
raw
|
||||||
</div>
|
showUnit
|
||||||
|
feature={travelFeature}
|
||||||
|
onValueChange={onTimeRangeChange}
|
||||||
|
/>
|
||||||
{filterImpact != null && filterImpact > 0 && (
|
{filterImpact != null && filterImpact > 0 && (
|
||||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||||
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
|
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
getSchoolFilterConfig,
|
getSchoolFilterConfig,
|
||||||
getSchoolFilterMeta,
|
getSchoolFilterMeta,
|
||||||
replaceSchoolFilterKeySelection,
|
replaceSchoolFilterKeySelection,
|
||||||
type SchoolDistance,
|
|
||||||
type SchoolPhase,
|
type SchoolPhase,
|
||||||
type SchoolRating,
|
type SchoolRating,
|
||||||
} from '../../../lib/school-filter';
|
} from '../../../lib/school-filter';
|
||||||
|
|
@ -74,7 +73,6 @@ export function SchoolFilterCard({
|
||||||
next: Partial<{
|
next: Partial<{
|
||||||
phase: SchoolPhase;
|
phase: SchoolPhase;
|
||||||
rating: SchoolRating;
|
rating: SchoolRating;
|
||||||
distance: SchoolDistance;
|
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
|
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
|
||||||
|
|
@ -180,35 +178,6 @@ export function SchoolFilterCard({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
|
||||||
{t('filters.distance')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={segmentedClass}
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={t('filters.schoolDistance')}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={config.distance === 2}
|
|
||||||
onClick={() => replaceSchoolFeature({ distance: 2 })}
|
|
||||||
className={optionClass(config.distance === 2)}
|
|
||||||
>
|
|
||||||
2 km
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={config.distance === 5}
|
|
||||||
onClick={() => replaceSchoolFeature({ distance: 5 })}
|
|
||||||
className={optionClass(config.distance === 5)}
|
|
||||||
>
|
|
||||||
5 km
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { logNonAbortError } from '../../lib/api';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { apiUrl } from '../../lib/api';
|
import { apiUrl } from '../../lib/api';
|
||||||
import HexCanvas from '../home/HexCanvas';
|
import HexCanvas from '../home/HexCanvas';
|
||||||
|
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
|
||||||
|
|
||||||
// Feature list keys — resolved inside the component via t()
|
// Feature list keys — resolved inside the component via t()
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ export default function PricingPage({
|
||||||
onRegisterClick?: () => void;
|
onRegisterClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isDark = useIsDarkTheme();
|
||||||
const license = useLicense();
|
const license = useLicense();
|
||||||
const [pricing, setPricing] = useState<PricingData | null>(null);
|
const [pricing, setPricing] = useState<PricingData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -137,17 +139,23 @@ export default function PricingPage({
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
||||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-navy-950 via-navy-900 to-navy-950" />
|
<div className="absolute inset-0 bg-gradient-to-b from-warm-100 via-warm-50 to-warm-100 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950" />
|
||||||
<HexCanvas isDark />
|
<HexCanvas isDark={isDark} />
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
|
<div className="absolute inset-0 hidden dark:block bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
|
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-white mb-3">
|
||||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
|
{t('pricingPage.title')}
|
||||||
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
|
</h1>
|
||||||
|
<p className="text-lg text-warm-600 dark:text-warm-300 max-w-lg mx-auto">
|
||||||
|
{t('pricingPage.subtitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-5 text-warm-700 dark:text-warm-200 font-semibold">
|
||||||
|
{t('pricingPage.lessThanSurvey')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
|
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
|
||||||
|
|
@ -203,7 +211,7 @@ export default function PricingPage({
|
||||||
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
||||||
isCurrent
|
isCurrent
|
||||||
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
||||||
: 'border-warm-700 shadow-md'
|
: 'border-warm-300 dark:border-warm-700 shadow-md'
|
||||||
} ${isFilled ? 'opacity-60' : ''}`}
|
} ${isFilled ? 'opacity-60' : ''}`}
|
||||||
>
|
>
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
|
|
@ -266,11 +274,6 @@ export default function PricingPage({
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isFilled && (
|
|
||||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
|
||||||
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar for current tier */}
|
{/* Progress bar for current tier */}
|
||||||
|
|
@ -308,10 +311,14 @@ export default function PricingPage({
|
||||||
{license.error}
|
{license.error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isFree && (
|
{isFree ? (
|
||||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||||
{t('pricingPage.noCreditCard')}
|
{t('pricingPage.noCreditCard')}
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||||
|
{t('pricingPage.moneyBack')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : isFilled ? (
|
) : isFilled ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useEffect, useId } from 'react';
|
import { useState, useCallback, useEffect, useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { CloseIcon } from './icons/CloseIcon';
|
import { CloseIcon } from './icons/CloseIcon';
|
||||||
import { GoogleIcon } from './icons/GoogleIcon';
|
import { GoogleIcon } from './icons/GoogleIcon';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
|
@ -106,7 +106,7 @@ export default function AuthModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-[10001] flex items-center justify-center"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
|
|
@ -283,6 +283,32 @@ export default function AuthModal({
|
||||||
{t('auth.backToLogin')}
|
{t('auth.backToLogin')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'register' && (
|
||||||
|
<p className="text-[11px] leading-relaxed text-center text-warm-400 dark:text-warm-500">
|
||||||
|
<Trans
|
||||||
|
i18nKey="auth.registerConsent"
|
||||||
|
components={{
|
||||||
|
terms: (
|
||||||
|
<a
|
||||||
|
href="/terms"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="underline hover:text-teal-600 dark:hover:text-teal-400"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
privacy: (
|
||||||
|
<a
|
||||||
|
href="/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="underline hover:text-teal-600 dark:hover:text-teal-400"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export type Page =
|
||||||
| 'data-sources'
|
| 'data-sources'
|
||||||
| 'methodology'
|
| 'methodology'
|
||||||
| 'privacy-security'
|
| 'privacy-security'
|
||||||
|
| 'terms'
|
||||||
|
| 'privacy'
|
||||||
| 'account'
|
| 'account'
|
||||||
| 'saved'
|
| 'saved'
|
||||||
| 'invite';
|
| 'invite';
|
||||||
|
|
@ -58,6 +60,8 @@ export const PAGE_PATHS: Record<Page, string> = {
|
||||||
'data-sources': '/data-sources',
|
'data-sources': '/data-sources',
|
||||||
methodology: '/methodology',
|
methodology: '/methodology',
|
||||||
'privacy-security': '/privacy-security',
|
'privacy-security': '/privacy-security',
|
||||||
|
terms: '/terms',
|
||||||
|
privacy: '/privacy',
|
||||||
saved: '/saved',
|
saved: '/saved',
|
||||||
account: '/account',
|
account: '/account',
|
||||||
invite: '/invite',
|
invite: '/invite',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ interface SearchHook {
|
||||||
handleInputChange: (value: string) => void;
|
handleInputChange: (value: string) => void;
|
||||||
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
||||||
showEmptySearches: () => void;
|
showEmptySearches: () => void;
|
||||||
|
close?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Addresses arrive in raw ALL-CAPS Land Registry casing; title-case for display. */
|
||||||
|
function titleCaseAddress(address: string): string {
|
||||||
|
return address.toLowerCase().replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlaceSearchInputProps {
|
interface PlaceSearchInputProps {
|
||||||
|
|
@ -65,6 +71,9 @@ export function PlaceSearchInput({
|
||||||
const dropdown = showDropdown && (
|
const dropdown = showDropdown && (
|
||||||
<div
|
<div
|
||||||
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
|
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
|
||||||
|
// Keep focus on the input while interacting with the list (incl. its
|
||||||
|
// scrollbar) so the blur-close below doesn't fire mid-click.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
style={
|
style={
|
||||||
portal && dropdownPos
|
portal && dropdownPos
|
||||||
? {
|
? {
|
||||||
|
|
@ -116,8 +125,11 @@ export function PlaceSearchInput({
|
||||||
) : result.type === 'address' ? (
|
) : result.type === 'address' ? (
|
||||||
<>
|
<>
|
||||||
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||||
<span className="min-w-0 text-warm-700 dark:text-warm-200">
|
<span
|
||||||
<span className="block truncate">{result.address}</span>
|
className="min-w-0 text-warm-700 dark:text-warm-200"
|
||||||
|
title={`${titleCaseAddress(result.address)}, ${result.postcode}`}
|
||||||
|
>
|
||||||
|
<span className="block truncate">{titleCaseAddress(result.address)}</span>
|
||||||
<span className="block truncate text-warm-400 dark:text-warm-500">
|
<span className="block truncate text-warm-400 dark:text-warm-500">
|
||||||
{result.postcode}
|
{result.postcode}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -126,7 +138,10 @@ export function PlaceSearchInput({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||||
<span className="text-warm-700 dark:text-warm-200">
|
<span
|
||||||
|
className="text-warm-700 dark:text-warm-200"
|
||||||
|
title={result.city ? `${result.name} (${result.city})` : result.name}
|
||||||
|
>
|
||||||
{result.name}
|
{result.name}
|
||||||
{result.city && (
|
{result.city && (
|
||||||
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
|
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
|
||||||
|
|
@ -154,6 +169,9 @@ export function PlaceSearchInput({
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
search.showEmptySearches();
|
search.showEmptySearches();
|
||||||
}}
|
}}
|
||||||
|
// Without this, instances whose parents lack outside-click handling
|
||||||
|
// leave a detached dropdown floating over the map.
|
||||||
|
onBlur={() => search.close?.()}
|
||||||
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
||||||
aria-label={ariaLabel ?? placeholder}
|
aria-label={ariaLabel ?? placeholder}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|
|
||||||
|
|
@ -176,10 +176,9 @@ export function useDeckLayers({
|
||||||
enumPaletteRef.current = enumPalette;
|
enumPaletteRef.current = enumPalette;
|
||||||
|
|
||||||
const countRange = useMemo(() => {
|
const countRange = useMemo(() => {
|
||||||
if (data.length === 0) return { min: 0, max: 1, total: 0 };
|
if (data.length === 0) return { min: 0, max: 1 };
|
||||||
let min = Infinity;
|
let min = Infinity;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
let total = 0;
|
|
||||||
for (const d of data) {
|
for (const d of data) {
|
||||||
if (viewportBounds) {
|
if (viewportBounds) {
|
||||||
if (
|
if (
|
||||||
|
|
@ -191,24 +190,22 @@ export function useDeckLayers({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const c = d.count as number;
|
const c = d.count as number;
|
||||||
total += c;
|
|
||||||
if (c <= 0) continue;
|
if (c <= 0) continue;
|
||||||
if (c < min) min = c;
|
if (c < min) min = c;
|
||||||
if (c > max) max = c;
|
if (c > max) max = c;
|
||||||
}
|
}
|
||||||
if (min === Infinity) return { min: 0, max: 1, total: 0 };
|
if (min === Infinity) return { min: 0, max: 1 };
|
||||||
if (min === max) return { min, max: min + 1, total };
|
if (min === max) return { min, max: min + 1 };
|
||||||
return { min, max, total };
|
return { min, max };
|
||||||
}, [data, viewportBounds]);
|
}, [data, viewportBounds]);
|
||||||
|
|
||||||
const countRangeRef = useRef(countRange);
|
const countRangeRef = useRef(countRange);
|
||||||
countRangeRef.current = countRange;
|
countRangeRef.current = countRange;
|
||||||
|
|
||||||
const postcodeCountRange = useMemo(() => {
|
const postcodeCountRange = useMemo(() => {
|
||||||
if (postcodeData.length === 0) return { min: 0, max: 1, total: 0 };
|
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||||
let min = Infinity;
|
let min = Infinity;
|
||||||
let max = -Infinity;
|
let max = -Infinity;
|
||||||
let total = 0;
|
|
||||||
for (const d of postcodeData) {
|
for (const d of postcodeData) {
|
||||||
if (viewportBounds) {
|
if (viewportBounds) {
|
||||||
const [lng, lat] = d.properties.centroid as [number, number];
|
const [lng, lat] = d.properties.centroid as [number, number];
|
||||||
|
|
@ -221,14 +218,13 @@ export function useDeckLayers({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const c = d.properties.count;
|
const c = d.properties.count;
|
||||||
total += c;
|
|
||||||
if (c <= 0) continue;
|
if (c <= 0) continue;
|
||||||
if (c < min) min = c;
|
if (c < min) min = c;
|
||||||
if (c > max) max = c;
|
if (c > max) max = c;
|
||||||
}
|
}
|
||||||
if (min === Infinity) return { min: 0, max: 1, total: 0 };
|
if (min === Infinity) return { min: 0, max: 1 };
|
||||||
if (min === max) return { min, max: min + 1, total };
|
if (min === max) return { min, max: min + 1 };
|
||||||
return { min, max, total };
|
return { min, max };
|
||||||
}, [postcodeData, viewportBounds]);
|
}, [postcodeData, viewportBounds]);
|
||||||
|
|
||||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||||
|
|
|
||||||
|
|
@ -180,12 +180,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
undoStackRef.current.push(prev);
|
undoStackRef.current.push(prev);
|
||||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||||
if (name === SCHOOL_FILTER_NAME) {
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
const schoolKey = createSchoolFilterKey(
|
const schoolKey = createSchoolFilterKey('primary', 'good', schoolFilterIdRef.current++);
|
||||||
'primary',
|
|
||||||
'good',
|
|
||||||
2,
|
|
||||||
schoolFilterIdRef.current++
|
|
||||||
);
|
|
||||||
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
|
const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
|
||||||
const defaultSchoolFeature = defaultSchoolFeatureName
|
const defaultSchoolFeature = defaultSchoolFeatureName
|
||||||
? features.find((feature) => feature.name === defaultSchoolFeatureName)
|
? features.find((feature) => feature.name === defaultSchoolFeatureName)
|
||||||
|
|
|
||||||
|
|
@ -1111,6 +1111,34 @@ const de: Translations = {
|
||||||
articlesIntro:
|
articlesIntro:
|
||||||
'Durchsuche die öffentlichen Leitfäden zu Immobiliensuche, Pendeln, Schulen, Postleitzahlprüfungen, regionalen Vergleichen, Datenabdeckung, Methodik und Datenschutz.',
|
'Durchsuche die öffentlichen Leitfäden zu Immobiliensuche, Pendeln, Schulen, Postleitzahlprüfungen, regionalen Vergleichen, Datenabdeckung, Methodik und Datenschutz.',
|
||||||
supportIntro: 'Hast du eine Frage? Schau in unsere FAQ oder kontaktiere uns direkt.',
|
supportIntro: 'Hast du eine Frage? Schau in unsere FAQ oder kontaktiere uns direkt.',
|
||||||
|
videos: 'Videos',
|
||||||
|
videosTitle: 'Social-Media-Videos',
|
||||||
|
videosIntro:
|
||||||
|
'Kurze Clips aus unseren Social-Media-Kanälen – jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
|
||||||
|
video01Title: 'Ein Satz, jede Postleitzahl',
|
||||||
|
video01Desc:
|
||||||
|
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
|
||||||
|
video02Title: 'Die 20-Minuten-Karte',
|
||||||
|
video02Desc:
|
||||||
|
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
|
||||||
|
video03Title: 'Jede Postleitzahl hat eine Akte',
|
||||||
|
video03Desc:
|
||||||
|
'Tippe auf eine Postleitzahl und lies ihre Akte – verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
|
||||||
|
video04Title: 'Ein Foto kann man nicht hören',
|
||||||
|
video04Desc:
|
||||||
|
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
|
||||||
|
video05Title: 'Die Schulweg-Karte',
|
||||||
|
video05Desc:
|
||||||
|
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget – der Familienwunsch, über eine ganze Stadt kartiert.',
|
||||||
|
video06Title: 'Der Waitrose-Test',
|
||||||
|
video06Desc:
|
||||||
|
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park – filtere nach dem Leben, nicht nur nach dem Grundriss.',
|
||||||
|
video07Title: 'Auch Mieter bekommen eine Karte',
|
||||||
|
video07Desc:
|
||||||
|
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße – Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
|
||||||
|
video08Title: '9,99 £ gegen einen verlorenen Samstag',
|
||||||
|
video08Desc:
|
||||||
|
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
|
||||||
source: 'Quelle:',
|
source: 'Quelle:',
|
||||||
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
||||||
attribution: 'Quellenangaben',
|
attribution: 'Quellenangaben',
|
||||||
|
|
|
||||||
|
|
@ -1091,6 +1091,34 @@ const en = {
|
||||||
articlesIntro:
|
articlesIntro:
|
||||||
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.',
|
'Browse the public guides for property search, commute, schools, postcode checks, regional comparisons, data coverage, methodology, and privacy.',
|
||||||
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
||||||
|
videos: 'Videos',
|
||||||
|
videosTitle: 'Social media videos',
|
||||||
|
videosIntro:
|
||||||
|
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
|
||||||
|
video01Title: 'One sentence, every postcode',
|
||||||
|
video01Desc:
|
||||||
|
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
|
||||||
|
video02Title: 'The 20-minute map',
|
||||||
|
video02Desc:
|
||||||
|
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
|
||||||
|
video03Title: 'Every postcode has a file',
|
||||||
|
video03Desc:
|
||||||
|
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
|
||||||
|
video04Title: 'You can’t hear a photo',
|
||||||
|
video04Desc:
|
||||||
|
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
|
||||||
|
video05Title: 'The school-run map',
|
||||||
|
video05Desc:
|
||||||
|
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
|
||||||
|
video06Title: 'The Waitrose test',
|
||||||
|
video06Desc:
|
||||||
|
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
|
||||||
|
video07Title: 'Renters get a map too',
|
||||||
|
video07Desc:
|
||||||
|
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
|
||||||
|
video08Title: '£9.99 vs a wasted Saturday',
|
||||||
|
video08Desc:
|
||||||
|
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
|
||||||
source: 'Source:',
|
source: 'Source:',
|
||||||
optOut: 'Opt out of public disclosure',
|
optOut: 'Opt out of public disclosure',
|
||||||
attribution: 'Attribution',
|
attribution: 'Attribution',
|
||||||
|
|
|
||||||
|
|
@ -1125,6 +1125,34 @@ const fr: Translations = {
|
||||||
articlesIntro:
|
articlesIntro:
|
||||||
'Parcourez les guides publics sur la recherche immobilière, les trajets, les écoles, les codes postaux, les comparaisons régionales, la couverture des données, la méthodologie et la confidentialité.',
|
'Parcourez les guides publics sur la recherche immobilière, les trajets, les écoles, les codes postaux, les comparaisons régionales, la couverture des données, la méthodologie et la confidentialité.',
|
||||||
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
|
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
|
||||||
|
videos: 'Vidéos',
|
||||||
|
videosTitle: 'Vidéos pour les réseaux sociaux',
|
||||||
|
videosIntro:
|
||||||
|
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
|
||||||
|
video01Title: 'Une phrase, chaque code postal',
|
||||||
|
video01Desc:
|
||||||
|
'Décrivez tout votre projet immobilier en langage courant et voyez s’allumer chaque code postal correspondant en Angleterre.',
|
||||||
|
video02Title: 'La carte des 20 minutes',
|
||||||
|
video02Desc:
|
||||||
|
'Colorez la carte selon le temps de trajet et voyez exactement ce que 20 minutes du centre de Londres vous laissent vraiment.',
|
||||||
|
video03Title: 'Chaque code postal a sa fiche',
|
||||||
|
video03Desc:
|
||||||
|
'Touchez un code postal pour lire sa fiche — prix de vente, écoles, criminalité et Street View, au même endroit.',
|
||||||
|
video04Title: 'Une photo, ça ne s’entend pas',
|
||||||
|
video04Desc:
|
||||||
|
'Les photos d’annonces sont muettes. Filtrez par niveau de bruit pour trouver les rues vraiment calmes, sous 55 décibels.',
|
||||||
|
video05Title: 'La carte du trajet d’école',
|
||||||
|
video05Desc:
|
||||||
|
'Bons secteurs d’école primaire, faible criminalité et un budget — le projet des familles, cartographié sur toute une ville.',
|
||||||
|
video06Title: 'Le test Waitrose',
|
||||||
|
video06Desc:
|
||||||
|
'À pied d’un supermarché, d’une station de métro et d’un parc — filtrez selon votre vie, pas seulement selon le plan.',
|
||||||
|
video07Title: 'Les locataires aussi ont une carte',
|
||||||
|
video07Desc:
|
||||||
|
'Loyer dans le budget, trajet court et rue calme — les sites de location montrent des logements, ceci vous montre des quartiers.',
|
||||||
|
video08Title: '9,99 £ contre un samedi gâché',
|
||||||
|
video08Desc:
|
||||||
|
'Une mauvaise visite coûte un billet de train et la moitié d’un week-end. Voyez où ne pas aller avant d’en réserver une.',
|
||||||
source: 'Source :',
|
source: 'Source :',
|
||||||
optOut: 'Refus de la publication publique',
|
optOut: 'Refus de la publication publique',
|
||||||
attribution: 'Attribution',
|
attribution: 'Attribution',
|
||||||
|
|
|
||||||
|
|
@ -1072,6 +1072,34 @@ const hi: Translations = {
|
||||||
articlesIntro:
|
articlesIntro:
|
||||||
'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.',
|
'संपत्ति खोज, आवागमन, स्कूल, पोस्टकोड जांच, क्षेत्रीय तुलना, डेटा कवरेज, कार्यप्रणाली और गोपनीयता पर सार्वजनिक गाइड देखें.',
|
||||||
supportIntro: 'कोई सवाल है? हमारे प्रश्नोत्तर देखें या सीधे संपर्क करें.',
|
supportIntro: 'कोई सवाल है? हमारे प्रश्नोत्तर देखें या सीधे संपर्क करें.',
|
||||||
|
videos: 'वीडियो',
|
||||||
|
videosTitle: 'सोशल मीडिया वीडियो',
|
||||||
|
videosIntro:
|
||||||
|
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
|
||||||
|
video01Title: 'एक वाक्य, हर पोस्टकोड',
|
||||||
|
video01Desc:
|
||||||
|
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
|
||||||
|
video02Title: '20 मिनट का नक्शा',
|
||||||
|
video02Desc:
|
||||||
|
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
|
||||||
|
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
|
||||||
|
video03Desc:
|
||||||
|
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
|
||||||
|
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
|
||||||
|
video04Desc:
|
||||||
|
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
|
||||||
|
video05Title: 'स्कूल-रन नक्शा',
|
||||||
|
video05Desc:
|
||||||
|
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
|
||||||
|
video06Title: 'वेट्रोज़ टेस्ट',
|
||||||
|
video06Desc:
|
||||||
|
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
|
||||||
|
video07Title: 'किराएदारों के लिए भी नक्शा',
|
||||||
|
video07Desc:
|
||||||
|
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
|
||||||
|
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
|
||||||
|
video08Desc:
|
||||||
|
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
|
||||||
source: 'स्रोत:',
|
source: 'स्रोत:',
|
||||||
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
|
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
|
||||||
attribution: 'श्रेय',
|
attribution: 'श्रेय',
|
||||||
|
|
|
||||||
|
|
@ -1109,6 +1109,34 @@ const hu: Translations = {
|
||||||
articlesIntro:
|
articlesIntro:
|
||||||
'Böngészd a nyilvános útmutatókat ingatlankeresésről, ingázásról, iskolákról, irányítószám-ellenőrzésről, regionális összehasonlításokról, adatlefedettségről, módszertanról és adatvédelemről.',
|
'Böngészd a nyilvános útmutatókat ingatlankeresésről, ingázásról, iskolákról, irányítószám-ellenőrzésről, regionális összehasonlításokról, adatlefedettségről, módszertanról és adatvédelemről.',
|
||||||
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
|
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
|
||||||
|
videos: 'Videók',
|
||||||
|
videosTitle: 'Közösségimédia-videók',
|
||||||
|
videosIntro:
|
||||||
|
'Rövid klipek a közösségi csatornáinkról – mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
|
||||||
|
video01Title: 'Egy mondat, minden irányítószám',
|
||||||
|
video01Desc:
|
||||||
|
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
|
||||||
|
video02Title: 'A 20 perces térkép',
|
||||||
|
video02Desc:
|
||||||
|
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
|
||||||
|
video03Title: 'Minden irányítószámnak van aktája',
|
||||||
|
video03Desc:
|
||||||
|
'Koppints bármelyik irányítószámra, és olvasd el az aktáját – eladási árak, iskolák, bűnözés és Street View egy helyen.',
|
||||||
|
video04Title: 'Egy fotót nem lehet meghallani',
|
||||||
|
video04Desc:
|
||||||
|
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
|
||||||
|
video05Title: 'Az iskolába vezető út térképe',
|
||||||
|
video05Desc:
|
||||||
|
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés – a családi igény, egy egész városra térképezve.',
|
||||||
|
video06Title: 'A Waitrose-teszt',
|
||||||
|
video06Desc:
|
||||||
|
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól – az életedre szűrj, ne csak az alaprajzra.',
|
||||||
|
video07Title: 'A bérlőknek is jár térkép',
|
||||||
|
video07Desc:
|
||||||
|
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca – a bérleti oldalak lakásokat mutatnak, ez környékeket.',
|
||||||
|
video08Title: '9,99 £ egy elpazarolt szombat ellen',
|
||||||
|
video08Desc:
|
||||||
|
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
|
||||||
source: 'Forrás:',
|
source: 'Forrás:',
|
||||||
optOut: 'Nyilvános közzététel visszautasítása',
|
optOut: 'Nyilvános közzététel visszautasítása',
|
||||||
attribution: 'Forrásmegnevezés',
|
attribution: 'Forrásmegnevezés',
|
||||||
|
|
|
||||||
|
|
@ -1045,6 +1045,26 @@ const zh: Translations = {
|
||||||
articlesIntro:
|
articlesIntro:
|
||||||
'浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
|
'浏览关于找房、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
|
||||||
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
|
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
|
||||||
|
videos: '视频',
|
||||||
|
videosTitle: '社交媒体视频',
|
||||||
|
videosIntro:
|
||||||
|
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
|
||||||
|
video01Title: '一句话,每个邮编',
|
||||||
|
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
|
||||||
|
video02Title: '20 分钟地图',
|
||||||
|
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
|
||||||
|
video03Title: '每个邮编都有一份档案',
|
||||||
|
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
|
||||||
|
video04Title: '照片听不见声音',
|
||||||
|
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
|
||||||
|
video05Title: '上学路线地图',
|
||||||
|
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
|
||||||
|
video06Title: 'Waitrose 测试',
|
||||||
|
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
|
||||||
|
video07Title: '租房者也有地图',
|
||||||
|
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
|
||||||
|
video08Title: '£9.99 对比浪费的周六',
|
||||||
|
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
|
||||||
source: '来源:',
|
source: '来源:',
|
||||||
optOut: '选择不公开',
|
optOut: '选择不公开',
|
||||||
attribution: '数据引用声明',
|
attribution: '数据引用声明',
|
||||||
|
|
|
||||||
|
|
@ -82,18 +82,18 @@ describe('api utilities', () => {
|
||||||
|
|
||||||
it('deduplicates repeated synthetic school filters before backend routes', () => {
|
it('deduplicates repeated synthetic school filters before backend routes', () => {
|
||||||
const features: FeatureMeta[] = [
|
const features: FeatureMeta[] = [
|
||||||
{ name: 'Good+ primary schools within 2km', type: 'numeric', min: 0, max: 10 },
|
{ name: 'Good+ primary school catchments', type: 'numeric', min: 0, max: 10 },
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildFilterString(
|
buildFilterString(
|
||||||
{
|
{
|
||||||
[createSchoolFilterKey('primary', 'good', 2, 1)]: [1, 10],
|
[createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
|
||||||
[createSchoolFilterKey('primary', 'good', 2, 2)]: [2, 8],
|
[createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
|
||||||
},
|
},
|
||||||
features
|
features
|
||||||
)
|
)
|
||||||
).toBe('Good+ primary schools within 2km:2:8');
|
).toBe('Good+ primary school catchments:2:8');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes specific crime filters using their selected backend crime feature', () => {
|
it('serializes specific crime filters using their selected backend crime feature', () => {
|
||||||
|
|
|
||||||
|
|
@ -146,53 +146,27 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
||||||
<path d="M2 3h6a4 4 0 014 4 4 4 0 014-4h6v18a2 2 0 01-2 2h-4a4 4 0 00-4 4 4 4 0 00-4-4H4a2 2 0 01-2-2z" />
|
<path d="M2 3h6a4 4 0 014 4 4 4 0 014-4h6v18a2 2 0 01-2 2h-4a4 4 0 00-4 4 4 4 0 00-4-4H4a2 2 0 01-2-2z" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
'Good+ primary schools within 5km': (
|
'Good+ primary school catchments': (
|
||||||
<>
|
<>
|
||||||
<path d="M4 19V9l8-6 8 6v10" />
|
<path d="M4 19V9l8-6 8 6v10" />
|
||||||
<path d="M9 19v-6h6v6" />
|
<path d="M9 19v-6h6v6" />
|
||||||
<line x1="4" y1="19" x2="20" y2="19" />
|
<line x1="4" y1="19" x2="20" y2="19" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
'Good+ secondary schools within 5km': (
|
'Good+ secondary school catchments': (
|
||||||
<>
|
<>
|
||||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
'Outstanding primary schools within 5km': (
|
'Outstanding primary school catchments': (
|
||||||
<>
|
<>
|
||||||
<path d="M4 19V9l8-6 8 6v10" />
|
<path d="M4 19V9l8-6 8 6v10" />
|
||||||
<path d="M9 19v-6h6v6" />
|
<path d="M9 19v-6h6v6" />
|
||||||
<line x1="4" y1="19" x2="20" y2="19" />
|
<line x1="4" y1="19" x2="20" y2="19" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
'Outstanding secondary schools within 5km': (
|
'Outstanding secondary school catchments': (
|
||||||
<>
|
|
||||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
|
||||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
'Good+ primary schools within 2km': (
|
|
||||||
<>
|
|
||||||
<path d="M4 19V9l8-6 8 6v10" />
|
|
||||||
<path d="M9 19v-6h6v6" />
|
|
||||||
<line x1="4" y1="19" x2="20" y2="19" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
'Good+ secondary schools within 2km': (
|
|
||||||
<>
|
|
||||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
|
||||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
'Outstanding primary schools within 2km': (
|
|
||||||
<>
|
|
||||||
<path d="M4 19V9l8-6 8 6v10" />
|
|
||||||
<path d="M9 19v-6h6v6" />
|
|
||||||
<line x1="4" y1="19" x2="20" y2="19" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
'Outstanding secondary schools within 2km': (
|
|
||||||
<>
|
<>
|
||||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||||
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const TRANSPORT_POI_CATEGORIES = new Set([
|
||||||
'Ferry',
|
'Ferry',
|
||||||
'Rail station',
|
'Rail station',
|
||||||
'Taxi rank',
|
'Taxi rank',
|
||||||
|
'Tram & Metro stop',
|
||||||
'Tube station',
|
'Tube station',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,10 @@ export const SCHOOL_FILTER_KEY_PREFIX = `${SCHOOL_FILTER_NAME}:`;
|
||||||
|
|
||||||
export type SchoolPhase = 'primary' | 'secondary';
|
export type SchoolPhase = 'primary' | 'secondary';
|
||||||
export type SchoolRating = 'good' | 'outstanding';
|
export type SchoolRating = 'good' | 'outstanding';
|
||||||
export type SchoolDistance = 2 | 5;
|
|
||||||
|
|
||||||
export interface SchoolFilterConfig {
|
export interface SchoolFilterConfig {
|
||||||
phase: SchoolPhase;
|
phase: SchoolPhase;
|
||||||
rating: SchoolRating;
|
rating: SchoolRating;
|
||||||
distance: SchoolDistance;
|
|
||||||
featureName: string;
|
featureName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,50 +16,22 @@ export const SCHOOL_FILTERS: SchoolFilterConfig[] = [
|
||||||
{
|
{
|
||||||
phase: 'primary',
|
phase: 'primary',
|
||||||
rating: 'good',
|
rating: 'good',
|
||||||
distance: 2,
|
featureName: 'Good+ primary school catchments',
|
||||||
featureName: 'Good+ primary schools within 2km',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phase: 'secondary',
|
phase: 'secondary',
|
||||||
rating: 'good',
|
rating: 'good',
|
||||||
distance: 2,
|
featureName: 'Good+ secondary school catchments',
|
||||||
featureName: 'Good+ secondary schools within 2km',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phase: 'primary',
|
phase: 'primary',
|
||||||
rating: 'outstanding',
|
rating: 'outstanding',
|
||||||
distance: 2,
|
featureName: 'Outstanding primary school catchments',
|
||||||
featureName: 'Outstanding primary schools within 2km',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phase: 'secondary',
|
phase: 'secondary',
|
||||||
rating: 'outstanding',
|
rating: 'outstanding',
|
||||||
distance: 2,
|
featureName: 'Outstanding secondary school catchments',
|
||||||
featureName: 'Outstanding secondary schools within 2km',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'primary',
|
|
||||||
rating: 'good',
|
|
||||||
distance: 5,
|
|
||||||
featureName: 'Good+ primary schools within 5km',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'secondary',
|
|
||||||
rating: 'good',
|
|
||||||
distance: 5,
|
|
||||||
featureName: 'Good+ secondary schools within 5km',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'primary',
|
|
||||||
rating: 'outstanding',
|
|
||||||
distance: 5,
|
|
||||||
featureName: 'Outstanding primary schools within 5km',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'secondary',
|
|
||||||
rating: 'outstanding',
|
|
||||||
distance: 5,
|
|
||||||
featureName: 'Outstanding secondary schools within 5km',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -81,42 +51,34 @@ export function getSchoolFilterConfig(name: string): SchoolFilterConfig | null {
|
||||||
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
|
return SCHOOL_FILTERS.find((filter) => filter.featureName === name) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSchoolFeatureName(
|
export function getSchoolFeatureName(phase: SchoolPhase, rating: SchoolRating): string {
|
||||||
phase: SchoolPhase,
|
|
||||||
rating: SchoolRating,
|
|
||||||
distance: SchoolDistance
|
|
||||||
): string {
|
|
||||||
return (
|
return (
|
||||||
SCHOOL_FILTERS.find(
|
SCHOOL_FILTERS.find((filter) => filter.phase === phase && filter.rating === rating)
|
||||||
(filter) => filter.phase === phase && filter.rating === rating && filter.distance === distance
|
?.featureName ?? SCHOOL_FILTERS[0].featureName
|
||||||
)?.featureName ?? SCHOOL_FILTERS[0].featureName
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSchoolFilterKey(
|
export function createSchoolFilterKey(
|
||||||
phase: SchoolPhase,
|
phase: SchoolPhase,
|
||||||
rating: SchoolRating,
|
rating: SchoolRating,
|
||||||
distance: SchoolDistance,
|
|
||||||
id: number | string
|
id: number | string
|
||||||
): string {
|
): string {
|
||||||
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${distance}:${id}`;
|
return `${SCHOOL_FILTER_KEY_PREFIX}${phase}:${rating}:${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSchoolFilterKeyId(name: string): string | null {
|
export function getSchoolFilterKeyId(name: string): string | null {
|
||||||
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||||
return name.split(':')[4] ?? null;
|
return name.split(':')[3] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
||||||
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
if (!name.startsWith(SCHOOL_FILTER_KEY_PREFIX)) return null;
|
||||||
const [, phaseRaw, ratingRaw, distanceRaw] = name.split(':');
|
const [, phaseRaw, ratingRaw] = name.split(':');
|
||||||
const phase = phaseRaw as SchoolPhase;
|
const phase = phaseRaw as SchoolPhase;
|
||||||
const rating = ratingRaw as SchoolRating;
|
const rating = ratingRaw as SchoolRating;
|
||||||
const distance = Number(distanceRaw) as SchoolDistance;
|
|
||||||
if (
|
if (
|
||||||
(phase !== 'primary' && phase !== 'secondary') ||
|
(phase !== 'primary' && phase !== 'secondary') ||
|
||||||
(rating !== 'good' && rating !== 'outstanding') ||
|
(rating !== 'good' && rating !== 'outstanding')
|
||||||
(distance !== 2 && distance !== 5)
|
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -124,8 +86,7 @@ export function parseSchoolFilterKey(name: string): SchoolFilterConfig | null {
|
||||||
return {
|
return {
|
||||||
phase,
|
phase,
|
||||||
rating,
|
rating,
|
||||||
distance,
|
featureName: getSchoolFeatureName(phase, rating),
|
||||||
featureName: getSchoolFeatureName(phase, rating, distance),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,18 +100,12 @@ export function replaceSchoolFilterKeySelection(
|
||||||
next: {
|
next: {
|
||||||
phase?: SchoolPhase;
|
phase?: SchoolPhase;
|
||||||
rating?: SchoolRating;
|
rating?: SchoolRating;
|
||||||
distance?: SchoolDistance;
|
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
|
const config = getSchoolFilterConfig(key) ?? SCHOOL_FILTERS[0];
|
||||||
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
|
const parts = key.startsWith(SCHOOL_FILTER_KEY_PREFIX) ? key.split(':') : [];
|
||||||
const id = parts[4] ?? '0';
|
const id = parts[3] ?? '0';
|
||||||
return createSchoolFilterKey(
|
return createSchoolFilterKey(next.phase ?? config.phase, next.rating ?? config.rating, id);
|
||||||
next.phase ?? config.phase,
|
|
||||||
next.rating ?? config.rating,
|
|
||||||
next.distance ?? config.distance,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
|
export function getDefaultSchoolFeatureName(features: FeatureMeta[]): string | null {
|
||||||
|
|
@ -171,14 +126,7 @@ export function normalizeSchoolFilters(filters: FeatureFilters): FeatureFilters
|
||||||
if (isBackendSchoolFeatureName(name)) {
|
if (isBackendSchoolFeatureName(name)) {
|
||||||
const config = getSchoolFilterConfig(name);
|
const config = getSchoolFilterConfig(name);
|
||||||
if (!config) continue;
|
if (!config) continue;
|
||||||
next[
|
next[createSchoolFilterKey(config.phase, config.rating, Object.keys(next).length)] = value;
|
||||||
createSchoolFilterKey(
|
|
||||||
config.phase,
|
|
||||||
config.rating,
|
|
||||||
config.distance,
|
|
||||||
Object.keys(next).length
|
|
||||||
)
|
|
||||||
] = value;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -201,9 +149,9 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
||||||
min: sourceFeature?.min ?? 0,
|
min: sourceFeature?.min ?? 0,
|
||||||
max: sourceFeature?.max ?? 10,
|
max: sourceFeature?.max ?? 10,
|
||||||
step: 1,
|
step: 1,
|
||||||
description: 'Rated primary and secondary schools nearby',
|
description: 'Rated schools whose catchment area likely covers the postcode',
|
||||||
detail:
|
detail:
|
||||||
'Filter by primary or secondary schools, Ofsted rating, and whether schools are within 2km or 5km.',
|
'Filter by how many Good+ or Outstanding primary or secondary schools have a historical catchment area covering the postcode. Catchments are modelled from each school’s pupil numbers and local child population, approximating distance-based admissions.',
|
||||||
source: 'ofsted',
|
source: 'ofsted',
|
||||||
raw: true,
|
raw: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -352,8 +352,8 @@ describe('url-state', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
const schoolOne = createSchoolFilterKey('primary', 'good', 1);
|
||||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 2);
|
||||||
|
|
||||||
const params = stateToParams(
|
const params = stateToParams(
|
||||||
null,
|
null,
|
||||||
|
|
@ -366,18 +366,22 @@ describe('url-state', () => {
|
||||||
'area'
|
'area'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(params.getAll('school')).toEqual([
|
expect(params.getAll('school')).toEqual(['primary:good:1:10', 'secondary:outstanding:2:15']);
|
||||||
'primary:good:2:1:10',
|
|
||||||
'secondary:outstanding:5:2:15',
|
|
||||||
]);
|
|
||||||
expect(params.getAll('filter')).toEqual([]);
|
expect(params.getAll('filter')).toEqual([]);
|
||||||
|
|
||||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||||
const state = parseUrlState();
|
const state = parseUrlState();
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
expect(state.filters).toEqual({
|
||||||
[createSchoolFilterKey('primary', 'good', 2, 0)]: [1, 10],
|
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
|
||||||
[createSchoolFilterKey('secondary', 'outstanding', 5, 1)]: [2, 15],
|
[createSchoolFilterKey('secondary', 'outstanding', 1)]: [2, 15],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses legacy school URL params that still carry a distance segment', () => {
|
||||||
|
window.history.replaceState({}, '', '/?school=primary%3Agood%3A2%3A1%3A10');
|
||||||
|
expect(parseUrlState().filters).toEqual({
|
||||||
|
[createSchoolFilterKey('primary', 'good', 0)]: [1, 10],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
createSchoolFilterKey,
|
createSchoolFilterKey,
|
||||||
getSchoolFilterConfig,
|
getSchoolFilterConfig,
|
||||||
isSchoolFilterName,
|
isSchoolFilterName,
|
||||||
type SchoolDistance,
|
|
||||||
type SchoolPhase,
|
type SchoolPhase,
|
||||||
type SchoolRating,
|
type SchoolRating,
|
||||||
} from './school-filter';
|
} from './school-filter';
|
||||||
|
|
@ -122,22 +121,22 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
||||||
|
|
||||||
schoolParams.forEach((entry, index) => {
|
schoolParams.forEach((entry, index) => {
|
||||||
const parts = entry.split(':');
|
const parts = entry.split(':');
|
||||||
if (parts.length !== 5) return;
|
// 4 parts is the current phase:rating:min:max form; 5 parts is the legacy
|
||||||
|
// phase:rating:distance:min:max form, whose distance segment is ignored.
|
||||||
|
if (parts.length !== 4 && parts.length !== 5) return;
|
||||||
const phase = parts[0] as SchoolPhase;
|
const phase = parts[0] as SchoolPhase;
|
||||||
const rating = parts[1] as SchoolRating;
|
const rating = parts[1] as SchoolRating;
|
||||||
const distance = Number(parts[2]) as SchoolDistance;
|
const min = Number(parts[parts.length - 2]);
|
||||||
const min = Number(parts[3]);
|
const max = Number(parts[parts.length - 1]);
|
||||||
const max = Number(parts[4]);
|
|
||||||
if (
|
if (
|
||||||
(phase !== 'primary' && phase !== 'secondary') ||
|
(phase !== 'primary' && phase !== 'secondary') ||
|
||||||
(rating !== 'good' && rating !== 'outstanding') ||
|
(rating !== 'good' && rating !== 'outstanding') ||
|
||||||
(distance !== 2 && distance !== 5) ||
|
|
||||||
isNaN(min) ||
|
isNaN(min) ||
|
||||||
isNaN(max)
|
isNaN(max)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
|
filters[createSchoolFilterKey(phase, rating, index)] = [min, max];
|
||||||
});
|
});
|
||||||
|
|
||||||
crimeParams.forEach((entry, index) => {
|
crimeParams.forEach((entry, index) => {
|
||||||
|
|
@ -379,10 +378,7 @@ export function stateToParams(
|
||||||
const schoolConfig = getSchoolFilterConfig(name);
|
const schoolConfig = getSchoolFilterConfig(name);
|
||||||
if (schoolConfig && isSchoolFilterName(name)) {
|
if (schoolConfig && isSchoolFilterName(name)) {
|
||||||
const [min, max] = value as [number, number];
|
const [min, max] = value as [number, number];
|
||||||
params.append(
|
params.append('school', `${schoolConfig.phase}:${schoolConfig.rating}:${min}:${max}`);
|
||||||
'school',
|
|
||||||
`${schoolConfig.phase}:${schoolConfig.rating}:${schoolConfig.distance}:${min}:${max}`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,14 @@ type FormFactor = 'desktop' | 'mobile';
|
||||||
* length, padding short `during` blocks with a trailing wait.
|
* length, padding short `during` blocks with a trailing wait.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// School-count features as served by live /api/features TODAY. The data
|
// School features as served by live /api/features. The data pipeline moved
|
||||||
// pipeline has already moved to modelled catchment counts ("Good+ primary
|
// to modelled catchment counts ("Good+ primary school catchments"), which the
|
||||||
// school catchments"), so flip these two constants when that deploy lands
|
// local stack already serves; prod still serves the older "…within 2km" names
|
||||||
// on prod — preflight will fail loudly if the names drift from the API.
|
// until that deploy lands. Preflight fails loudly if these drift from
|
||||||
const SCHOOL_GOOD_PRIMARY = 'Good+ primary schools within 2km';
|
// whichever API render.sh is pointed at — flip them back if you render against
|
||||||
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary schools within 2km';
|
// prod before the catchment model deploys there.
|
||||||
|
const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments';
|
||||||
|
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
|
||||||
|
|
||||||
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the
|
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the
|
||||||
// map remains visible on the right (zoomTo clamps the pan so the app
|
// map remains visible on the right (zoomTo clamps the pan so the app
|
||||||
|
|
@ -59,10 +61,10 @@ const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
||||||
const TT_SLIDER_MAX = 120;
|
const TT_SLIDER_MAX = 120;
|
||||||
const TT_DRAG_FROM_MIN = 35;
|
const TT_DRAG_FROM_MIN = 35;
|
||||||
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
||||||
// enough that street-level Manchester keeps plenty of matching postcodes —
|
// enough that street-level central London keeps plenty of matching
|
||||||
// at 20 the brief emptied the centre and the postcode tap had nothing
|
// postcodes — at 20 the brief emptied the centre and the postcode tap had
|
||||||
// fresh to land on (the drawer then opened in its "filtered stats are
|
// nothing fresh to land on (the drawer then opened in its "filtered stats
|
||||||
// empty" fallback).
|
// are empty" fallback).
|
||||||
const TT_DRAG_TO_MIN = 25;
|
const TT_DRAG_TO_MIN = 25;
|
||||||
|
|
||||||
// Where on the map the demo zoom-in lands. Desktop targets a fixed pixel
|
// Where on the map the demo zoom-in lands. Desktop targets a fixed pixel
|
||||||
|
|
@ -138,8 +140,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
promptText:
|
promptText:
|
||||||
'First home under £315k, 35 min to Manchester, good schools, low crime, quiet street, fast broadband',
|
'First home under £600k, 35 min to central London, good schools, low crime, quiet street, fast broadband',
|
||||||
travelTimeLabel: 'Manchester city centre',
|
travelTimeLabel: 'Central London',
|
||||||
exportButtonTitle: 'Export to Excel',
|
exportButtonTitle: 'Export to Excel',
|
||||||
exportConfirmLabel: 'Export',
|
exportConfirmLabel: 'Export',
|
||||||
closeDrawerLabel: 'Close drawer',
|
closeDrawerLabel: 'Close drawer',
|
||||||
|
|
@ -173,8 +175,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.',
|
'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.',
|
||||||
promptText:
|
promptText:
|
||||||
'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
'Wohnung unter £600k, 35 Min. ins Zentrum von London, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
||||||
travelTimeLabel: 'Stadtzentrum Manchester',
|
travelTimeLabel: 'Zentrum von London',
|
||||||
exportButtonTitle: 'Nach Excel exportieren',
|
exportButtonTitle: 'Nach Excel exportieren',
|
||||||
exportConfirmLabel: 'Exportieren',
|
exportConfirmLabel: 'Exportieren',
|
||||||
closeDrawerLabel: 'Drawer schließen',
|
closeDrawerLabel: 'Drawer schließen',
|
||||||
|
|
@ -206,8 +208,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
|
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
|
||||||
'pronunciation and a friendly, practical delivery.',
|
'pronunciation and a friendly, practical delivery.',
|
||||||
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
|
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
|
||||||
promptText: '30万英镑以内的公寓,35分钟到曼彻斯特,学校好,犯罪率低,街道安静',
|
promptText: '60万英镑以内的公寓,35分钟到伦敦市中心,学校好,犯罪率低,街道安静',
|
||||||
travelTimeLabel: '曼彻斯特市中心',
|
travelTimeLabel: '伦敦市中心',
|
||||||
exportButtonTitle: '导出为 Excel',
|
exportButtonTitle: '导出为 Excel',
|
||||||
exportConfirmLabel: '导出',
|
exportConfirmLabel: '导出',
|
||||||
closeDrawerLabel: '关闭侧栏',
|
closeDrawerLabel: '关闭侧栏',
|
||||||
|
|
@ -237,8 +239,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'and a friendly, practical delivery.',
|
'and a friendly, practical delivery.',
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets',
|
promptText: 'Flat under £600k, 35 min to central London, good schools, low crime, quiet streets',
|
||||||
travelTimeLabel: 'Manchester city centre',
|
travelTimeLabel: 'Central London',
|
||||||
exportButtonTitle: 'Excel में export करें',
|
exportButtonTitle: 'Excel में export करें',
|
||||||
exportConfirmLabel: 'Export',
|
exportConfirmLabel: 'Export',
|
||||||
closeDrawerLabel: 'ड्रॉअर बंद करें',
|
closeDrawerLabel: 'ड्रॉअर बंद करें',
|
||||||
|
|
@ -520,7 +522,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
||||||
outputFps: 50,
|
outputFps: 50,
|
||||||
minDurationS: 10,
|
minDurationS: 10,
|
||||||
maxDurationS: 75,
|
maxDurationS: 75,
|
||||||
// Right-pane inspection: Manchester map, filters applied, data pane
|
// Right-pane inspection: London map, filters applied, data pane
|
||||||
// open — the clearest paused-state preview.
|
// open — the clearest paused-state preview.
|
||||||
posterTimeS: 31,
|
posterTimeS: 31,
|
||||||
};
|
};
|
||||||
|
|
@ -536,20 +538,22 @@ function createRecordingStoryboard(
|
||||||
): Storyboard {
|
): Storyboard {
|
||||||
const copy = RECORDING_LOCALIZATIONS[locale];
|
const copy = RECORDING_LOCALIZATIONS[locale];
|
||||||
// Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide
|
// Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide
|
||||||
// viewport still shows a Manchester-metro slice densely populated with
|
// viewport still shows an inner-London slice densely populated with
|
||||||
// hexagons (otherwise the visible map gets dominated by Pennine moors
|
// hexagons (otherwise the visible map gets dominated by the low-density
|
||||||
// on the east edge with no matches).
|
// outer edges with no matches).
|
||||||
const initialZoom = formFactor === 'mobile' ? 12 : 11.5;
|
const initialZoom = formFactor === 'mobile' ? 12 : 11.5;
|
||||||
|
|
||||||
// On mobile the MobileBottomSheet covers the bottom ~44% of the
|
// On mobile the MobileBottomSheet covers the bottom ~44% of the
|
||||||
// viewport, so the map's geographic centre sits roughly at the seam
|
// viewport, so the map's geographic centre sits roughly at the seam
|
||||||
// between the visible map and the sheet. Shift the centre lat ~0.04°
|
// between the visible map and the sheet. Shift the centre lat ~0.04°
|
||||||
// south so Manchester city centre (53.4795) lands in the upper half of
|
// south so central London (51.515) lands in the upper half of the
|
||||||
// the visible map area instead of getting hidden under the sheet. The
|
// visible map area instead of getting hidden under the sheet. The
|
||||||
// desktop layout already has the map dominate the viewport, so it
|
// desktop layout already has the map dominate the viewport, so it
|
||||||
// keeps the original centre.
|
// keeps the original centre. -0.13 lon centres on Holborn/Bloomsbury,
|
||||||
const mapLat = formFactor === 'mobile' ? 53.4395 : 53.4795;
|
// between the West End and the City, so the Bank-station travel filter
|
||||||
const mapLon = -2.2451;
|
// and the deep zoom-in both land on richly matched inner-London blocks.
|
||||||
|
const mapLat = formFactor === 'mobile' ? 51.475 : 51.515;
|
||||||
|
const mapLon = -0.13;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: storyboardName(copy, formFactor),
|
name: storyboardName(copy, formFactor),
|
||||||
|
|
@ -572,10 +576,15 @@ function createRecordingStoryboard(
|
||||||
// from /api/features (preflight validates against the live server).
|
// from /api/features (preflight validates against the live server).
|
||||||
stubbedFilters: {
|
stubbedFilters: {
|
||||||
'Property type': ['Flats/Maisonettes', 'Semi-Detached'],
|
'Property type': ['Flats/Maisonettes', 'Semi-Detached'],
|
||||||
'Estimated current price': [0, 315000],
|
// £600k (not £315k like the old Manchester cut): central London is
|
||||||
// Loose enough to keep the Manchester map richly populated — a cap
|
// pricier, and at £315k the price+crime combo emptied the inner
|
||||||
// of 20 emptied the city centre and left the zoom with nothing to
|
// boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
|
||||||
// land on.
|
// once the commute is dragged to 25 min — a visible prune that still
|
||||||
|
// leaves the zoom something to land on (verified via /api/filter-counts).
|
||||||
|
'Estimated current price': [0, 600000],
|
||||||
|
// Loose enough to keep the central-London map richly populated — a
|
||||||
|
// cap of 20 emptied the city centre and left the zoom with nothing
|
||||||
|
// to land on.
|
||||||
'Serious crime (avg/yr)': [0, 40],
|
'Serious crime (avg/yr)': [0, 40],
|
||||||
[SCHOOL_GOOD_PRIMARY]: [1, 10],
|
[SCHOOL_GOOD_PRIMARY]: [1, 10],
|
||||||
'Noise (dB)': [0, 65],
|
'Noise (dB)': [0, 65],
|
||||||
|
|
@ -584,11 +593,13 @@ function createRecordingStoryboard(
|
||||||
'Max available download speed (Mbps)': [30, 1000],
|
'Max available download speed (Mbps)': [30, 1000],
|
||||||
},
|
},
|
||||||
// Travel-time filters returned by the AI stub. Slug matches the real
|
// Travel-time filters returned by the AI stub. Slug matches the real
|
||||||
// /api/travel-destinations?mode=transit response.
|
// /api/travel-destinations?mode=transit response. Bank tube station is
|
||||||
|
// the central-London transit anchor served by the local stack (the
|
||||||
|
// older city-level "manchester" slug only exists on prod's fuller data).
|
||||||
stubbedTravelTimeFilters: [
|
stubbedTravelTimeFilters: [
|
||||||
{
|
{
|
||||||
mode: 'transit',
|
mode: 'transit',
|
||||||
slug: 'manchester',
|
slug: 'bank-tube-station',
|
||||||
label: copy.travelTimeLabel,
|
label: copy.travelTimeLabel,
|
||||||
max: TT_DRAG_FROM_MIN,
|
max: TT_DRAG_FROM_MIN,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue