Improve FAQ & video rendering, tighten homepage and CSS

This commit is contained in:
Andras Schmelczer 2026-05-04 22:07:30 +01:00
parent 05a1f316e1
commit c69bb0d614
48 changed files with 4689 additions and 1077 deletions

View file

@ -4711,9 +4711,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4731,9 +4728,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4751,9 +4745,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4771,9 +4762,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4791,9 +4779,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4811,9 +4796,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -9874,6 +9856,21 @@
} }
} }
}, },
"node_modules/html-encoding-sniffer/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/html-entities": { "node_modules/html-entities": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
@ -11075,6 +11072,21 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/jsdom/node_modules/entities": { "node_modules/jsdom/node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@ -11405,9 +11417,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -11429,9 +11438,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -11453,9 +11459,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -11477,9 +11480,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -16527,6 +16527,21 @@
} }
} }
}, },
"node_modules/whatwg-url/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/which": { "node_modules/which": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",

View file

@ -300,6 +300,7 @@ export default function App() {
screenshotMode screenshotMode
ogMode={isOgMode} ogMode={isOgMode}
initialTravelTime={urlState.travelTime} initialTravelTime={urlState.travelTime}
shareCode={urlState.share}
/> />
); );
} }
@ -406,6 +407,7 @@ export default function App() {
isMobile={isMobile} isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime} initialTravelTime={mapUrlState.travelTime}
initialPostcode={mapUrlState.postcode} initialPostcode={mapUrlState.postcode}
shareCode={mapUrlState.share}
user={user} user={user}
onLoginClick={() => { onLoginClick={() => {
setAuthModalTab('login'); setAuthModalTab('login');

File diff suppressed because it is too large Load diff

View file

@ -131,7 +131,7 @@ export default function InvitePage({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [code]); }, [code, t]);
const handleRedeem = useCallback(async () => { const handleRedeem = useCallback(async () => {
if (!user) return; if (!user) return;

View file

@ -184,7 +184,6 @@ export default function LearnPage() {
title: t('learnPage.faqFindingTitle'), title: t('learnPage.faqFindingTitle'),
items: [ items: [
{ question: t('learnPage.faqFinding1Q'), answer: t('learnPage.faqFinding1A') }, { question: t('learnPage.faqFinding1Q'), answer: t('learnPage.faqFinding1A') },
{ question: t('learnPage.faqFinding2Q'), answer: t('learnPage.faqFinding2A') },
{ question: t('learnPage.faqFinding3Q'), answer: t('learnPage.faqFinding3A') }, { question: t('learnPage.faqFinding3Q'), answer: t('learnPage.faqFinding3A') },
], ],
}, },
@ -203,50 +202,19 @@ export default function LearnPage() {
], ],
}, },
{ {
title: t('learnPage.faqSafetyTitle'), title: t('learnPage.faqDueDiligenceTitle'),
items: [ items: [
{ question: t('learnPage.faqSafety1Q'), answer: t('learnPage.faqSafety1A') }, { question: t('learnPage.faqDueDiligence1Q'), answer: t('learnPage.faqDueDiligence1A') },
{ question: t('learnPage.faqSafety2Q'), answer: t('learnPage.faqSafety2A') }, { question: t('learnPage.faqDueDiligence4Q'), answer: t('learnPage.faqDueDiligence4A') },
], ],
}, },
{ {
title: t('learnPage.faqFamiliesTitle'), title: t('learnPage.faqPrivacyTitle'),
items: [ items: [{ question: t('learnPage.faqPrivacy1Q'), answer: t('learnPage.faqPrivacy1A') }],
{ question: t('learnPage.faqFamilies1Q'), answer: t('learnPage.faqFamilies1A') },
{ question: t('learnPage.faqFamilies2Q'), answer: t('learnPage.faqFamilies2A') },
],
},
{
title: t('learnPage.faqEnvironmentTitle'),
items: [
{ question: t('learnPage.faqEnv1Q'), answer: t('learnPage.faqEnv1A') },
{ question: t('learnPage.faqEnv2Q'), answer: t('learnPage.faqEnv2A') },
{ question: t('learnPage.faqEnv3Q'), answer: t('learnPage.faqEnv3A') },
],
},
{
title: t('learnPage.faqWhyTitle'),
items: [
{ question: t('learnPage.faqWhy1Q'), answer: t('learnPage.faqWhy1A') },
{ question: t('learnPage.faqWhy2Q'), answer: t('learnPage.faqWhy2A') },
{ question: t('learnPage.faqWhy3Q'), answer: t('learnPage.faqWhy3A') },
],
}, },
{ {
title: t('learnPage.faqPricingTitle'), title: t('learnPage.faqPricingTitle'),
items: [ items: [{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') }],
{ question: t('learnPage.faqPricing1Q'), answer: t('learnPage.faqPricing1A') },
{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') },
{ question: t('learnPage.faqPricing3Q'), answer: t('learnPage.faqPricing3A') },
],
},
{
title: t('learnPage.faqTipsTitle'),
items: [
{ question: t('learnPage.faqTips1Q'), answer: t('learnPage.faqTips1A') },
{ question: t('learnPage.faqTips2Q'), answer: t('learnPage.faqTips2A') },
{ question: t('learnPage.faqTips3Q'), answer: t('learnPage.faqTips3A') },
],
}, },
]; ];

View file

@ -38,6 +38,8 @@ interface AiFilterInputProps {
onSubmit: (query: string) => void; onSubmit: (query: string) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onLoginRequired: () => void; onLoginRequired: () => void;
defaultExpanded?: boolean;
defaultQuery?: string;
} }
export default memo(function AiFilterInput({ export default memo(function AiFilterInput({
@ -49,10 +51,12 @@ export default memo(function AiFilterInput({
onSubmit, onSubmit,
isLoggedIn, isLoggedIn,
onLoginRequired, onLoginRequired,
defaultExpanded = false,
defaultQuery = '',
}: AiFilterInputProps) { }: AiFilterInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState(''); const [query, setQuery] = useState(defaultQuery);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(defaultExpanded);
const exampleQueries = useMemo( const exampleQueries = useMemo(
() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')], () => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')],
[t] [t]

View file

@ -61,7 +61,7 @@ export default function LocationSearch({
}; };
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [isMobile, search.close]); }, [isMobile, search]);
// Focus input when expanding on mobile // Focus input when expanding on mobile
useEffect(() => { useEffect(() => {
@ -112,7 +112,7 @@ export default function LocationSearch({
setLoading(false); setLoading(false);
} }
}, },
[onFlyTo, onLocationSearched, isMobile, search] [onFlyTo, onLocationSearched, isMobile, search, t]
); );
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);

View file

@ -72,6 +72,7 @@ interface MapPageProps {
isMobile?: boolean; isMobile?: boolean;
initialTravelTime?: TravelTimeInitial; initialTravelTime?: TravelTimeInitial;
initialPostcode?: string; initialPostcode?: string;
shareCode?: string;
user?: { id: string; subscription: string; isAdmin?: boolean } | null; user?: { id: string; subscription: string; isAdmin?: boolean } | null;
onLoginClick?: () => void; onLoginClick?: () => void;
onRegisterClick?: () => void; onRegisterClick?: () => void;
@ -102,6 +103,7 @@ export default function MapPage({
isMobile = false, isMobile = false,
initialTravelTime, initialTravelTime,
initialPostcode, initialPostcode,
shareCode,
user, user,
onLoginClick, onLoginClick,
onRegisterClick, onRegisterClick,
@ -192,6 +194,7 @@ export default function MapPage({
viewFeature, viewFeature,
activeFeature, activeFeature,
travelTimeEntries: entries, travelTimeEntries: entries,
shareCode,
}); });
const handleAiFilterSubmit = useCallback( const handleAiFilterSubmit = useCallback(
@ -352,18 +355,28 @@ export default function MapPage({
[handleCurrentLocationSearch, isMobile] [handleCurrentLocationSearch, isMobile]
); );
// For share-link recipients, "Continue with Demo" snaps back to the shared
// coords (the area their link was meant to show), not the central-London
// free-zone demo. Captured once on mount so a later URL rewrite by
// useUrlSync (which tracks the user's current view) doesn't overwrite it.
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
const handleZoomToFreeZone = useCallback(() => { const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.( const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
INITIAL_VIEW_STATE.latitude, mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
INITIAL_VIEW_STATE.longitude,
INITIAL_VIEW_STATE.zoom
);
}, []); }, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories); const pois = usePOIData(mapData.bounds, selectedPOICategories);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true); const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, rightPaneTab, entries); useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
rightPaneTab,
entries,
shareCode
);
useEffect(() => { useEffect(() => {
mapData.setInitialView(initialViewState); mapData.setInitialView(initialViewState);
@ -868,6 +881,7 @@ export default function MapPage({
onRegisterClick={onRegisterClick ?? (() => {})} onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()} onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone} onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/> />
)} )}
</div> </div>
@ -996,6 +1010,7 @@ export default function MapPage({
onRegisterClick={onRegisterClick ?? (() => {})} onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()} onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone} onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/> />
)} )}
</div> </div>

View file

@ -86,8 +86,13 @@ export default function PricingPage({
if (currentTierIndex === 0) return; if (currentTierIndex === 0) return;
const container = scrollRef.current; const container = scrollRef.current;
const card = activeCardRef.current; const card = activeCardRef.current;
const containerRect = container.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const scrollLeft = const scrollLeft =
card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; container.scrollLeft +
cardRect.left -
containerRect.left -
(container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft); container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0); setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]); }, [pricing, currentTierIndex]);
@ -191,7 +196,7 @@ export default function PricingPage({
<p className="text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p> <p className="text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
</div> </div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-16"> <div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
{/* Tier cards — full viewport width carousel */} {/* Tier cards — full viewport width carousel */}
{loading ? ( {loading ? (
<div className="flex justify-center py-16"> <div className="flex justify-center py-16">
@ -199,7 +204,7 @@ export default function PricingPage({
</div> </div>
) : pricing ? ( ) : pricing ? (
<div <div
className="relative mb-12" className="relative"
style={{ style={{
marginLeft: 'calc(-50vw + 50%)', marginLeft: 'calc(-50vw + 50%)',
marginRight: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)',
@ -219,146 +224,151 @@ export default function PricingPage({
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={onScroll} onScroll={onScroll}
className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" className="overflow-x-auto px-6 pb-4 scrollbar-hide"
style={{ scrollbarWidth: 'none' }} style={{ scrollbarWidth: 'none' }}
> >
{pricing.tiers.map((tier, i) => { <div className="flex w-max gap-6 mx-auto">
const isCurrent = i === currentTierIndex; {pricing.tiers.map((tier, i) => {
const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to; const isCurrent = i === currentTierIndex;
const filledInTier = isCurrent const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to;
? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0) const filledInTier = isCurrent
: 0; ? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
const tierSlots = tier.slots;
const fillPercent = isFilled
? 100
: isCurrent && tierSlots > 0
? (filledInTier / tierSlots) * 100
: 0; : 0;
const tierSlots = tier.slots;
const fillPercent = isFilled
? 100
: isCurrent && tierSlots > 0
? (filledInTier / tierSlots) * 100
: 0;
return ( return (
<div
key={i}
ref={isCurrent ? activeCardRef : undefined}
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
{isCurrent && (
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
{t('pricingPage.currentTier')}
</div>
)}
<div <div
className={`px-6 py-8 text-center ${ key={i}
ref={isCurrent ? activeCardRef : undefined}
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent isCurrent
? 'bg-gradient-to-br from-navy-950 to-teal-900' ? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'bg-white dark:bg-warm-800' : 'border-warm-700 shadow-md'
}`} } ${isFilled ? 'opacity-60' : ''}`}
> >
<p {isCurrent && (
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${ <div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400' {t('pricingPage.currentTier')}
</div>
)}
<div
className={`px-6 py-8 text-center ${
isCurrent
? 'bg-gradient-to-br from-navy-950 to-teal-900'
: 'bg-white dark:bg-warm-800'
}`} }`}
> >
{i === 0 <p
? t('pricingPage.firstNUsers', { count: tier.slots }) className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
: tier.up_to === null isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
? t('pricingPage.everyoneAfter')
: t('pricingPage.nextNUsers', { count: tier.slots })}
</p>
<div className="flex items-baseline justify-center gap-1">
<span
className={`text-4xl font-extrabold ${
isCurrent
? 'text-white'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-navy-950 dark:text-warm-100'
}`} }`}
> >
{tier.price_pence === 0 {i === 0
? t('upgrade.free') ? t('pricingPage.firstNUsers', { count: tier.slots })
: formatPricePence(tier.price_pence)} : tier.up_to === null
</span> ? t('pricingPage.everyoneAfter')
{tier.price_pence > 0 && ( : t('pricingPage.nextNUsers', { count: tier.slots })}
</p>
<div className="flex items-baseline justify-center gap-1">
<span <span
className={`text-lg ${ className={`text-4xl font-extrabold ${
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500' isCurrent
? 'text-white'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-navy-950 dark:text-warm-100'
}`} }`}
> >
{t('pricingPage.lifetime')} {tier.price_pence === 0
? t('upgrade.free')
: formatPricePence(tier.price_pence)}
</span> </span>
{tier.price_pence > 0 && (
<span
className={`text-lg ${
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
}`}
>
{t('pricingPage.lifetime')}
</span>
)}
</div>
{isCurrent && spotsRemaining > 0 && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining === 1
? t('pricingPage.spotsRemaining', { count: spotsRemaining })
: t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })}
</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>
{isCurrent && spotsRemaining > 0 && ( {/* Progress bar for current tier */}
<p className="text-teal-300 text-sm mt-2 font-medium"> {isCurrent && tierSlots > 0 && (
{spotsRemaining === 1 <div className="h-1.5 bg-warm-200 dark:bg-warm-700">
? t('pricingPage.spotsRemaining', { count: spotsRemaining }) <div
: t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })} className="h-full bg-teal-500"
</p> style={{ width: `${fillPercent}%` }}
/>
</div>
)} )}
{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>
{/* Progress bar for current tier */} <div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
{isCurrent && tierSlots > 0 && ( <ul className="space-y-3 mb-6 flex-1">
<div className="h-1.5 bg-warm-200 dark:bg-warm-700"> {[
<div className="h-full bg-teal-500" style={{ width: `${fillPercent}%` }} /> t('pricingPage.feat1'),
t('pricingPage.feat2'),
t('pricingPage.feat3'),
t('pricingPage.feat4'),
t('pricingPage.feat5'),
t('pricingPage.feat6'),
].map((feat, idx) => (
<li key={idx} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feat}</span>
</li>
))}
</ul>
{isCurrent ? (
<>
{ctaButton}
{license.error && (
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
</p>
)}
{isFree && (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.noCreditCard')}
</p>
)}
</>
) : isFilled ? (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
{t('pricingPage.soldOut')}
</div>
) : (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
{t('pricingPage.upcoming')}
</div>
)}
</div> </div>
)}
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
<ul className="space-y-3 mb-6 flex-1">
{[
t('pricingPage.feat1'),
t('pricingPage.feat2'),
t('pricingPage.feat3'),
t('pricingPage.feat4'),
t('pricingPage.feat5'),
t('pricingPage.feat6'),
].map((feat, idx) => (
<li key={idx} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feat}</span>
</li>
))}
</ul>
{isCurrent ? (
<>
{ctaButton}
{license.error && (
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
</p>
)}
{isFree && (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.noCreditCard')}
</p>
)}
</>
) : isFilled ? (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
{t('pricingPage.soldOut')}
</div>
) : (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
{t('pricingPage.upcoming')}
</div>
)}
</div> </div>
</div> );
); })}
})} </div>
</div> </div>
</div> </div>
) : ( ) : (

View file

@ -10,6 +10,10 @@ interface UpgradeModalProps {
onRegisterClick: () => void; onRegisterClick: () => void;
onStartCheckout: () => Promise<void>; onStartCheckout: () => Promise<void>;
onZoomToFreeZone: () => void; onZoomToFreeZone: () => void;
/** When true, the user came in via a share link; relabel "Continue with demo"
* to "Back to shared area" since clicking it returns to the share's coords,
* not the central-London demo. */
isShareReturn?: boolean;
} }
export default function UpgradeModal({ export default function UpgradeModal({
@ -18,6 +22,7 @@ export default function UpgradeModal({
onRegisterClick, onRegisterClick,
onStartCheckout, onStartCheckout,
onZoomToFreeZone, onZoomToFreeZone,
isShareReturn,
}: UpgradeModalProps) { }: UpgradeModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -67,7 +72,9 @@ export default function UpgradeModal({
{/* Header */} {/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center"> <div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2> <h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p> <p className="text-warm-300 text-sm">
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
</p>
</div> </div>
{/* Body */} {/* Body */}
@ -122,7 +129,7 @@ export default function UpgradeModal({
onClick={onZoomToFreeZone} onClick={onZoomToFreeZone}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400" className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
> >
{t('upgrade.continueWithDemo')} {isShareReturn ? t('upgrade.backToSharedArea') : t('upgrade.continueWithDemo')}
</button> </button>
</div> </div>
</div> </div>

View file

@ -40,6 +40,9 @@ interface UseMapDataOptions {
viewFeature: string | null; viewFeature: string | null;
activeFeature: string | null; activeFeature: string | null;
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
shareCode?: string;
} }
export function useMapData({ export function useMapData({
@ -48,6 +51,7 @@ export function useMapData({
viewFeature, viewFeature,
activeFeature, activeFeature,
travelTimeEntries, travelTimeEntries,
shareCode,
}: UseMapDataOptions) { }: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]); const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]); const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
@ -148,6 +152,7 @@ export function useMapData({
params.set('fields', fieldsParam); params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam); if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature); if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal })) fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json()) .then((res) => res.json())
@ -166,6 +171,7 @@ export function useMapData({
params.set('fields', fieldsParam); params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam); if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature); if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json()) .then((res) => res.json())
@ -194,6 +200,7 @@ export function useMapData({
buildTravelParam, buildTravelParam,
dataViewFeature, dataViewFeature,
viewFeatureIsEnum, viewFeatureIsEnum,
shareCode,
]); ]);
// Fetch hexagons or postcodes when bounds/filters change // Fetch hexagons or postcodes when bounds/filters change
@ -226,6 +233,7 @@ export function useMapData({
params.set('travel', travelParam); params.set('travel', travelParam);
} }
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature); if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
const res = await fetch( const res = await fetch(
apiUrl('postcodes', params), apiUrl('postcodes', params),
authHeaders({ authHeaders({
@ -260,6 +268,7 @@ export function useMapData({
params.set('travel', travelParam); params.set('travel', travelParam);
} }
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature); if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
const res = await fetch( const res = await fetch(
apiUrl('hexagons', params), apiUrl('hexagons', params),
authHeaders({ authHeaders({
@ -311,6 +320,7 @@ export function useMapData({
viewFeatureIsEnum, viewFeatureIsEnum,
usePostcodeView, usePostcodeView,
travelParam, travelParam,
shareCode,
]); ]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData // Use drag data when it matches the current view feature, otherwise fall back to rawData

View file

@ -11,7 +11,8 @@ export function useUrlSync(
features: FeatureMeta[], features: FeatureMeta[],
selectedPOICategories: Set<string>, selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area', rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntry[] travelTimeEntries?: TravelTimeEntry[],
share?: string
) { ) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -26,7 +27,8 @@ export function useUrlSync(
features, features,
selectedPOICategories, selectedPOICategories,
rightPaneTab, rightPaneTab,
travelTimeEntries travelTimeEntries,
share
); );
const search = params.toString(); const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -36,5 +38,13 @@ export function useUrlSync(
return () => { return () => {
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
}; };
}, [currentView, filters, features, selectedPOICategories, rightPaneTab, travelTimeEntries]); }, [
currentView,
filters,
features,
selectedPOICategories,
rightPaneTab,
travelTimeEntries,
share,
]);
} }

View file

@ -101,6 +101,9 @@ const de: Translations = {
registerAndUpgrade: 'Registrieren & Upgraden', registerAndUpgrade: 'Registrieren & Upgraden',
alreadyHaveAccount: 'Bereits ein Konto? Anmelden', alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
continueWithDemo: 'Mit Demo fortfahren', continueWithDemo: 'Mit Demo fortfahren',
backToSharedArea: 'Zurück zum geteilten Gebiet',
sharedAreaDescription:
'Sie sehen ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichern Sie sich lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
checkoutFailed: 'Bezahlvorgang fehlgeschlagen', checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
}, },
@ -343,51 +346,48 @@ const de: Translations = {
exploreTheMap: 'Passende Postleitzahlen finden', exploreTheMap: 'Passende Postleitzahlen finden',
seeTheDifference: 'So funktioniert es', seeTheDifference: 'So funktioniert es',
showcaseHeader: 'Produktvorschau', showcaseHeader: 'Produktvorschau',
showcaseContext: 'Käufersuche in ganz England', showcaseContext: 'So funktioniert Perfect Postcode',
showcaseStep1Tab: 'Beschreiben', showcaseStep1Tab: 'Filtern',
showcaseStep1Title: 'Beschreiben Sie das Leben, das Sie möchten', showcaseStep1Title: 'Filter kombinieren, die Portale gar nicht haben',
showcaseStep1Body: showcaseStep1Body:
'Nutzen Sie natürliche Sprache oder Filter, um komplexe Kaufkriterien in eine Suche zu verwandeln.', 'Wählen Sie, was wirklich zählt — jenseits von Preis und Zimmern. Wo sich Ihre Filter überlappen, das ist Ihre echte Shortlist.',
showcaseStep1Prompt: showcaseStep1Chip1: 'Ruhige Straßen',
'2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhige Straßen, gute Schulen', showcaseStep1Chip2: 'Top-Grundschulen',
showcaseStep1Chip1: '<= £525k', showcaseStep1Chip3: 'Unter £500k',
showcaseStep1Chip2: '2+ Schlafzimmer', showcaseStep1VennCenter: 'Postleitzahlen, die alle drei erfüllen',
showcaseStep1Chip3: '45 Min. Pendeln', showcaseStep2Tab: 'Abgleichen',
showcaseStep1Chip4: 'Wenig Straßenlärm', showcaseStep2Title: 'Abgeglichen mit 13M Verkäufen und neuesten staatlichen Studien',
showcaseStep2Tab: 'Entdecken',
showcaseStep2Title: 'Zeigen Sie Orte, die Sie nicht erwogen hatten',
showcaseStep2Body: showcaseStep2Body:
'Die Karte markiert passende Postleitzahlen, auch außerhalb Ihrer bisherigen Shortlist.', 'Jedes Hexagon in England wird gegen Ihre Filter bewertet. Die Karte leuchtet dort, wo sich Treffer häufen.',
showcaseStep2Metric: '47 passende Postleitzahlen', showcaseStep2Region: 'Großraum London',
showcaseStep2Note: 'jenseits der offensichtlichen Shortlist', showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseKnownAreas: 'Bekannte Gegenden', showcaseStep2ClustersLabel: 'Passende Cluster',
showcaseNewMatches: 'Neue Treffer',
showcaseKnownAreaStatus: 'wenige Treffer',
showcaseStep3Tab: 'Prüfen', showcaseStep3Tab: 'Prüfen',
showcaseStep3Title: 'Verstehen Sie, warum jede Postleitzahl passt', showcaseStep3Title: 'Jedes Viertel in einem Panel lesen',
showcaseStep3Body: showcaseStep3Body:
'Öffnen Sie einen Treffer und prüfen Sie die Belege, bevor Sie ein Wochenende für Besichtigungen opfern.', 'Öffnen Sie ein Hexagon und Sie sehen Verkaufspreis-Trends, Kriminalität, Demografie und Schulen — ohne Tab-Jonglieren.',
showcaseStep3Postcode: 'Postleitzahl-Beispiel', showcaseStep3HeaderArea: 'Penge · SE20',
showcaseStep3Area: 'Penge', showcaseStep3HeaderFit: 'Starker Fit · 7/8',
showcaseStep3Code: 'SE20', showcaseStep3Stat1Label: 'Verkaufspreis-Trend',
showcaseStep3Score: 'Starker Fit', showcaseStep3Stat2Label: 'Kriminalität',
showcaseEvidence1: '42 Min. Pendelzeit', showcaseStep3Stat2Value: 'Unter Borough-Schnitt',
showcaseEvidence2: 'Weniger Straßenlärm', showcaseStep3Stat3Label: 'Median-Alter',
showcaseEvidence3: 'Gute Grundschuloptionen', showcaseStep3Stat4Label: 'Breitband',
showcaseEvidence4: 'Verkaufspreise im Budget', showcaseStep3Stat4Value: '1 Gbps verfügbar',
showcaseStep4Tab: 'Vergleichen', showcaseStep3Stat5Label: 'Grundschulen',
showcaseStep4Title: 'Kompromisse vor Besichtigungen vergleichen', showcaseStep3Stat5Value: '3 „outstanding“ in 1 Meile',
showcaseStep4Tab: 'Exportieren',
showcaseStep4Title: 'Shortlist sichern und losziehen',
showcaseStep4Body: showcaseStep4Body:
'Erstellen Sie eine Shortlist danach, was Sie gewinnen und aufgeben, nicht nur nach Ruf.', 'Mit einem Klick landet jede passende Postleitzahl — samt Belegen — in einer Tabelle. Jetzt wissen Sie genau, wo Sie suchen sollen.',
showcaseCompare1: 'Penge: Londoner Bahnanschluss, mehr Platz', showcaseStep4FileName: 'perfect-postcode-shortlist.xlsx',
showcaseCompare2: 'Totterdown: fußläufige Straßen in Bristol', showcaseStep4ExportLabel: 'Nach Excel exportieren',
showcaseCompare3: 'Walkley: größere Häuser, guter Gegenwert', showcaseStep4ColPostcode: 'Postleitzahl',
showcaseMapLabel: 'Passende Postleitzahlen', showcaseStep4ColScore: 'Fit',
showcaseSaveLabel: 'Shortlist bereit', showcaseStep4ColCommute: 'Pendeln',
showcaseMatchPenge: 'London im Budget', showcaseStep4ColPrice: 'Median verkauft',
showcaseMatchAbbeyWood: 'Elizabeth line + Grünflächen', showcaseStep4Conclusion:
showcaseMatchTotterdown: 'Bristol gut zu Fuß', 'Schluss mit Raten — besichtigen Sie Häuser dort, wo bereits alles passt.',
showcaseMatchWalkley: 'Sheffield: Platz + Schulen',
statProperties: 'historische Verkäufe', statProperties: 'historische Verkäufe',
statFilters: 'kombinierbare Filter', statFilters: 'kombinierbare Filter',
statEvery: 'Jede', statEvery: 'Jede',
@ -406,17 +406,6 @@ const de: Translations = {
streetCard2Title: 'Sehen Sie Kompromisse vor Besichtigungen', streetCard2Title: 'Sehen Sie Kompromisse vor Besichtigungen',
streetCard2Body: streetCard2Body:
'Vergleichen Sie Preis, Platz, Pendelzeit, Sicherheit, Schulen, Breitband, Lärm und Energieeffizienz, bevor Sie Wochenenden mit Besichtigungen verbringen.', 'Vergleichen Sie Preis, Platz, Pendelzeit, Sicherheit, Schulen, Breitband, Lärm und Energieeffizienz, bevor Sie Wochenenden mit Besichtigungen verbringen.',
howToUseIt: 'So funktioniert es',
howStep1Title: 'Beschreiben Sie das Leben, das Sie brauchen',
howStep1Desc: 'Budget, Pendelzeit, Immobilientyp, Schulen, Sicherheit, Platz und Alltag.',
howStep2Title: 'Passende Postleitzahlen anzeigen',
howStep2Desc: 'Die Karte markiert Orte, die Ihre Filter erfüllen, auch unbekanntere Gegenden.',
howStep3Title: 'Die Belege prüfen',
howStep3Desc:
'Prüfen Sie Verkaufspreise, Wohnfläche, EPC, Straßenlärm, Breitband, Kriminalität und Schulen.',
howStep4Title: 'Shortlist vor der Listingsuche',
howStep4Desc:
'Gehen Sie mit besseren Suchgebieten zu Rightmove, Zoopla, Maklern und Besichtigungen.',
othersVs: 'Andere vs', othersVs: 'Andere vs',
checkMyPostcode: 'Immobilienportale', checkMyPostcode: 'Immobilienportale',
areaGuides: 'Postleitzahl-Berichte', areaGuides: 'Postleitzahl-Berichte',
@ -553,42 +542,41 @@ const de: Translations = {
dsElectionUse: dsElectionUse:
'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: Wahlbeteiligung (%) und Parteistimmenanteile (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.', 'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: Wahlbeteiligung (%) und Parteistimmenanteile (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
// FAQ section titles // FAQ section titles
faqFindingTitle: 'Ihr Gebiet finden', faqFindingTitle: 'Suchstrategie',
faqCommuteTitle: 'Pendelweg und Reisezeit', faqCommuteTitle: 'Reisezeit-Routing',
faqBudgetTitle: 'Budget und Preis-Leistung', faqBudgetTitle: 'Geschätzte Preise',
faqSafetyTitle: 'Sicherheit und Nachbarschaft', faqSafetyTitle: 'Sicherheit und Nachbarschaft',
faqFamiliesTitle: 'Familien und Schulen', faqFamiliesTitle: 'Familien und Schulen',
faqEnvironmentTitle: 'Umwelt und Lebensqualität', faqEnvironmentTitle: 'Umwelt und Lebensqualität',
faqDueDiligenceTitle: 'Umfang und Due Diligence',
faqPrivacyTitle: 'Datenschutz',
faqWhyTitle: 'Warum Perfect Postcode', faqWhyTitle: 'Warum Perfect Postcode',
faqPricingTitle: 'Preise und Zugang', faqPricingTitle: 'Zugang',
faqTipsTitle: 'Tipps und Tricks', faqTipsTitle: 'Tipps und Tricks',
// FAQ items — Finding Your Area // FAQ items — Finding Your Area
faqFinding1Q: faqFinding1Q: 'Wo soll ich suchen, wenn die offensichtlichen Gebiete zu teuer sind?',
'Ich weiß nicht einmal, welche Gebiete ich mir ansehen soll. Kann mir das helfen?',
faqFinding1A: faqFinding1A:
'Genau dafür ist es da. Legen Sie Ihre Filter fest (Budget, Pendelzeit, geringe Kriminalität, gute Schulen) und die Karte leuchtet auf, um Ihnen jedes Gebiet zu zeigen, das alle Kriterien erfüllt. Kein nächtliches Googeln nach „beste Wohngegenden bei Manchester“ mehr.', 'Setzen Sie Budget, Immobilientyp, Wohnfläche, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, Parks und andere Muss-Kriterien. Die Karte entfernt Postleitzahlen, die diese Tests nicht bestehen, sodass übersehene Gebiete sichtbar werden, bevor Sie in Portalen suchen.',
faqFinding2Q: 'Ich ziehe irgendwohin, wo ich noch nie war. Wie fange ich überhaupt an?', faqFinding2Q: 'Ich ziehe irgendwohin, wo ich noch nie war. Wie fange ich überhaupt an?',
faqFinding2A: faqFinding2A:
'Stellen Sie Ihre Filter für das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von „Ich kenne keine einzige Straße“ zu einer Auswahlliste in wenigen Minuten.', 'Stellen Sie Ihre Filter für das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von „Ich kenne keine einzige Straße“ zu einer Auswahlliste in wenigen Minuten.',
faqFinding3Q: 'Wie finde ich Gebiete, die alle meine Kriterien gleichzeitig erfüllen?', faqFinding3Q: 'Was mache ich, wenn die Suche zu viele oder zu wenige Gebiete zeigt?',
faqFinding3A: faqFinding3A:
'Kombinieren Sie mehrere Filter (Kriminalität unter dem Durchschnitt, gute Schulen, Pendelweg unter 40 Minuten) und färben Sie die Karte nach Preis, um die Gebiete mit dem besten Preis-Leistungs-Verhältnis zu finden. Die Karte aktualisiert sich in Echtzeit, wenn Sie die Regler bewegen.', 'Beginnen Sie mit harten Grenzen und färben Sie die Karte dann nach einem Trade-off wie Preis pro m², Straßenlärm, Schulscore oder Reisezeit. Wenn die Karte zu eng wird, lockern Sie einen Regler und sehen sofort, welcher Kompromiss neue Optionen öffnet.',
// FAQ items — Commute and Travel // FAQ items — Commute and Travel
faqCommute1Q: faqCommute1Q: 'Wie werden die Reisezeiten berechnet?',
'Kann ich sehen, wie lange mein Pendelweg aus verschiedenen Gebieten tatsächlich dauern würde?',
faqCommute1A: faqCommute1A:
'Legen Sie Ihren Arbeitsplatz als Ziel fest und wir färben jede Postleitzahl nach Fahrzeit ob mit Auto, Fahrrad oder öffentlichen Verkehrsmitteln. Filtern Sie nach Ihrer maximalen Pendelzeit und der Rest verschwindet.', 'Die Reisezeiten werden mit Conveyal R5 vorab berechnet, einer Routing-Engine für Verkehrsanalysen. Für jedes unterstützte Ziel routen wir zu erreichbaren Postleitzahlen über das Straßen- und ÖPNV-Netz und speichern schlanke Postleitzahl-Reisezeitdateien für Auto, Fahrrad, Fußweg und öffentliche Verkehrsmittel. Dadurch kann die Karte viele Postleitzahlen schnell filtern, statt jede Route einzeln abzufragen.',
faqCommute2Q: 'Wie ist das besser als Google Maps?', faqCommute2Q: 'Was sollte ich über die Reisezeitwerte wissen?',
faqCommute2A: faqCommute2A:
'Google Maps zeigt Ihnen eine Fahrt auf einmal. Wir färben jede Postleitzahl in England nach Pendelzeit in einem Blick, sodass Sie Hunderte von Gebieten nebeneinander vergleichen können, anstatt sie einzeln zu suchen.', 'ÖPNV-Zeiten verwenden ein morgendliches Abfahrtsfenster von 07:30 bis 08:30. Standard ist die Medianzeit, also der typische Wert in diesem Fenster; die Best-Case-Option nutzt das 5. Perzentil für gut getimte Abfahrten. Es sind modellierte Vergleichswerte, keine Live-Störungen, Verkehrslagen oder Bahnsteigwechsel-Prognosen.',
// FAQ items — Budget and Value // FAQ items — Budget and Value
faqBudget1Q: 'Wie finde ich Gebiete, in denen ich am meisten Wohnfläche für mein Geld bekomme?', faqBudget1Q: 'Wie funktioniert der Algorithmus für den geschätzten aktuellen Preis?',
faqBudget1A: faqBudget1A:
'Filtern Sie nach Preis pro m² und Sie sehen sofort, welche Postleitzahlen am meisten Fläche pro Pfund bieten. Kombinieren Sie es mit dem Energiebewertungsfilter, um Immobilien mit hohen Heizkosten zu vermeiden.', 'Die proprietäre Schätzung beginnt mit dem letzten HM-Land-Registry-Verkaufspreis. Dieser wird mit einem Repeat-Sales-Index auf heute angepasst, gelernt aus Immobilien mit mehreren Verkäufen und gegliedert nach Postleitzahlsektor und Immobilientyp. Dünn besetzte Gebiete werden Richtung District-, Area-, nationalem und hedonischem Fallback-Modell geschrumpft und räumlich geglättet. Danach wird das Ergebnis mit einer Nächste-Nachbarn-Schätzung aus nahe gelegenen, kürzlich verkauften, ähnlichen Immobilien auf Basis von Preis pro m² und EPC-Wohnfläche gemischt.',
faqBudget2Q: faqBudget2Q: 'Warum den geschätzten aktuellen Preis statt des letzten Verkaufspreises nutzen?',
'Wie stelle ich sicher, dass ein günstiges Gebiet nicht aus gutem Grund günstig ist?',
faqBudget2A: faqBudget2A:
'Legen Sie Benachteiligungswerte, Kriminalitätsstatistiken, Schulbewertungen und Breitbandgeschwindigkeiten neben den Preis. Wenn eine Postleitzahl erschwinglich ist und bei allem, was zählt, gut abschneidet, haben Sie echten Wert gefunden nicht nur einen niedrigen Preis mit Kompromissen, die Sie noch nicht bemerkt haben.', 'Der letzte Verkaufspreis kann Jahre oder Jahrzehnte alt sein, und aktuelle Angebotspreise zeigen nur, was gerade auf dem Markt ist. Der geschätzte aktuelle Preis bringt alte Verkäufe näher an heutige Marktbedingungen, damit Sie mehr Immobilien vergleichen, geschätzten Preis pro m² berechnen und gute Werte erkennen können, bevor passende Inserate erscheinen. Es ist ein Screening-Wert, keine formale Bewertung.',
// FAQ items — Safety and Neighbourhood // FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Wie kann ich prüfen, ob ein Gebiet sicher ist, bevor ich dorthin ziehe?', faqSafety1Q: 'Wie kann ich prüfen, ob ein Gebiet sicher ist, bevor ich dorthin ziehe?',
faqSafety1A: faqSafety1A:
@ -610,12 +598,29 @@ const de: Translations = {
'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Straße liegen?', 'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Straße liegen?',
faqEnv1A: faqEnv1A:
'Filtern Sie nach EPC-Bewertung (A bis C), dann überlagern Sie die Straßenlärmdaten, um alles über Ihrem Schwellenwert auszuschließen. Färben Sie nach einem der beiden Kriterien, um ruhige, effiziente Straßen auf einen Blick zu erkennen.', 'Filtern Sie nach EPC-Bewertung (A bis C), dann überlagern Sie die Straßenlärmdaten, um alles über Ihrem Schwellenwert auszuschließen. Färben Sie nach einem der beiden Kriterien, um ruhige, effiziente Straßen auf einen Blick zu erkennen.',
faqEnv2Q: 'Zeigt es Hochwasser- oder Senkungsrisiken?', faqEnv2Q: 'Zeigt es Hochwasser-, Senkungs- oder Gutachterrisiken?',
faqEnv2A: faqEnv2A:
'Wir integrieren Bodenstabilitätsdaten, damit Sie vor dem Kauf auf Senkungen, Schrumpf-Quell-Tone und andere geologische Risiken prüfen können. Schließen Sie Risikogebiete frühzeitig aus.', 'Nicht als Live-Filter. Wir zeigen Daten wie Straßenlärm, EPC, Baualter und lokale Umweltindikatoren, aber Hochwassersuchen, Grundbuchthemen, bauliche Mängel und Finanzierbarkeit müssen weiterhin durch Anwälte, Kreditgeber und Gutachter geprüft werden.',
faqEnv3Q: 'Kann ich Gebiete mit schnellem Breitband finden, die wirklich ruhig sind?', faqEnv3Q: 'Kann ich Gebiete mit schnellem Breitband finden, die wirklich ruhig sind?',
faqEnv3A: faqEnv3A:
'Überlagern Sie den Breitbandfilter mit den Straßenlärmdaten, um Straßen mit guter Anbindung und wenig Verkehrslärm zu finden. Färben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.', 'Überlagern Sie den Breitbandfilter mit den Straßenlärmdaten, um Straßen mit guter Anbindung und wenig Verkehrslärm zu finden. Färben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Sollte ich das vor oder nach Rightmove nutzen?',
faqDueDiligence1A:
'Nutzen Sie Perfect Postcode vor und neben Listing-Portalen. Rightmove, Zoopla und OnTheMarket bleiben für aktuelle Verfügbarkeit, Fotos, Maklerkontakt, Besichtigungen und Benachrichtigungen zuständig. Perfect Postcode hilft zu entscheiden, welche Postleitzahlen die Suche überhaupt wert sind.',
faqDueDiligence2Q: 'Kann ich nach Garten, Garage, Grundriss oder Anzeigentext filtern?',
faqDueDiligence2A:
'Nur, wenn die Information in strukturierten offiziellen Daten vorhanden ist. Perfect Postcode kann nach Wohnfläche, Immobilientyp, Eigentumsform, EPC, Verkaufspreisen und lokalen Daten filtern. Gärten, Garagen, Ausrichtung, Raumaufteilung und Maklerformulierungen müssen weiterhin in der Anzeige und bei der Besichtigung geprüft werden.',
faqDueDiligence3Q: 'Kann ich Preisreduzierungen oder die Online-Dauer eines Listings sehen?',
faqDueDiligence3A:
'Derzeit nicht. Perfect Postcode basiert auf offiziellen Verkaufspreis-, EPC-, Postleitzahl-, Reisezeit- und Nachbarschaftsdaten, nicht auf Live-Listing-Feeds. Sie können aber Transaktionsdatum, Verkaufshistorie, geschätzten aktuellen Wert und Preis pro m² nutzen, um einen Angebotspreis einzuordnen.',
faqDueDiligence4Q: 'Was sollte ich vor einem Angebot trotzdem prüfen?',
faqDueDiligence4A:
'Nutzen Sie Perfect Postcode für den Gebiets- und Wert-Check, prüfen Sie danach aber Listing-Details, Eigentumsform, Laufzeit eines Leaseholds, Service Charge, Planungshistorie, Hochwasserrisiko, Grundbuchthemen, Kreditgeberanforderungen und Gutachten über den üblichen professionellen Prozess.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Speichern Sie personenbezogene Daten über mich?',
faqPrivacy1A:
'Wir speichern keine personenbezogenen Daten in den Immobilien- und Nachbarschaftsdatensätzen. Diese Datensätze stammen aus offiziellen und öffentlichen Quellen und dienen der Postleitzahlen- und Immobilienrecherche. Wenn Sie ein Konto erstellen, speichern wir nur, was für den Betrieb des Dienstes erforderlich ist, etwa E-Mail-Adresse, Lizenzstatus, Newsletter-Einstellung, gespeicherte Suchen, gespeicherte Immobilien und Zahlungskennungen, die über Stripe verarbeitet werden. Diese Kontodaten behandeln wir nach UK GDPR und dem Data Protection Act 2018.',
// FAQ items — Why Perfect Postcode // FAQ items — Why Perfect Postcode
faqWhy1Q: 'Ich benutze bereits Rightmove. Was bringt mir das zusätzlich?', faqWhy1Q: 'Ich benutze bereits Rightmove. Was bringt mir das zusätzlich?',
faqWhy1A: faqWhy1A:
@ -630,9 +635,9 @@ const de: Translations = {
faqPricing1Q: 'Lohnt es sich wirklich, für ein Immobilien-Suchtool zu bezahlen?', faqPricing1Q: 'Lohnt es sich wirklich, für ein Immobilien-Suchtool zu bezahlen?',
faqPricing1A: faqPricing1A:
'Ein Hauskauf ist wahrscheinlich die größte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Straße, schlechtes Breitband, steigende Kriminalität) bevor Sie sich festlegen, könnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankfüllung.', 'Ein Hauskauf ist wahrscheinlich die größte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Straße, schlechtes Breitband, steigende Kriminalität) bevor Sie sich festlegen, könnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankfüllung.',
faqPricing2Q: 'Ist das ein Abonnement?', faqPricing2Q: 'Was bedeutet lebenslanger Zugang?',
faqPricing2A: faqPricing2A:
'Nein. Einmalzahlung, Ihres für immer. Nutzen Sie es intensiv während Ihrer Suche, kommen Sie zurück, wenn Sie neugierig auf ein neues Gebiet sind, und es ist immer noch da, falls Sie erneut umziehen.', 'Lebenslanger Zugang bedeutet, dass eine Zahlung Ihrem Konto laufenden Zugriff auf die kostenpflichtige Perfect-Postcode-Karte für die Lebensdauer des Dienstes gibt. Es ist kein Monats- oder Jahresabo, und normale Datenaktualisierungen sind enthalten. Sie können es während dieser Suche nutzen, später zurückkommen und weiterhin Zugriff haben, wenn Sie erneut umziehen.',
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?', faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
faqPricing3A: faqPricing3A:
'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.', 'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.',

View file

@ -99,6 +99,9 @@ const en = {
registerAndUpgrade: 'Register & Upgrade', registerAndUpgrade: 'Register & Upgrade',
alreadyHaveAccount: 'Already have an account? Log in', alreadyHaveAccount: 'Already have an account? Log in',
continueWithDemo: 'Continue with demo', continueWithDemo: 'Continue with demo',
backToSharedArea: 'Back to shared area',
sharedAreaDescription:
'Youre viewing a shared area. To explore beyond it, get lifetime access to every postcode, every filter, and every neighbourhood in England.',
checkoutFailed: 'Checkout failed', checkoutFailed: 'Checkout failed',
}, },
@ -339,49 +342,47 @@ const en = {
exploreTheMap: 'Find my matching postcodes', exploreTheMap: 'Find my matching postcodes',
seeTheDifference: 'See how it works', seeTheDifference: 'See how it works',
showcaseHeader: 'Product showcase', showcaseHeader: 'Product showcase',
showcaseContext: 'England-wide buyer search', showcaseContext: 'How Perfect Postcode works',
showcaseStep1Tab: 'Describe', showcaseStep1Tab: 'Filter',
showcaseStep1Title: 'Describe the life you want', showcaseStep1Title: 'Turn vague needs into a tight search',
showcaseStep1Body: showcaseStep1Body:
'Use natural language or filters to turn messy buyer criteria into one search.', 'Set what matters and see exactly how many wrong-fit postcodes each requirement keeps out of your search.',
showcaseStep1Prompt: '2-bed under £525k, 45 mins to work, quiet streets, good schools', showcaseStep1Chip1: 'Quiet streets',
showcaseStep1Chip1: '<= £525k', showcaseStep1Chip2: 'Top-rated primaries',
showcaseStep1Chip2: '2+ beds', showcaseStep1Chip3: 'Under £500k',
showcaseStep1Chip3: '45 min commute', showcaseStep1VennCenter: 'Postcodes that meet all three',
showcaseStep1Chip4: 'Low road noise', showcaseStep2Tab: 'Match',
showcaseStep2Tab: 'Discover', showcaseStep2Title: 'Let the map surface places you would not have typed',
showcaseStep2Title: 'Reveal places you had not considered',
showcaseStep2Body: showcaseStep2Body:
'The map lights up matching postcodes, including areas outside your usual shortlist.', 'Scan England by fit instead of starting from familiar area names. Hidden pockets become visible before listing portals narrow your imagination.',
showcaseStep2Metric: '47 matching postcodes', showcaseStep2Region: 'Greater London',
showcaseStep2Note: 'beyond the obvious shortlist', showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseKnownAreas: 'Known areas', showcaseStep2ClustersLabel: 'Matching clusters',
showcaseNewMatches: 'New matches', showcaseStep3Tab: 'Inspect',
showcaseKnownAreaStatus: 'few matches', showcaseStep3Title: 'Inspect why a postcode made the cut',
showcaseStep3Tab: 'Check',
showcaseStep3Title: 'Understand why each postcode fits',
showcaseStep3Body: showcaseStep3Body:
'Open a result and check the evidence before you give up a weekend for viewings.', 'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
showcaseStep3Postcode: 'Postcode example', showcaseStep3HeaderArea: 'Penge · SE20',
showcaseStep3Area: 'Penge', showcaseStep3HeaderFit: 'Strong fit · 7/8',
showcaseStep3Code: 'SE20', showcaseStep3Stat1Label: 'Sold price trend',
showcaseStep3Score: 'Strong fit', showcaseStep3Stat2Label: 'Crime rate',
showcaseEvidence1: '42 min commute', showcaseStep3Stat2Value: 'Below borough avg.',
showcaseEvidence2: 'Lower road noise', showcaseStep3Stat3Label: 'Median age',
showcaseEvidence3: 'Good primary options', showcaseStep3Stat4Label: 'Broadband',
showcaseEvidence4: 'Sold prices in budget', showcaseStep3Stat4Value: '1 Gbps available',
showcaseStep4Tab: 'Compare', showcaseStep3Stat5Label: 'Primary schools',
showcaseStep4Title: 'Compare trade-offs before viewings', showcaseStep3Stat5Value: '3 outstanding within 1 mile',
showcaseStep4Body: 'Shortlist areas by what you gain and give up, not by reputation alone.', showcaseStep4Tab: 'Scout',
showcaseCompare1: 'Penge: London rail links, more space', showcaseStep4Title: 'Scout it out yourself',
showcaseCompare2: 'Totterdown: walkable Bristol streets', showcaseStep4Body:
showcaseCompare3: 'Walkley: larger homes, strong value', 'Take three grounded starting points into the real world. Walk the streets, test the commute, and compare viewings with context.',
showcaseMapLabel: 'Matching postcodes', showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseSaveLabel: 'Shortlist ready', showcaseStep4ExportLabel: 'Export to Excel',
showcaseMatchPenge: 'London budget fit', showcaseStep4ColPostcode: 'Postcode',
showcaseMatchAbbeyWood: 'Elizabeth line + green space', showcaseStep4ColScore: 'Fit',
showcaseMatchTotterdown: 'Bristol walkability', showcaseStep4ColCommute: 'Commute',
showcaseMatchWalkley: 'Sheffield space + schools', showcaseStep4ColPrice: 'Median sold',
showcaseStep4Conclusion: 'You can start your journey from here. You are no longer lost.',
statProperties: 'historical sales', statProperties: 'historical sales',
statFilters: 'combinable filters', statFilters: 'combinable filters',
statEvery: 'Every', statEvery: 'Every',
@ -400,17 +401,6 @@ const en = {
streetCard2Title: 'See the trade-offs before viewings', streetCard2Title: 'See the trade-offs before viewings',
streetCard2Body: streetCard2Body:
'Compare price, space, commute, safety, schools, broadband, noise, and energy ratings before you spend weekends travelling between viewings.', 'Compare price, space, commute, safety, schools, broadband, noise, and energy ratings before you spend weekends travelling between viewings.',
howToUseIt: 'How to use it',
howStep1Title: 'Describe the life you need',
howStep1Desc: 'Budget, commute, property type, schools, safety, space, and daily essentials.',
howStep2Title: 'Reveal matching postcodes',
howStep2Desc:
'The map highlights the places that pass your filters, including unfamiliar areas.',
howStep3Title: 'Check the evidence',
howStep3Desc:
'Inspect sold prices, floor area, EPC, road noise, broadband, crime, and schools.',
howStep4Title: 'Shortlist before you browse listings',
howStep4Desc: 'Take a better search area to Rightmove, Zoopla, agents, and viewings.',
othersVs: 'Others vs', othersVs: 'Others vs',
checkMyPostcode: 'Listing portals', checkMyPostcode: 'Listing portals',
areaGuides: 'Postcode reports', areaGuides: 'Postcode reports',
@ -467,7 +457,7 @@ const en = {
dataSourcesIntro: dataSourcesIntro:
'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.', 'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
faqIntro: faqIntro:
'Whether youre buying in London, comparing commuter towns, or sanity-checking an unfamiliar postcode, heres how Perfect Postcode helps you work out where to look.', 'Whether youre narrowing a first-time buyer search, checking an unfamiliar postcode, or building a viewing shortlist, heres how Perfect Postcode helps you work out where to look.',
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.',
source: 'Source:', source: 'Source:',
optOut: 'Opt out of public disclosure', optOut: 'Opt out of public disclosure',
@ -546,95 +536,113 @@ const en = {
dsElectionUse: dsElectionUse:
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: voter turnout (%) and party vote shares (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.', 'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: voter turnout (%) and party vote shares (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
// FAQ section titles // FAQ section titles
faqFindingTitle: 'Finding Your Area', faqFindingTitle: 'Search Strategy',
faqCommuteTitle: 'Commute and Travel', faqCommuteTitle: 'Travel-Time Routing',
faqBudgetTitle: 'Budget and Value', faqBudgetTitle: 'Estimated Prices',
faqSafetyTitle: 'Safety and Neighbourhood', faqSafetyTitle: 'Safety and Neighbourhood',
faqFamiliesTitle: 'Families and Schools', faqFamiliesTitle: 'Families and Schools',
faqEnvironmentTitle: 'Environment and Quality of Life', faqEnvironmentTitle: 'Environment and Quality of Life',
faqDueDiligenceTitle: 'Scope and Due Diligence',
faqPrivacyTitle: 'Privacy and Data Protection',
faqWhyTitle: 'Why Perfect Postcode', faqWhyTitle: 'Why Perfect Postcode',
faqPricingTitle: 'Pricing and Access', faqPricingTitle: 'Access',
faqTipsTitle: 'Tips and Tricks', faqTipsTitle: 'Tips and Tricks',
// FAQ items — Finding Your Area // FAQ items — Finding Your Area
faqFinding1Q: 'I dont even know which areas to look at. Can this help?', faqFinding1Q: 'Where should I look once the obvious areas are too expensive?',
faqFinding1A: faqFinding1A:
'That is exactly what it is for. Set your filters (budget, commute time, low crime, good schools, broadband, road noise) and the map lights up to show every postcode that fits. You can discover areas before you know their names.', 'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
faqFinding2Q: 'Im moving somewhere Ive never been. How do I even start?', faqFinding2Q: 'How do I find good postcodes in places I do not know well?',
faqFinding2A: faqFinding2A:
'Set what matters and the map highlights the postcodes that qualify. You go from "I do not know a single street" to a shortlist you can inspect in minutes.', 'Filter the whole map by your hard requirements, then inspect the clusters that remain. You can compare unfamiliar postcodes by commute, sold prices, schools, crime, broadband, noise, and amenities instead of relying on reputation.',
faqFinding3Q: 'How do I find areas that tick all my boxes at once?', faqFinding3Q: 'What should I do when my search returns too many or too few areas?',
faqFinding3A: faqFinding3A:
'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.', 'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.',
// FAQ items — Commute and Travel // FAQ items — Commute and Travel
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?', faqCommute1Q: 'How are the travel times calculated?',
faqCommute1A: faqCommute1A:
'Set your workplace as a destination and well colour every postcode by journey time, whether thats by car, bike, or public transport. Filter to your max commute and the rest disappears.', 'Travel times are precomputed with Conveyal R5, a routing engine used for transport analysis. For each supported destination we route to reachable postcodes over the street and transit network, then store sparse postcode travel-time files for car, cycling, walking, and public transport. That lets the map filter thousands of postcodes quickly instead of calling a route API one postcode at a time.',
faqCommute2Q: 'How is that better than checking Google Maps?', faqCommute2Q: 'What should I know about the travel-time numbers?',
faqCommute2A: faqCommute2A:
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.', 'Public transport times use a morning peak departure window, 07:30 to 08:30. The default is the median journey time, which is the typical result across that window; the best-case toggle uses the 5th percentile for well-timed departures. They are modelled comparison times, not live disruption, traffic, or platform-change predictions.',
// FAQ items — Budget and Value // FAQ items — Budget and Value
faqBudget1Q: 'How do I find areas where I get the most space for my money?', faqBudget1Q: 'How does the estimated current price algorithm work?',
faqBudget1A: faqBudget1A:
'Filter by price per sqm and youll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.', 'The proprietary estimate starts with the last HM Land Registry sale price. It adjusts that price to today using a repeat-sales index learned from properties that sold more than once, stratified by postcode sector and property type. Sparse areas are shrunk toward district, area, national, and hedonic fallback models, then spatially smoothed. Finally, the result is blended with a nearest-neighbour estimate from nearby, recently sold, same-type homes using adjusted price per sqm and EPC floor area.',
faqBudget2Q: 'How do I make sure a cheap area isnt cheap for a reason?', faqBudget2Q: 'Why use estimated current price instead of last sold price?',
faqBudget2A: faqBudget2A:
'Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, youve found genuine value, not just a low price with trade-offs you havent spotted yet.', 'Last sold price can be years or decades old, and live asking prices only cover whatever happens to be listed today. Estimated current price puts old sales into current-market terms so you can compare more properties, calculate estimated price per sqm, and spot areas that look good value before listings appear. It is a screening estimate, not a formal valuation.',
// FAQ items — Safety and Neighbourhood // FAQ items — Safety and Neighbourhood
faqSafety1Q: 'How can I check if an area is safe before I move there?', faqSafety1Q: 'What type of crime is common around this postcode?',
faqSafety1A: faqSafety1A:
'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.', 'Police-recorded crime is broken down by type, including violence, burglary, robbery, vehicle crime, antisocial behaviour, shoplifting, drugs, and public order. You can filter by the specific risks you care about instead of relying on a single vague safety score.',
faqSafety2Q: faqSafety2Q: 'What should I check before viewing an unfamiliar street?',
'I keep finding flats that look great online, then the area turns out to be rough.',
faqSafety2A: faqSafety2A:
'Thats exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.', 'Check crime, road noise, deprivation, broadband, parks, groceries, schools, and commute before you book. The listing photos can still be useful, but they should not be the first time you learn what the street is like.',
// FAQ items — Families and Schools // FAQ items — Families and Schools
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?', faqFamilies1Q: 'Which areas have the right mix of schools, space, safety, and commute?',
faqFamilies1A: faqFamilies1A:
'Yes. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, and the map highlights only the areas that tick every box. No more cross-referencing five different websites.', 'Stack Ofsted ratings, crime, parks, commute, floor area, property type, and budget on one map. The result is a practical family shortlist rather than a pile of separate school, crime, listing, and transport searches.',
faqFamilies2Q: 'How do I know if a neighbourhood has parks and playgrounds nearby?', faqFamilies2Q: 'Does this prove I am inside a school catchment?',
faqFamilies2A: faqFamilies2A:
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.', 'No. We show nearby school quality and area-level education data, but admissions boundaries and priority rules can change. Treat Perfect Postcode as a shortlist tool, then verify catchments and admissions with the school or local authority.',
// FAQ items — Environment and Quality of Life // FAQ items — Environment and Quality of Life
faqEnv1Q: 'Can I find energy-efficient homes that arent on a noisy road?', faqEnv1Q: 'How do I avoid a noisy road without losing commute or broadband quality?',
faqEnv1A: faqEnv1A:
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.', 'Filter by road noise, then keep commute time, broadband speed, price, and property filters active. You can colour by any one feature while the others keep the shortlist honest.',
faqEnv2Q: 'Does it show flood or subsidence risk?', faqEnv2Q: 'Do you show flood risk, subsidence, or survey issues?',
faqEnv2A: faqEnv2A:
'We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before committing to a property. Filter out risky areas early.', 'Not as live filters today. We show data such as road noise, EPC, construction age, and local environment indicators, but flood searches, title checks, structural issues, and mortgageability still need proper conveyancing, lender checks, and a survey.',
faqEnv3Q: 'Can I find areas with fast broadband that are actually quiet?', faqEnv3Q: 'What running-cost checks can I do before viewing?',
faqEnv3A: faqEnv3A:
'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to compare areas at a glance.', 'You can screen for EPC rating, total floor area, construction age, council tax authority, broadband, and noise before viewing. That will not predict your exact bills, but it helps you avoid obvious mismatches early.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Should I use this before or after checking Rightmove?',
faqDueDiligence1A:
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts. Perfect Postcode helps you work out which postcodes are worth searching in the first place.',
faqDueDiligence2Q: 'Why cant I filter by garden, garage, or layout?',
faqDueDiligence2A:
'Only where the information exists in structured official data. Perfect Postcode can filter by things like floor area, property type, tenure, EPC, sold prices, and local data. Gardens, garages, aspect, room layout, and estate-agent wording still need to be checked in the listing and at the viewing.',
faqDueDiligence3Q: 'Do you track listing price cuts and time on market?',
faqDueDiligence3A:
'Not currently. Perfect Postcode is built around official sold-price, EPC, postcode, travel-time, and neighbourhood data rather than live listing feeds. You can still use date of last transaction, sold price history, estimated current value, and price per sqm to judge whether a live asking price looks stretched.',
faqDueDiligence4Q: 'What should I still verify before making an offer?',
faqDueDiligence4A:
'Use Perfect Postcode to sanity-check the area and value, then verify the live listing details, tenure, lease terms, service charge, planning history, flood risk, title issues, lender requirements, and survey findings through the usual professional process.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Do you store personal data about me?',
faqPrivacy1A:
'We do not store personal data in the property and neighbourhood datasets. Those datasets are built from official and public sources for postcode and property research. If you create an account, we store only what is needed to run the service, such as your email address, licence status, newsletter preference, saved searches, saved properties, and payment identifiers handled through Stripe. We handle that account data under UK GDPR and the Data Protection Act 2018.',
// FAQ items — Why Perfect Postcode // FAQ items — Why Perfect Postcode
faqWhy1Q: 'I already use Rightmove. What does this add?', faqWhy1Q: 'What does this show that listing portals usually do not?',
faqWhy1A: faqWhy1A:
'Rightmove shows you listings. Perfect Postcode shows you where to look. Crime rates, school ratings, broadband speeds, noise levels, sold prices, floor area, EPC data, and more are all filterable on one map before you open listings.', 'Listing portals start from homes that are for sale right now. Perfect Postcode starts from the places that fit your life and budget, using sold prices, floor area, commute, schools, crime, noise, broadband, EPC, tenure, and amenities before you open the listings.',
faqWhy2Q: 'Cant I just research all this myself for free?', faqWhy2Q: 'How much manual research does this save?',
faqWhy2A: faqWhy2A:
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, ONS statistics, Street View and commute tools one postcode at a time. Or you could have the evidence filterable and colour-coded on one map.', 'You can, but it means stitching together Land Registry, EPC, police, Ofsted, Ofcom, ONS, Defra, travel-time, and map data one postcode at a time. Perfect Postcode makes those sources filterable across England in one place.',
faqWhy3Q: 'Where does the data actually come from?', faqWhy3Q: 'How reliable are the underlying sources?',
faqWhy3A: faqWhy3A:
'Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We dont scrape estate agents or make anything up. You can verify any record against the original source.', 'The core datasets come from official or authoritative sources such as HM Land Registry, EPC records, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey, and OpenStreetMap. They are excellent for shortlisting and comparison, but any purchase decision still needs current checks and professional advice.',
// FAQ items — Pricing and Access // FAQ items — Pricing and Access
faqPricing1Q: 'Is it really worth paying for a property search tool?', faqPricing1Q: 'Why pay when postcode reports are free?',
faqPricing1A: faqPricing1A:
'Buying a home is likely the biggest purchase youll make. Spotting one red flag (a noisy road, weak broadband, awkward commute, poor school access, or bad value) before committing could save you years of regret.', 'Free postcode tools are useful once you already know what to check. Perfect Postcode is for scanning every postcode in England against your criteria, combining filters, comparing trade-offs, saving searches, and exporting a shortlist before you commit weekends to viewings.',
faqPricing2Q: 'Is this a subscription?', faqPricing2Q: 'What does lifetime access mean?',
faqPricing2A: faqPricing2A:
'No. One-time payment, yours forever. Use it intensively during your search, come back whenever youre curious about a new area, and its still there if you ever move again.', 'Lifetime access means one payment gives your account ongoing access to the paid Perfect Postcode map for the lifetime of the service. It is not a monthly or annual subscription, and normal data updates are included. You can use it during this search, come back later, and still have access if you move again.',
faqPricing3Q: 'What can I access on the free tier?', faqPricing3Q: 'What can I access on the free tier?',
faqPricing3A: faqPricing3A:
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.', 'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
// FAQ items — Tips and Tricks // FAQ items — Tips and Tricks
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?', faqTips1Q: 'How do I describe a search in plain English?',
faqTips1A: faqTips1A:
'Type what you want in plain English, something like "2-bed under £525k, 45 minutes to work, quiet, good broadband", and it will set up the relevant filters in one go. Tweak any of them manually afterwards.', 'Type something like "freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband", and the AI filter will set up the matching filters it understands. It will also tell you when a request, such as garden size, is not available as a structured filter.',
faqTips2Q: 'Can I save a search and come back to it later?', faqTips2Q: 'Can I save a search and come back to it later?',
faqTips2A: faqTips2A:
'Hit the save button and everything is captured: your filters, zoom level, and which data layer youre colouring by. Pick up exactly where you left off or share the link with your partner.', 'Hit the save button and everything is captured: your filters, zoom level, and which data layer youre colouring by. Pick up exactly where you left off or share the link with your partner.',
faqTips3Q: 'Can I export the data Im looking at?', faqTips3Q: 'Can I export the data Im looking at?',
faqTips3A: faqTips3A:
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.', 'Use the export button to download the currently filtered properties as a spreadsheet. The export respects your active filters, so you can take a clean shortlist into portals, viewings, spreadsheets, or conversations with someone buying with you.',
}, },
// ── Account Page ─────────────────────────────────── // ── Account Page ───────────────────────────────────

View file

@ -102,6 +102,9 @@ const fr: Translations = {
registerAndUpgrade: 'Sinscrire et passer à la version complète', registerAndUpgrade: 'Sinscrire et passer à la version complète',
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous', alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
continueWithDemo: 'Continuer avec la démo', continueWithDemo: 'Continuer avec la démo',
backToSharedArea: 'Retour à la zone partagée',
sharedAreaDescription:
'Vous consultez une zone partagée. Pour explorer au-delà, obtenez un accès à vie à chaque code postal, chaque filtre et chaque quartier dAngleterre.',
checkoutFailed: 'Échec du paiement', checkoutFailed: 'Échec du paiement',
}, },
@ -347,51 +350,48 @@ const fr: Translations = {
exploreTheMap: 'Trouver mes codes postaux', exploreTheMap: 'Trouver mes codes postaux',
seeTheDifference: 'Voir comment ça marche', seeTheDifference: 'Voir comment ça marche',
showcaseHeader: 'Aperçu du produit', showcaseHeader: 'Aperçu du produit',
showcaseContext: 'Recherche dacheteur en Angleterre', showcaseContext: 'Comment fonctionne Perfect Postcode',
showcaseStep1Tab: 'Décrire', showcaseStep1Tab: 'Filtrer',
showcaseStep1Title: 'Décrivez la vie que vous voulez', showcaseStep1Title: 'Combinez des filtres absents des portails',
showcaseStep1Body: showcaseStep1Body:
'Utilisez le langage naturel ou les filtres pour transformer des critères complexes en une seule recherche.', 'Choisissez ce qui compte vraiment — au-delà du prix et des chambres. Là où vos filtres se croisent, voilà votre vraie sélection.',
showcaseStep1Prompt: showcaseStep1Chip1: 'Rues calmes',
'2 chambres sous £525k, 45 min jusquau travail, rues calmes, bonnes écoles', showcaseStep1Chip2: 'Écoles primaires bien notées',
showcaseStep1Chip1: '<= £525k', showcaseStep1Chip3: 'Moins de £500k',
showcaseStep1Chip2: '2+ chambres', showcaseStep1VennCenter: 'Codes postaux qui cochent les trois',
showcaseStep1Chip3: '45 min de trajet', showcaseStep2Tab: 'Comparer',
showcaseStep1Chip4: 'Faible bruit routier', showcaseStep2Title: 'Recoupé avec 13M de ventes et les dernières études publiques',
showcaseStep2Tab: 'Découvrir',
showcaseStep2Title: 'Révélez des lieux que vous naviez pas envisagés',
showcaseStep2Body: showcaseStep2Body:
'La carte met en évidence les codes postaux compatibles, y compris hors de votre sélection habituelle.', 'Chaque hexagone en Angleterre est noté selon vos filtres. La carte séclaire là où les correspondances se regroupent.',
showcaseStep2Metric: '47 codes postaux compatibles', showcaseStep2Region: 'Grand Londres',
showcaseStep2Note: 'au-delà de la sélection évidente', showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseKnownAreas: 'Zones connues', showcaseStep2ClustersLabel: 'Grappes correspondantes',
showcaseNewMatches: 'Nouvelles correspondances', showcaseStep3Tab: 'Inspecter',
showcaseKnownAreaStatus: 'peu de résultats', showcaseStep3Title: 'Lisez chaque quartier dans un seul panneau',
showcaseStep3Tab: 'Vérifier',
showcaseStep3Title: 'Comprenez pourquoi chaque code postal correspond',
showcaseStep3Body: showcaseStep3Body:
'Ouvrez un résultat et vérifiez les preuves avant de réserver votre week-end pour des visites.', 'Ouvrez nimporte quel hexagone : tendances de prix vendus, criminalité, démographie et écoles, sans jongler entre les onglets.',
showcaseStep3Postcode: 'Exemple de code postal', showcaseStep3HeaderArea: 'Penge · SE20',
showcaseStep3Area: 'Penge', showcaseStep3HeaderFit: 'Très bon · 7/8',
showcaseStep3Code: 'SE20', showcaseStep3Stat1Label: 'Tendance des prix vendus',
showcaseStep3Score: 'Très bon ajustement', showcaseStep3Stat2Label: 'Criminalité',
showcaseEvidence1: '42 min de trajet', showcaseStep3Stat2Value: 'Sous la moyenne du borough',
showcaseEvidence2: 'Bruit routier plus faible', showcaseStep3Stat3Label: 'Âge médian',
showcaseEvidence3: 'Bonnes écoles primaires', showcaseStep3Stat4Label: 'Débit internet',
showcaseEvidence4: 'Prix vendus dans le budget', showcaseStep3Stat4Value: '1 Gbps disponible',
showcaseStep4Tab: 'Comparer', showcaseStep3Stat5Label: 'Écoles primaires',
showcaseStep4Title: 'Comparez les compromis avant les visites', showcaseStep3Stat5Value: '3 « outstanding » à moins dun mile',
showcaseStep4Tab: 'Exporter',
showcaseStep4Title: 'Sauvegardez votre sélection et lancez-vous',
showcaseStep4Body: showcaseStep4Body:
'Sélectionnez les zones selon ce que vous gagnez et perdez, pas seulement selon leur réputation.', 'En un clic, chaque code postal correspondant — avec ses preuves — sexporte vers un tableur. Vous savez maintenant exactement où chercher.',
showcaseCompare1: 'Penge : liaisons londoniennes, plus despace', showcaseStep4FileName: 'perfect-postcode-shortlist.xlsx',
showcaseCompare2: 'Totterdown : rues accessibles à pied à Bristol', showcaseStep4ExportLabel: 'Exporter vers Excel',
showcaseCompare3: 'Walkley : logements plus grands, bon rapport qualité-prix', showcaseStep4ColPostcode: 'Code postal',
showcaseMapLabel: 'Codes postaux compatibles', showcaseStep4ColScore: 'Ajust.',
showcaseSaveLabel: 'Sélection prête', showcaseStep4ColCommute: 'Trajet',
showcaseMatchPenge: 'budget compatible à Londres', showcaseStep4ColPrice: 'Prix médian',
showcaseMatchAbbeyWood: 'Elizabeth line + espaces verts', showcaseStep4Conclusion:
showcaseMatchTotterdown: 'Bristol accessible à pied', 'Fini les suppositions — visitez des biens dans des lieux déjà validés.',
showcaseMatchWalkley: 'espace + écoles à Sheffield',
statProperties: 'ventes historiques', statProperties: 'ventes historiques',
statFilters: 'filtres combinables', statFilters: 'filtres combinables',
statEvery: 'Chaque', statEvery: 'Chaque',
@ -410,19 +410,6 @@ const fr: Translations = {
streetCard2Title: 'Voyez les compromis avant les visites', streetCard2Title: 'Voyez les compromis avant les visites',
streetCard2Body: streetCard2Body:
'Comparez prix, surface, trajet, sécurité, écoles, débit internet, bruit et énergie avant de passer vos week-ends à courir les visites.', 'Comparez prix, surface, trajet, sécurité, écoles, débit internet, bruit et énergie avant de passer vos week-ends à courir les visites.',
howToUseIt: 'Comment lutiliser',
howStep1Title: 'Décrivez la vie dont vous avez besoin',
howStep1Desc:
'Budget, trajet, type de bien, écoles, sécurité, surface et essentiels du quotidien.',
howStep2Title: 'Révélez les codes postaux compatibles',
howStep2Desc:
'La carte met en évidence les lieux qui passent vos filtres, y compris les zones moins connues.',
howStep3Title: 'Vérifiez les preuves',
howStep3Desc:
'Consultez prix vendus, surface, DPE, bruit routier, débit internet, criminalité et écoles.',
howStep4Title: 'Faites votre sélection avant les annonces',
howStep4Desc:
'Arrivez sur Rightmove, Zoopla, chez les agents et aux visites avec de meilleures zones de recherche.',
othersVs: 'Les autres vs', othersVs: 'Les autres vs',
checkMyPostcode: 'Portails dannonces', checkMyPostcode: 'Portails dannonces',
areaGuides: 'Rapports de code postal', areaGuides: 'Rapports de code postal',
@ -558,40 +545,41 @@ const fr: Translations = {
dsElectionUse: dsElectionUse:
'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : participation électorale (%) et parts des voix par parti (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.', 'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : participation électorale (%) et parts des voix par parti (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.',
// FAQ section titles // FAQ section titles
faqFindingTitle: 'Trouver votre quartier', faqFindingTitle: 'Stratégie de recherche',
faqCommuteTitle: 'Trajet et déplacements', faqCommuteTitle: 'Calcul des temps de trajet',
faqBudgetTitle: 'Budget et rapport qualité-prix', faqBudgetTitle: 'Prix estimés',
faqSafetyTitle: 'Sécurité et voisinage', faqSafetyTitle: 'Sécurité et voisinage',
faqFamiliesTitle: 'Familles et écoles', faqFamiliesTitle: 'Familles et écoles',
faqEnvironmentTitle: 'Environnement et qualité de vie', faqEnvironmentTitle: 'Environnement et qualité de vie',
faqDueDiligenceTitle: 'Périmètre et vérifications',
faqPrivacyTitle: 'Confidentialité et protection des données',
faqWhyTitle: 'Pourquoi Perfect Postcode', faqWhyTitle: 'Pourquoi Perfect Postcode',
faqPricingTitle: 'Tarifs et accès', faqPricingTitle: 'Accès',
faqTipsTitle: 'Astuces', faqTipsTitle: 'Astuces',
// FAQ items — Finding Your Area // FAQ items — Finding Your Area
faqFinding1Q: 'Je ne sais même pas quelles zones regarder. Est-ce que ça peut maider ?', faqFinding1Q: 'Où chercher quand les zones évidentes sont trop chères ?',
faqFinding1A: faqFinding1A:
'Cest exactement à ça que ça sert. Définissez vos filtres (budget, temps de trajet, faible criminalité, bonnes écoles) et la carte sillumine pour montrer chaque zone qui coche toutes les cases. Fini de chercher « meilleures zones pour vivre près de Manchester » à minuit.', 'Définissez le budget, le type de bien, la surface, le trajet, les écoles, la criminalité, le bruit, le débit internet, les parcs et vos autres critères indispensables. La carte retire les codes postaux qui échouent à ces tests, ce qui fait apparaître des zones moins évidentes avant même de chercher des annonces.',
faqFinding2Q: 'Je déménage dans un endroit que je ne connais pas du tout. Par où commencer ?', faqFinding2Q: 'Je déménage dans un endroit que je ne connais pas du tout. Par où commencer ?',
faqFinding2A: faqFinding2A:
'Définissez vos filtres pour ce qui compte et la carte met instantanément en évidence les zones qui correspondent. Vous passez de « je ne connais pas une seule rue » à une sélection en quelques minutes.', 'Définissez vos filtres pour ce qui compte et la carte met instantanément en évidence les zones qui correspondent. Vous passez de « je ne connais pas une seule rue » à une sélection en quelques minutes.',
faqFinding3Q: 'Comment trouver des zones qui cochent toutes mes cases en une seule fois ?', faqFinding3Q: 'Que faire si ma recherche renvoie trop ou trop peu de zones ?',
faqFinding3A: faqFinding3A:
'Empilez plusieurs filtres (criminalité sous la moyenne, bonnes écoles, trajet de moins de 40 minutes) puis colorez la carte par prix pour repérer les zones au meilleur rapport qualité-prix. La carte se met à jour en temps réel quand vous bougez les curseurs.', 'Commencez par vos limites fermes, puis colorez la carte selon un compromis comme le prix au m², le bruit routier, le score des écoles ou le temps de trajet. Si la carte devient trop restrictive, relâchez un curseur et voyez immédiatement quel compromis ouvre de nouvelles options.',
// FAQ items — Commute and Travel // FAQ items — Commute and Travel
faqCommute1Q: 'Puis-je voir combien de temps durerait mon trajet depuis différentes zones ?', faqCommute1Q: 'Comment les temps de trajet sont-ils calculés ?',
faqCommute1A: faqCommute1A:
'Définissez votre lieu de travail comme destination et nous colorons chaque code postal par temps de trajet, que ce soit en voiture, à vélo ou en transports en commun. Filtrez par votre trajet maximum et le reste disparaît.', 'Les temps de trajet sont précalculés avec Conveyal R5, un moteur de routage utilisé pour lanalyse des transports. Pour chaque destination prise en charge, nous calculons les trajets vers les codes postaux atteignables sur le réseau de rues et de transports, puis stockons des fichiers de temps de trajet par code postal pour la voiture, le vélo, la marche et les transports publics. La carte peut ainsi filtrer beaucoup de codes postaux rapidement au lieu dappeler une API itinéraire un par un.',
faqCommute2Q: 'En quoi cest mieux que Google Maps ?', faqCommute2Q: 'Que faut-il savoir sur ces temps de trajet ?',
faqCommute2A: faqCommute2A:
'Google Maps vous montre un trajet à la fois. Nous colorons chaque code postal dAngleterre par temps de trajet en une seule vue, pour que vous puissiez comparer des centaines de zones côte à côte au lieu de les chercher une par une.', 'Les temps en transports publics utilisent une fenêtre de départ du matin, de 07:30 à 08:30. Par défaut, nous affichons la médiane, cest-à-dire le résultat typique sur cette fenêtre ; loption best-case utilise le 5e percentile pour un départ bien synchronisé. Ce sont des temps de comparaison modélisés, pas des données en direct sur les perturbations, le trafic ou les changements de quai.',
// FAQ items — Budget and Value // FAQ items — Budget and Value
faqBudget1Q: 'Comment trouver les zones où jai le plus despace pour mon argent ?', faqBudget1Q: 'Comment fonctionne lalgorithme de prix actuel estimé ?',
faqBudget1A: faqBudget1A:
'Filtrez par prix au m² et vous verrez instantanément quels codes postaux offrent le plus despace par livre. Combinez avec le filtre de classement énergétique pour éviter les biens aux coûts de chauffage élevés.', 'Lestimation propriétaire part du dernier prix de vente HM Land Registry. Elle lajuste à aujourdhui avec un indice de ventes répétées appris à partir de biens vendus plusieurs fois, segmenté par secteur de code postal et type de bien. Les zones peu fournies sont rapprochées de modèles district, zone, national et hédonique, puis lissées spatialement. Le résultat est ensuite mélangé avec une estimation par plus proches voisins, à partir de biens similaires vendus récemment à proximité, du prix au m² ajusté et de la surface EPC.',
faqBudget2Q: faqBudget2Q: 'Pourquoi utiliser le prix actuel estimé plutôt que le dernier prix vendu ?',
'Comment massurer quune zone bon marché ne lest pas pour de mauvaises raisons ?',
faqBudget2A: faqBudget2A:
'Superposez les scores de défaveur, les statistiques de criminalité, les notes des écoles et les débits internet à côté du prix. Si un code postal est abordable et obtient de bons scores sur tout ce qui compte, vous avez trouvé une vraie bonne affaire, pas juste un prix bas avec des compromis que vous navez pas encore repérés.', 'Le dernier prix vendu peut dater de plusieurs années ou décennies, et les prix demandés ne couvrent que les biens en vente aujourdhui. Le prix actuel estimé remet les anciennes ventes dans des conditions de marché plus actuelles, afin de comparer davantage de biens, calculer un prix estimé au m² et repérer les zones de valeur avant larrivée des annonces. Cest un outil de tri, pas une évaluation formelle.',
// FAQ items — Safety and Neighbourhood // FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Comment vérifier si une zone est sûre avant dy déménager ?', faqSafety1Q: 'Comment vérifier si une zone est sûre avant dy déménager ?',
faqSafety1A: faqSafety1A:
@ -613,12 +601,30 @@ const fr: Translations = {
'Puis-je trouver des logements économes en énergie qui ne sont pas sur une route bruyante ?', 'Puis-je trouver des logements économes en énergie qui ne sont pas sur une route bruyante ?',
faqEnv1A: faqEnv1A:
'Filtrez par classement EPC (A à C), puis superposez les données de bruit routier pour exclure tout ce qui dépasse votre seuil. Colorez par lun ou lautre critère pour repérer les rues calmes et économes dun coup dœil.', 'Filtrez par classement EPC (A à C), puis superposez les données de bruit routier pour exclure tout ce qui dépasse votre seuil. Colorez par lun ou lautre critère pour repérer les rues calmes et économes dun coup dœil.',
faqEnv2Q: 'Est-ce que ça montre le risque dinondation ou daffaissement ?', faqEnv2Q: 'Affichez-vous le risque dinondation, daffaissement ou de survey ?',
faqEnv2A: faqEnv2A:
'Nous incluons des données de stabilité du sol pour que vous puissiez vérifier les risques daffaissement, de retrait-gonflement des argiles et dautres aléas géologiques avant de vous engager. Excluez les zones à risque dès le départ.', 'Pas sous forme de filtres en direct aujourdhui. Nous affichons des données comme le bruit routier, lEPC, lâge de construction et certains indicateurs locaux, mais les recherches dinondation, les titres, les problèmes structurels et la capacité de financement doivent toujours être vérifiés par les professionnels habituels.',
faqEnv3Q: 'Puis-je trouver des zones avec un bon débit internet qui soient aussi calmes ?', faqEnv3Q: 'Puis-je trouver des zones avec un bon débit internet qui soient aussi calmes ?',
faqEnv3A: faqEnv3A:
'Superposez le filtre de débit internet avec les données de bruit routier pour trouver des rues avec une bonne connectivité et peu de bruit. Colorez par lun ou lautre critère pour comparer les zones dun coup dœil.', 'Superposez le filtre de débit internet avec les données de bruit routier pour trouver des rues avec une bonne connectivité et peu de bruit. Colorez par lun ou lautre critère pour comparer les zones dun coup dœil.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Faut-il lutiliser avant ou après Rightmove ?',
faqDueDiligence1A:
'Utilisez Perfect Postcode avant et en parallèle des portails dannonces. Rightmove, Zoopla et OnTheMarket restent utiles pour la disponibilité en temps réel, les photos, le contact avec lagent, les visites et les alertes. Perfect Postcode sert à décider quels codes postaux valent la peine dêtre recherchés.',
faqDueDiligence2Q: 'Puis-je filtrer par jardin, garage, agencement ou texte dannonce ?',
faqDueDiligence2A:
'Seulement lorsque linformation existe dans des données officielles structurées. Perfect Postcode peut filtrer la surface, le type de bien, le régime foncier, lEPC, les prix de vente et les données locales. Les jardins, garages, orientation, agencements et formulations dagent doivent encore être vérifiés dans lannonce et lors de la visite.',
faqDueDiligence3Q:
'Puis-je voir lhistorique des baisses de prix ou la durée de mise en ligne ?',
faqDueDiligence3A:
'Pas actuellement. Perfect Postcode sappuie sur les prix de vente officiels, lEPC, les codes postaux, les temps de trajet et les données de quartier plutôt que sur des flux dannonces en direct. Vous pouvez tout de même utiliser la date de dernière transaction, lhistorique des ventes, la valeur actuelle estimée et le prix au m² pour juger un prix demandé.',
faqDueDiligence4Q: 'Que dois-je encore vérifier avant de faire une offre ?',
faqDueDiligence4A:
'Utilisez Perfect Postcode pour vérifier la zone et la valeur, puis contrôlez les détails de lannonce, le régime foncier, les conditions de leasehold, les charges, lhistorique durbanisme, le risque dinondation, les titres, les exigences du prêteur et le survey via le processus professionnel habituel.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Stockez-vous des données personnelles me concernant ?',
faqPrivacy1A:
'Nous ne stockons pas de données personnelles dans les jeux de données immobilières et de quartier. Ces données proviennent de sources officielles et publiques et servent à la recherche par code postal et par propriété. Si vous créez un compte, nous stockons uniquement ce qui est nécessaire au fonctionnement du service, comme votre adresse e-mail, votre statut de licence, votre préférence newsletter, vos recherches enregistrées, vos biens enregistrés et les identifiants de paiement traités par Stripe. Ces données de compte sont traitées conformément au UK GDPR et au Data Protection Act 2018.',
// FAQ items — Why Perfect Postcode // FAQ items — Why Perfect Postcode
faqWhy1Q: 'Jutilise déjà Rightmove. Quest-ce que ça apporte de plus ?', faqWhy1Q: 'Jutilise déjà Rightmove. Quest-ce que ça apporte de plus ?',
faqWhy1A: faqWhy1A:
@ -634,9 +640,9 @@ const fr: Translations = {
'Est-ce que ça vaut vraiment le coup de payer pour un outil de recherche immobilière ?', 'Est-ce que ça vaut vraiment le coup de payer pour un outil de recherche immobilière ?',
faqPricing1A: faqPricing1A:
'Lachat dun logement est probablement le plus gros achat de votre vie. Repérer un seul signal dalerte (une route bruyante, un mauvais débit, une criminalité en hausse) avant de vous engager pourrait vous épargner des années de regrets. Ça coûte moins quun plein dessence.', 'Lachat dun logement est probablement le plus gros achat de votre vie. Repérer un seul signal dalerte (une route bruyante, un mauvais débit, une criminalité en hausse) avant de vous engager pourrait vous épargner des années de regrets. Ça coûte moins quun plein dessence.',
faqPricing2Q: 'Est-ce un abonnement ?', faqPricing2Q: 'Que signifie laccès à vie ?',
faqPricing2A: faqPricing2A:
'Non. Paiement unique, à vous pour toujours. Utilisez-le intensivement pendant votre recherche, revenez quand vous êtes curieux dune nouvelle zone, et cest toujours là si vous déménagez à nouveau.', 'Laccès à vie signifie quun paiement donne à votre compte un accès continu à la carte payante Perfect Postcode pendant la durée de vie du service. Ce nest pas un abonnement mensuel ou annuel, et les mises à jour normales des données sont incluses. Vous pouvez lutiliser pendant cette recherche, revenir plus tard et conserver laccès si vous déménagez à nouveau.',
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?', faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
faqPricing3A: faqPricing3A:
'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de lAngleterre, il faut laccès à vie.', 'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de lAngleterre, il faut laccès à vie.',

View file

@ -102,6 +102,9 @@ const hu: Translations = {
registerAndUpgrade: 'Regisztráció és frissítés', registerAndUpgrade: 'Regisztráció és frissítés',
alreadyHaveAccount: 'Már van fiókod? Jelentkezz be', alreadyHaveAccount: 'Már van fiókod? Jelentkezz be',
continueWithDemo: 'Folytatás demóval', continueWithDemo: 'Folytatás demóval',
backToSharedArea: 'Vissza a megosztott területre',
sharedAreaDescription:
'Egy megosztott területet nézel. Ha tovább szeretnél felfedezni, szerezz élethosszig tartó hozzáférést Anglia minden irányítószámához, szűrőjéhez és környékéhez.',
checkoutFailed: 'A fizetés sikertelen', checkoutFailed: 'A fizetés sikertelen',
}, },
@ -340,50 +343,48 @@ const hu: Translations = {
exploreTheMap: 'Megfelelő irányítószámok keresése', exploreTheMap: 'Megfelelő irányítószámok keresése',
seeTheDifference: 'Így működik', seeTheDifference: 'Így működik',
showcaseHeader: 'Termékbemutató', showcaseHeader: 'Termékbemutató',
showcaseContext: 'Vevői keresés egész Angliában', showcaseContext: 'Így működik a Perfect Postcode',
showcaseStep1Tab: 'Leírás', showcaseStep1Tab: 'Szűrés',
showcaseStep1Title: 'Írd le, milyen életet szeretnél', showcaseStep1Title: 'Olyan szűrőket kombinálj, amelyek a portálokon nincsenek is',
showcaseStep1Body: showcaseStep1Body:
'Természetes nyelvvel vagy szűrőkkel alakítsd a bonyolult vevői igényeket egy kereséssé.', 'Válaszd, ami tényleg számít — áron és hálószobaszámon túl. Ahol a szűrőid metszik egymást, az a valódi listád.',
showcaseStep1Prompt: '2 háló £525k alatt, 45 perc munkáig, csendes utcák, jó iskolák', showcaseStep1Chip1: 'Csendes utcák',
showcaseStep1Chip1: '<= £525k', showcaseStep1Chip2: 'Kiváló általános iskolák',
showcaseStep1Chip2: '2+ háló', showcaseStep1Chip3: '£500k alatt',
showcaseStep1Chip3: '45 perc ingázás', showcaseStep1VennCenter: 'Mindhárom feltételt teljesítő irányítószámok',
showcaseStep1Chip4: 'Alacsony útzaj', showcaseStep2Tab: 'Egyeztetés',
showcaseStep2Tab: 'Felfedezés', showcaseStep2Title: 'Összevetve 13M eladással és a legfrissebb állami tanulmányokkal',
showcaseStep2Title: 'Mutasd meg azokat a helyeket, amelyekre nem gondoltál',
showcaseStep2Body: showcaseStep2Body:
'A térkép kiemeli a megfelelő irányítószámokat, a megszokott listádon kívül is.', 'Anglia minden hexagonját pontozzuk a szűrőid alapján. A térkép ott világít, ahol a találatok csoportosulnak.',
showcaseStep2Metric: '47 megfelelő irányítószám', showcaseStep2Region: 'Nagy-London',
showcaseStep2Note: 'a kézenfekvő listán túl', showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseKnownAreas: 'Ismert területek', showcaseStep2ClustersLabel: 'Találati klaszterek',
showcaseNewMatches: 'Új találatok', showcaseStep3Tab: 'Vizsgálat',
showcaseKnownAreaStatus: 'kevés találat', showcaseStep3Title: 'Olvasd ki a környéket egyetlen panelben',
showcaseStep3Tab: 'Ellenőrzés',
showcaseStep3Title: 'Értsd meg, miért illik egy irányítószám',
showcaseStep3Body: showcaseStep3Body:
'Nyiss meg egy találatot és ellenőrizd az adatokat, mielőtt egy hétvégét nézelődésre szánsz.', 'Nyisd meg bármelyik hexagont, és a jobb panelen láthatod az ártrendet, bűnözést, demográfiát és iskolákat — fülek nélkül.',
showcaseStep3Postcode: 'Irányítószám példa', showcaseStep3HeaderArea: 'Penge · SE20',
showcaseStep3Area: 'Penge', showcaseStep3HeaderFit: 'Erős egyezés · 7/8',
showcaseStep3Code: 'SE20', showcaseStep3Stat1Label: 'Eladási ár trend',
showcaseStep3Score: 'Erős egyezés', showcaseStep3Stat2Label: 'Bűnözési ráta',
showcaseEvidence1: '42 perc ingázás', showcaseStep3Stat2Value: 'Borough-átlag alatt',
showcaseEvidence2: 'Alacsonyabb útzaj', showcaseStep3Stat3Label: 'Medián életkor',
showcaseEvidence3: 'Jó általános iskolák', showcaseStep3Stat4Label: 'Internet',
showcaseEvidence4: 'Eladási árak a kereten belül', showcaseStep3Stat4Value: '1 Gbps elérhető',
showcaseStep4Tab: 'Összevetés', showcaseStep3Stat5Label: 'Általános iskolák',
showcaseStep4Title: 'Hasonlítsd össze a kompromisszumokat megtekintés előtt', showcaseStep3Stat5Value: '3 „outstanding” 1 mérföldön belül',
showcaseStep4Tab: 'Exportálás',
showcaseStep4Title: 'Mentsd a listád és indulj',
showcaseStep4Body: showcaseStep4Body:
'A nyereségek és veszteségek alapján szűkíts, ne csak a környék híre alapján.', 'Egy kattintás, és minden találat — a bizonyítékaikkal együtt — táblázatba kerül. Most már pontosan tudod, hol kezdj keresni.',
showcaseCompare1: 'Penge: londoni vasút, több tér', showcaseStep4FileName: 'perfect-postcode-shortlist.xlsx',
showcaseCompare2: 'Totterdown: gyalogos Bristol-utcák', showcaseStep4ExportLabel: 'Exportálás Excelbe',
showcaseCompare3: 'Walkley: nagyobb otthonok, jó érték', showcaseStep4ColPostcode: 'Irányítószám',
showcaseMapLabel: 'Megfelelő irányítószámok', showcaseStep4ColScore: 'Egyezés',
showcaseSaveLabel: 'Lista kész', showcaseStep4ColCommute: 'Ingázás',
showcaseMatchPenge: 'London a kereten belül', showcaseStep4ColPrice: 'Medián eladási ár',
showcaseMatchAbbeyWood: 'Elizabeth line + zöldterület', showcaseStep4Conclusion:
showcaseMatchTotterdown: 'Bristol gyalogosan élhető', 'Találgatás helyett indulj olyan helyekre, amelyek már átmentek a teszteden.',
showcaseMatchWalkley: 'Sheffield: tér + iskolák',
statProperties: 'korábbi eladás', statProperties: 'korábbi eladás',
statFilters: 'kombinálható szűrő', statFilters: 'kombinálható szűrő',
statEvery: 'Minden', statEvery: 'Minden',
@ -402,18 +403,6 @@ const hu: Translations = {
streetCard2Title: 'Lásd a kompromisszumokat megtekintés előtt', streetCard2Title: 'Lásd a kompromisszumokat megtekintés előtt',
streetCard2Body: streetCard2Body:
'Hasonlítsd össze az árat, méretet, ingázást, biztonságot, iskolákat, internetet, zajt és energiahatékonyságot, mielőtt hétvégéket töltesz megtekintésekkel.', 'Hasonlítsd össze az árat, méretet, ingázást, biztonságot, iskolákat, internetet, zajt és energiahatékonyságot, mielőtt hétvégéket töltesz megtekintésekkel.',
howToUseIt: 'Hogyan használd',
howStep1Title: 'Írd le, milyen életre van szükséged',
howStep1Desc:
'Költségvetés, ingázás, ingatlantípus, iskolák, biztonság, tér és napi szükségletek.',
howStep2Title: 'Fedd fel a megfelelő irányítószámokat',
howStep2Desc: 'A térkép kiemeli azokat a helyeket, amelyek átmennek a szűrőiden.',
howStep3Title: 'Ellenőrizd a bizonyítékokat',
howStep3Desc:
'Nézd meg az eladási árakat, alapterületet, EPC-t, zajt, internetet, bűnözést és iskolákat.',
howStep4Title: 'Szűkíts listát hirdetések előtt',
howStep4Desc:
'Menj Rightmove-ra, Zooplára, ügynökökhöz és megtekintésekre jobb keresési területekkel.',
othersVs: 'Mások vs.', othersVs: 'Mások vs.',
checkMyPostcode: 'Ingatlanportálok', checkMyPostcode: 'Ingatlanportálok',
areaGuides: 'Irányítószám-riportok', areaGuides: 'Irányítószám-riportok',
@ -550,40 +539,41 @@ const hu: Translations = {
dsElectionUse: dsElectionUse:
'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: részvételi arány (%) és pártszavazatarányok (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.', 'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: részvételi arány (%) és pártszavazatarányok (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.',
// FAQ section titles // FAQ section titles
faqFindingTitle: 'Területed megtalálása', faqFindingTitle: 'Keresési stratégia',
faqCommuteTitle: 'Ingazás és utazás', faqCommuteTitle: 'Utazási idő számítása',
faqBudgetTitle: 'Költségvetés és érték', faqBudgetTitle: 'Becsült árak',
faqSafetyTitle: 'Biztonság és szomszédság', faqSafetyTitle: 'Biztonság és szomszédság',
faqFamiliesTitle: 'Családok és iskolák', faqFamiliesTitle: 'Családok és iskolák',
faqEnvironmentTitle: 'Környezet és életminőség', faqEnvironmentTitle: 'Környezet és életminőség',
faqDueDiligenceTitle: 'Korlátok és ellenőrzések',
faqPrivacyTitle: 'Adatvédelem',
faqWhyTitle: 'Miért a Perfect Postcode', faqWhyTitle: 'Miért a Perfect Postcode',
faqPricingTitle: 'Árak és hozzáférés', faqPricingTitle: 'Hozzáférés',
faqTipsTitle: 'Tippek és trükkök', faqTipsTitle: 'Tippek és trükkök',
// FAQ items — Finding Your Area // FAQ items — Finding Your Area
faqFinding1Q: 'Fogalmam sincs, hol keressek. Segít ebben?', faqFinding1Q: 'Hol keressek, ha a nyilvánvaló környékek túl drágák?',
faqFinding1A: faqFinding1A:
'Pont erre való. Állítsd be a szűrőket (költségvetés, ingazási idő, alacsony bűnözés, jó iskolák), és a térkép kivilgítja minden területet, ami megfelel. Nem kell többé éjfélkor guglizni, hogy “hol a legjobb lakni Manchester közelében”.', 'Állítsd be a költségvetést, ingatlantípust, alapterületet, ingázást, iskolákat, bűnözést, zajt, szélessávot, parkokat és más kötelező feltételeket. A térkép eltávolítja azokat az irányítószámokat, amelyek nem felelnek meg, így a kevésbé nyilvánvaló területek is láthatóvá válnak, mielőtt hirdetéseket keresnél.',
faqFinding2Q: 'Olyan helyre költözöm, ahol még soha nem voltam. Hogyan kezdjem?', faqFinding2Q: 'Olyan helyre költözöm, ahol még soha nem voltam. Hogyan kezdjem?',
faqFinding2A: faqFinding2A:
'Állítsd be a szűrőket arra, ami fontos, és a térkép azonnal kiemeli a megfelelő területeket. Az “egyetlen utcát sem ismerek”-ből percek alatt rövid listához jutsz.', 'Állítsd be a szűrőket arra, ami fontos, és a térkép azonnal kiemeli a megfelelő területeket. Az “egyetlen utcát sem ismerek”-ből percek alatt rövid listához jutsz.',
faqFinding3Q: faqFinding3Q: 'Mit tegyek, ha a keresés túl sok vagy túl kevés területet ad?',
'Hogyan találom meg azokat a területeket, amelyek minden feltételemnek megfelelnek?',
faqFinding3A: faqFinding3A:
'Kombinálj több szűrőt (bűnözés átlag alatt, jó iskolák, ingazás 40 perc alatt), majd színezd a térképet ár szerint a legjobb értékű területek megtalálásához. A térkép élőben frissül, ahogy a csúszákat húzod.', 'Kezdd a kemény korlátokkal, majd színezd a térképet egy kompromisszum szerint, például négyzetméterár, közúti zaj, iskolai pontszám vagy utazási idő alapján. Ha túl szűk a találat, lazíts egy csúszkán, és azonnal látod, melyik kompromisszum nyit új lehetőségeket.',
// FAQ items — Commute and Travel // FAQ items — Commute and Travel
faqCommute1Q: 'Láthatom, mennyi lenne az ingazásom különböző területekről?', faqCommute1Q: 'Hogyan számítjátok az utazási időket?',
faqCommute1A: faqCommute1A:
'Állítsd be a munkahelyed úticélként, és minden irányítószámot kiszínezünk utazási idő szerint, legyen az autó, kerékpár vagy tömegközlekedés. Szűrj a maximális ingazási időre, és a többi eltűnik.', 'Az utazási idők előre vannak kiszámítva a Conveyal R5 közlekedési elemző útvonaltervezővel. Minden támogatott célhoz elérhető irányítószámokra számítunk útvonalakat az utcai és tömegközlekedési hálózaton, majd ritka irányítószám-utazási idő fájlokat tárolunk autóra, kerékpárra, gyaloglásra és tömegközlekedésre. Így a térkép sok irányítószámot gyorsan tud szűrni, nem kell egyesével útvonal API-t hívni.',
faqCommute2Q: 'Miért jobb ez, mint a Google Maps?', faqCommute2Q: 'Mit kell tudni az utazási idő számokról?',
faqCommute2A: faqCommute2A:
'A Google Maps egyszerre egy utazást mutat. Mi Anglia összes irányítószámát kiszínezzük ingazási idő szerint egyszerre, így száznál több területet hasonlíthatsz össze egyetlen pillantással, ahelyett hogy egyenként keres-gétnéd őket.', 'A tömegközlekedési idők a reggeli 07:30-08:30 indulási ablakot használják. Az alapértelmezett érték a medián, vagyis a tipikus eredmény ebben az ablakban; a best-case kapcsoló az 5. percentilist használja jól időzített indulásokhoz. Ezek modellezett összehasonlító idők, nem élő fennakadási, forgalmi vagy peronváltási előrejelzések.',
// FAQ items — Budget and Value // FAQ items — Budget and Value
faqBudget1Q: 'Hogyan találom meg, hol kapom a legtöbb helyet a pénzememért?', faqBudget1Q: 'Hogyan működik a becsült jelenlegi ár algoritmusa?',
faqBudget1A: faqBudget1A:
'Szűrj négyzetméterár szerint, és azonnal látod, mely irányítószámok adják a legtöbb helyet fontonként. Párosítsd az energetikai minősítés szűrővel, hogy elkerüld a magas fűtési költségű ingatlanokat.', 'A saját becslés a legutóbbi HM Land Registry eladási árból indul. Ezt egy ismételt eladásokból tanult index igazítja a jelenhez, irányítószám-szektor és ingatlantípus szerint. A kevés adattal rendelkező területek district, area, országos és hedonikus tartalékmodellek felé vannak húzva, majd térben simítva. Végül az eredmény keveredik közeli, frissen eladott, hasonló típusú ingatlanok legközelebbi szomszéd becslésével, korrigált négyzetméterár és EPC alapterület alapján.',
faqBudget2Q: 'Hogyan bizonyosodjak meg, hogy egy olcsó terület nem ok nélkül olcsó?', faqBudget2Q: 'Miért használjam a becsült jelenlegi árat a legutóbbi eladási ár helyett?',
faqBudget2A: faqBudget2A:
'Rétegezd rá a deprivációs pontokat, bűnözési statisztikákat, iskolai minősítéseket és szélessáv-sebességeket az ár mellé. Ha egy irányítószám megfizethető és minden fontos szempont szerint jól teljesít, valódi értéket találtál, nem csak alacsony árat észrevétlen kompromisszumokkal.', 'A legutóbbi eladási ár akár évekkel vagy évtizedekkel korábbi lehet, az aktuális irányárak pedig csak a ma hirdetett ingatlanokat fedik le. A becsült jelenlegi ár a régi eladásokat közelebb hozza a mai piachoz, így több ingatlant hasonlíthatsz össze, becsült négyzetméterárat számíthatsz, és jó értékű területeket találhatsz még a hirdetések előtt. Ez szűrési becslés, nem hivatalos értékbecslés.',
// FAQ items — Safety and Neighbourhood // FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Hogyan ellenőrizhetem, biztonságos-e egy terület, mielőtt odaköltözöm?', faqSafety1Q: 'Hogyan ellenőrizhetem, biztonságos-e egy terület, mielőtt odaköltözöm?',
faqSafety1A: faqSafety1A:
@ -603,12 +593,30 @@ const hu: Translations = {
faqEnv1Q: 'Találhatok energiahatékony otthonokat, amelyek nincsenek zajos úton?', faqEnv1Q: 'Találhatok energiahatékony otthonokat, amelyek nincsenek zajos úton?',
faqEnv1A: faqEnv1A:
'Szűrj EPC minősítés szerint (A-C), majd rétegezd rá a közúti zajadatokat, hogy kiszűrd a küszöbértéked feletti területeket. Színezd bármelyik jellemző szerint, hogy egy pillantással észrevedd a csendes, hatékony utcákat.', 'Szűrj EPC minősítés szerint (A-C), majd rétegezd rá a közúti zajadatokat, hogy kiszűrd a küszöbértéked feletti területeket. Színezd bármelyik jellemző szerint, hogy egy pillantással észrevedd a csendes, hatékony utcákat.',
faqEnv2Q: 'Mutatja az árvíz- vagy süllyedeskockázatot?', faqEnv2Q: 'Mutat árvíz-, süllyedés- vagy felmérési kockázatot?',
faqEnv2A: faqEnv2A:
'Tartalmazunk talajstabilitási adatokat, így ellenőrizheted a süllyeedést, agyagtalan zsugorodás-duzzadást és egyéb geológiai veszélyeket, mielőtt elköteleznéd magad egy ingatlan mellett. Szűrd ki a kockázatos területeket korán.', 'Jelenleg nem élő szűrőként. Mutatunk például közúti zajt, EPC-t, építési kort és helyi környezeti mutatókat, de az árvízkeresést, tulajdoni lapot, szerkezeti problémákat és hitelezhetőséget továbbra is ügyvéddel, hitelezővel és felmérővel kell ellenőrizni.',
faqEnv3Q: 'Találhatok területeket gyors internettel, amelyek tényleg csendesek?', faqEnv3Q: 'Találhatok területeket gyors internettel, amelyek tényleg csendesek?',
faqEnv3A: faqEnv3A:
'Rétegezd a szélessáv-sebesség szűrőt a közúti zajadatokkal, hogy megtaláld a kitűnő kapcsolattal és alacsony forgalmi zajjal rendelkező utcákat. Színezd bármelyik mérőszám szerint a területek összehasonlításához.', 'Rétegezd a szélessáv-sebesség szűrőt a közúti zajadatokkal, hogy megtaláld a kitűnő kapcsolattal és alacsony forgalmi zajjal rendelkező utcákat. Színezd bármelyik mérőszám szerint a területek összehasonlításához.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Rightmove előtt vagy után használjam?',
faqDueDiligence1A:
'Használd a Perfect Postcode-ot a hirdetési portálok előtt és mellett. A Rightmove, Zoopla és OnTheMarket továbbra is az aktuális elérhetőség, fotók, ügynöki kapcsolat, megtekintések és értesítések helye. A Perfect Postcode abban segít, hogy mely irányítószámokat érdemes egyáltalán keresni.',
faqDueDiligence2Q: 'Szűrhetek kertre, garázsra, alaprajzra vagy hirdetésszövegre?',
faqDueDiligence2A:
'Csak akkor, ha az információ strukturált hivatalos adatban elérhető. A Perfect Postcode tud szűrni alapterületre, ingatlantípusra, tulajdonformára, EPC-re, eladási árakra és helyi adatokra. A kertet, garázst, tájolást, szobaelrendezést és ügynöki megfogalmazást továbbra is a hirdetésben és a megtekintésen kell ellenőrizni.',
faqDueDiligence3Q:
'Láthatom az árcsökkentések történetét vagy hogy mióta van fent egy hirdetés?',
faqDueDiligence3A:
'Jelenleg nem. A Perfect Postcode hivatalos eladási árakra, EPC-re, irányítószám-, utazási idő- és környékadatokra épül, nem élő hirdetésfolyamokra. A legutóbbi tranzakció dátumát, eladási előzményeket, becsült aktuális értéket és négyzetméterárat viszont használhatod az irányár megítéléséhez.',
faqDueDiligence4Q: 'Mit kell még ellenőriznem ajánlattétel előtt?',
faqDueDiligence4A:
'A Perfect Postcode-dal ellenőrizd a környéket és az értéket, majd a szokásos szakmai folyamatban vizsgáld meg a hirdetés részleteit, tulajdonformát, leasehold feltételeket, service charge-ot, tervezési előzményeket, árvízkockázatot, tulajdoni kérdéseket, hitelezői feltételeket és felmérési eredményeket.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Tároltok rólam személyes adatokat?',
faqPrivacy1A:
'Az ingatlan- és környékadatok között nem tárolunk személyes felhasználói adatokat. Ezek az adatkészletek hivatalos és nyilvános forrásokból készülnek, irányítószám- és ingatlankutatáshoz. Ha fiókot hozol létre, csak a szolgáltatás működtetéséhez szükséges adatokat tároljuk, például az e-mail címet, licencállapotot, hírlevél-beállítást, mentett kereséseket, mentett ingatlanokat és a Stripe-on keresztül kezelt fizetési azonosítókat. Ezeket a fiókadatokat a UK GDPR és a Data Protection Act 2018 szerint kezeljük.',
// FAQ items — Why Perfect Postcode // FAQ items — Why Perfect Postcode
faqWhy1Q: 'Már használom a Rightmove-ot. Mit ad ez hozzá?', faqWhy1Q: 'Már használom a Rightmove-ot. Mit ad ez hozzá?',
faqWhy1A: faqWhy1A:
@ -623,9 +631,9 @@ const hu: Translations = {
faqPricing1Q: 'Tényleg megéri fizetni egy ingatlan-kereső eszközért?', faqPricing1Q: 'Tényleg megéri fizetni egy ingatlan-kereső eszközért?',
faqPricing1A: faqPricing1A:
'Egy lakásvásárlás valószínűleg a legnagyobb vásárlásod lesz. Egyetlen figyelmeztető jel felismerése (zajos út, gyenge internet, növekvő bűnözés) elköteleződés előtt éveknűi megbánást takaríthat meg. Ez kevesebbe kerül, mint egy tank benzin.', 'Egy lakásvásárlás valószínűleg a legnagyobb vásárlásod lesz. Egyetlen figyelmeztető jel felismerése (zajos út, gyenge internet, növekvő bűnözés) elköteleződés előtt éveknűi megbánást takaríthat meg. Ez kevesebbe kerül, mint egy tank benzin.',
faqPricing2Q: 'Ez előfizetés?', faqPricing2Q: 'Mit jelent az élethosszig tartó hozzáférés?',
faqPricing2A: faqPricing2A:
'Nem. Egyszeri fizetés, örökre a tied. Használd intenzíven a keresés során, gyere vissza bármikor, ha kíváncsi vagy egy új területre, és még mindig ott van, ha újra költözöl.', 'Az élethosszig tartó hozzáférés azt jelenti, hogy egy fizetéssel a fiókod folyamatos hozzáférést kap a fizetős Perfect Postcode térképhez a szolgáltatás élettartamára. Ez nem havi vagy éves előfizetés, és a szokásos adatfrissítések benne vannak. Használhatod a mostani kereséshez, később visszatérhetsz, és akkor is hozzáférsz, ha újra költözöl.',
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?', faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
faqPricing3A: faqPricing3A:
'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.', 'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',

View file

@ -100,6 +100,9 @@ const zh: Translations = {
registerAndUpgrade: '注册并升级', registerAndUpgrade: '注册并升级',
alreadyHaveAccount: '已有账户?请登录', alreadyHaveAccount: '已有账户?请登录',
continueWithDemo: '继续使用演示版', continueWithDemo: '继续使用演示版',
backToSharedArea: '返回共享区域',
sharedAreaDescription:
'您正在查看一个共享区域。要在此之外进行探索,请获取对英格兰每个邮政编码、每个筛选器和每个社区的终身访问权限。',
checkoutFailed: '结账失败', checkoutFailed: '结账失败',
}, },
@ -335,46 +338,45 @@ const zh: Translations = {
exploreTheMap: '找到匹配的邮编', exploreTheMap: '找到匹配的邮编',
seeTheDifference: '查看使用方式', seeTheDifference: '查看使用方式',
showcaseHeader: '产品展示', showcaseHeader: '产品展示',
showcaseContext: '英格兰买家搜索示例', showcaseContext: 'Perfect Postcode 的工作流程',
showcaseStep1Tab: '描述', showcaseStep1Tab: '筛选',
showcaseStep1Title: '描述您想要的生活', showcaseStep1Title: '组合那些房源门户根本没有的筛选条件',
showcaseStep1Body: '用自然语言或筛选条件,把复杂的买房需求变成一次搜索。', showcaseStep1Body:
showcaseStep1Prompt: '2房£525k以内45分钟到工作地点安静街道好学校', '挑选真正重要的条件——而不只是价格和卧室数。多个筛选条件交集的地方,才是真正的候选名单。',
showcaseStep1Chip1: '<= £525k', showcaseStep1Chip1: '安静街道',
showcaseStep1Chip2: '2+卧室', showcaseStep1Chip2: '顶级小学',
showcaseStep1Chip3: '45分钟通勤', showcaseStep1Chip3: '£500k 以内',
showcaseStep1Chip4: '低道路噪音', showcaseStep1VennCenter: '同时满足三项条件的邮编',
showcaseStep2Tab: '发现', showcaseStep2Tab: '匹配',
showcaseStep2Title: '发现您没有考虑过的地方', showcaseStep2Title: '与1300万笔成交记录和最新政府研究比对',
showcaseStep2Body: '地图会点亮匹配的邮编,包括您原本候选范围之外的区域。', showcaseStep2Body: '英格兰的每一个六边形都会按您的筛选条件打分。匹配集中的地方,地图就会亮起。',
showcaseStep2Metric: '47个匹配邮编', showcaseStep2Region: '大伦敦',
showcaseStep2Note: '超出显而易见的候选范围', showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseKnownAreas: '熟悉区域', showcaseStep2ClustersLabel: '匹配集群',
showcaseNewMatches: '新匹配',
showcaseKnownAreaStatus: '匹配较少',
showcaseStep3Tab: '检查', showcaseStep3Tab: '检查',
showcaseStep3Title: '了解每个邮编为什么匹配', showcaseStep3Title: '在一个面板里读懂每个社区',
showcaseStep3Body: '打开结果,在周末看房前先检查证据。', showcaseStep3Body:
showcaseStep3Postcode: '邮编示例', '打开任意六边形,右侧面板会展示成交价走势、犯罪率、人口结构和学校——无需在多个标签页之间来回切换。',
showcaseStep3Area: 'Penge', showcaseStep3HeaderArea: 'Penge · SE20',
showcaseStep3Code: 'SE20', showcaseStep3HeaderFit: '高度匹配 · 7/8',
showcaseStep3Score: '高度匹配', showcaseStep3Stat1Label: '成交价走势',
showcaseEvidence1: '42分钟通勤', showcaseStep3Stat2Label: '犯罪率',
showcaseEvidence2: '较低道路噪音', showcaseStep3Stat2Value: '低于本区平均水平',
showcaseEvidence3: '不错的小学选择', showcaseStep3Stat3Label: '中位年龄',
showcaseEvidence4: '成交价符合预算', showcaseStep3Stat4Label: '宽带',
showcaseStep4Tab: '比较', showcaseStep3Stat4Value: '可用 1 Gbps',
showcaseStep4Title: '看房前比较取舍', showcaseStep3Stat5Label: '小学',
showcaseStep4Body: '根据得到什么和放弃什么来筛选,而不是只看区域名声。', showcaseStep3Stat5Value: '1英里内3所「outstanding」',
showcaseCompare1: 'Penge伦敦铁路连接空间更大', showcaseStep4Tab: '导出',
showcaseCompare2: 'Totterdown布里斯托可步行街区', showcaseStep4Title: '保存名单,开始实地行动',
showcaseCompare3: 'Walkley更大住房更高性价比', showcaseStep4Body: '一键将所有匹配邮编及其证据导入电子表格。现在您清楚地知道该从哪里开始。',
showcaseMapLabel: '匹配邮编', showcaseStep4FileName: 'perfect-postcode-shortlist.xlsx',
showcaseSaveLabel: '候选名单已准备好', showcaseStep4ExportLabel: '导出到 Excel',
showcaseMatchPenge: '伦敦预算匹配', showcaseStep4ColPostcode: '邮编',
showcaseMatchAbbeyWood: 'Elizabeth line + 绿地', showcaseStep4ColScore: '匹配',
showcaseMatchTotterdown: '布里斯托步行便利', showcaseStep4ColCommute: '通勤',
showcaseMatchWalkley: '谢菲尔德空间 + 学校', showcaseStep4ColPrice: '成交中位价',
showcaseStep4Conclusion: '不再凭猜测——直接去看那些已经通过您测试的地方。',
statProperties: '历史成交记录', statProperties: '历史成交记录',
statFilters: '可组合筛选条件', statFilters: '可组合筛选条件',
statEvery: '覆盖', statEvery: '覆盖',
@ -393,15 +395,6 @@ const zh: Translations = {
streetCard2Title: '看房前先看清取舍', streetCard2Title: '看房前先看清取舍',
streetCard2Body: streetCard2Body:
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。', '在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
howToUseIt: '使用方法',
howStep1Title: '描述您需要的生活',
howStep1Desc: '预算、通勤、房产类型、学校、安全、空间和日常生活设施。',
howStep2Title: '显示匹配的邮编',
howStep2Desc: '地图会高亮通过筛选的地方,包括不熟悉的区域。',
howStep3Title: '查看证据',
howStep3Desc: '查看成交价、建筑面积、EPC、道路噪音、宽带、犯罪率和学校。',
howStep4Title: '先筛区域,再看房源',
howStep4Desc: '带着更好的搜索区域去 Rightmove、Zoopla、中介和看房。',
othersVs: '其他平台 vs', othersVs: '其他平台 vs',
checkMyPostcode: '房源门户', checkMyPostcode: '房源门户',
areaGuides: '邮编报告', areaGuides: '邮编报告',
@ -529,39 +522,41 @@ const zh: Translations = {
dsElectionUse: dsElectionUse:
'2024年7月英国大选的候选人级别结果。聚合到选区级别投票率%)和各政党得票率(%。通过NSPL邮编查询中的议会选区代码pcon关联到房产。', '2024年7月英国大选的候选人级别结果。聚合到选区级别投票率%)和各政党得票率(%。通过NSPL邮编查询中的议会选区代码pcon关联到房产。',
// FAQ section titles // FAQ section titles
faqFindingTitle: '寻找理想区域', faqFindingTitle: '搜索策略',
faqCommuteTitle: '通勤与出行', faqCommuteTitle: '出行时间算法',
faqBudgetTitle: '预算与性价比', faqBudgetTitle: '估计价格',
faqSafetyTitle: '安全与社区环境', faqSafetyTitle: '安全与社区环境',
faqFamiliesTitle: '家庭与学校', faqFamiliesTitle: '家庭与学校',
faqEnvironmentTitle: '环境与生活质量', faqEnvironmentTitle: '环境与生活质量',
faqDueDiligenceTitle: '范围与尽职调查',
faqPrivacyTitle: '隐私与数据保护',
faqWhyTitle: '为什么选择 Perfect Postcode', faqWhyTitle: '为什么选择 Perfect Postcode',
faqPricingTitle: '价格与访问权限', faqPricingTitle: '访问权限',
faqTipsTitle: '使用技巧', faqTipsTitle: '使用技巧',
// FAQ items — Finding Your Area // FAQ items — Finding Your Area
faqFinding1Q: '我完全不知道该看哪些区域,这个工具能帮到我吗', faqFinding1Q: '明显的区域太贵时,我应该去哪里找',
faqFinding1A: faqFinding1A:
'这正是它的用途。设置您的筛选条件(预算、通勤时间、低犯罪率、好学校),地图就会亮起来,显示所有符合条件的区域。不用再半夜搜索"曼彻斯特附近最好的居住区"了。', '设置预算、房产类型、室内面积、通勤、学校、犯罪率、噪音、宽带、公园等硬性条件。地图会排除不符合这些条件的邮编,让容易被忽略的区域在您开始看房源之前先浮现出来。',
faqFinding2Q: '我要搬到一个从未去过的地方,该从何开始?', faqFinding2Q: '我要搬到一个从未去过的地方,该从何开始?',
faqFinding2A: faqFinding2A:
'设置您关心的筛选条件,地图会立即高亮显示符合条件的区域。从"我一条街都不认识"到得出候选名单,只需几分钟。', '设置您关心的筛选条件,地图会立即高亮显示符合条件的区域。从"我一条街都不认识"到得出候选名单,只需几分钟。',
faqFinding3Q: '如何找到同时满足我所有要求的区域', faqFinding3Q: '搜索结果太多或太少时该怎么办',
faqFinding3A: faqFinding3A:
'叠加多个筛选条件(犯罪率低于平均水平、好学校、通勤时间少于 40 分钟),然后按价格为地图着色,找出性价比最高的区域。拖动滑块时地图会实时更新,让您即时看到变化。', '先设置硬性限制,再按一个取舍指标为地图着色,例如每平方米价格、道路噪音、学校评分或通勤时间。如果结果太少,放宽一个滑块,就能看到哪个妥协会打开更多选择。',
// FAQ items — Commute and Travel // FAQ items — Commute and Travel
faqCommute1Q: '我能看到从不同区域到公司的实际通勤时间吗', faqCommute1Q: '出行时间是如何计算的',
faqCommute1A: faqCommute1A:
'设置您的工作地点作为目的地,我们会按通勤时间为每个邮编着色——无论是开车、骑车还是公共交通。筛选出您的最大通勤时间,其余区域就会消失。', '出行时间使用 Conveyal R5 预先计算,这是一个用于交通分析的路径引擎。对于每个支持的目的地,我们会沿街道和公共交通网络计算可到达邮编的路线,并为开车、骑车、步行和公共交通存储稀疏的邮编出行时间文件。这样地图可以快速筛选大量邮编,而不是逐个调用路线 API。',
faqCommute2Q: '这比查 Google Maps 好在哪里', faqCommute2Q: '这些出行时间数字有什么限制',
faqCommute2A: faqCommute2A:
'Google Maps 一次只能查看一条路线。我们一次性将英格兰每个邮编按通勤时间着色,让您可以同时比较数百个区域,而不是逐个搜索。', '公共交通时间使用早高峰 07:30 到 08:30 的出发窗口。默认值是中位数代表该窗口内的典型行程best-case 开关使用第 5 百分位,表示出发时间配合较好时的情况。这些是用于比较的模型时间,不是实时延误、交通状况或换乘站台预测。',
// FAQ items — Budget and Value // FAQ items — Budget and Value
faqBudget1Q: '如何找到单位面积性价比最高的区域', faqBudget1Q: '估计当前价格算法是如何工作的',
faqBudget1A: faqBudget1A:
'按每平方米价格筛选,您会立即看到哪些邮编的单位面积价格最低。搭配能源评级筛选,避免取暖费用过高的房产。', '这个专有估算从 HM Land Registry 的最近成交价开始。它使用从多次成交房产中学习出的重复销售指数,将价格调整到当前市场,并按邮编分区和房产类型分层。数据较少的区域会向 district、area、全国和享乐模型回退并进行空间平滑。最后结果会与附近近期成交、同类型房产的最近邻估算混合使用调整后的每平方米价格和 EPC 室内面积。',
faqBudget2Q: '怎么确定一个便宜的区域不是因为有问题才便宜', faqBudget2Q: '为什么要用估计当前价格,而不是最近成交价',
faqBudget2A: faqBudget2A:
'将贫困指数、犯罪统计、学校评级和宽带速度叠加在价格旁边查看。如果一个邮编价格实惠且在各项重要指标上表现良好,那您就找到了真正的高性价比——而不是隐藏着您还没发现的问题的低价。', '最近成交价可能是几年甚至几十年前的价格,而实时挂牌价只覆盖今天正在出售的房源。估计当前价格把旧成交放到更接近当前市场的尺度上,方便比较更多房产、计算估计每平方米价格,并在房源出现前发现可能有价值的区域。它是筛选估算,不是正式估值。',
// FAQ items — Safety and Neighbourhood // FAQ items — Safety and Neighbourhood
faqSafety1Q: '搬家前如何查看一个区域是否安全?', faqSafety1Q: '搬家前如何查看一个区域是否安全?',
faqSafety1A: faqSafety1A:
@ -580,12 +575,29 @@ const zh: Translations = {
faqEnv1Q: '能找到不在嘈杂马路旁的节能住宅吗?', faqEnv1Q: '能找到不在嘈杂马路旁的节能住宅吗?',
faqEnv1A: faqEnv1A:
'按 EPC 评级A 至 C筛选然后叠加道路噪音数据排除超过您阈值的区域。按任一指标为地图着色一目了然地找到安静且节能的街道。', '按 EPC 评级A 至 C筛选然后叠加道路噪音数据排除超过您阈值的区域。按任一指标为地图着色一目了然地找到安静且节能的街道。',
faqEnv2Q: '有洪水或地基沉降风险数据吗', faqEnv2Q: '是否显示洪水、地基沉降或验房风险',
faqEnv2A: faqEnv2A:
'我们包含地基稳定性数据,让您在购房前检查沉降、膨胀收缩黏土和其他地质风险。尽早排除高风险区域。', '目前不作为实时筛选项提供。我们会显示道路噪音、EPC、建造年代和本地环境指标等数据但洪水查询、产权问题、结构问题和贷款适配性仍需要通过律师、贷款机构和专业验房流程确认。',
faqEnv3Q: '能找到宽带速度快又安静的区域吗?', faqEnv3Q: '能找到宽带速度快又安静的区域吗?',
faqEnv3A: faqEnv3A:
'将宽带速度筛选与道路噪音数据叠加,找到连接速度快且交通噪音低的街道。按任一指标着色,一目了然地比较各区域。', '将宽带速度筛选与道路噪音数据叠加,找到连接速度快且交通噪音低的街道。按任一指标着色,一目了然地比较各区域。',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: '应该在查看 Rightmove 前还是之后使用?',
faqDueDiligence1A:
'Perfect Postcode 适合在房源平台之前和同时使用。Rightmove、Zoopla 和 OnTheMarket 仍然用于查看实时房源、照片、中介联系方式、预约看房和提醒。Perfect Postcode 帮助您先判断哪些邮编值得搜索。',
faqDueDiligence2Q: '可以按花园、车库、户型或房源描述筛选吗?',
faqDueDiligence2A:
'只有当这些信息存在于结构化官方数据中时才可以。Perfect Postcode 可以按面积、房产类型、产权类型、EPC、成交价和本地数据筛选。花园、车库、朝向、户型和中介描述仍需要在房源页面和看房时核实。',
faqDueDiligence3Q: '可以看到降价历史或房源上线多久了吗?',
faqDueDiligence3A:
'目前不支持。Perfect Postcode 基于官方成交价、EPC、邮编、通勤时间和社区数据而不是实时房源信息流。您仍可以用最近成交日期、成交历史、估计当前价值和每平方米价格来判断挂牌价是否偏高。',
faqDueDiligence4Q: '出价前还需要核实什么?',
faqDueDiligence4A:
'可以先用 Perfect Postcode 检查区域和价值,再通过常规专业流程核实实时房源细节、产权类型、租赁年限、服务费、规划历史、洪水风险、产权问题、贷款要求和验房结果。',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: '你们会存储关于我的个人数据吗?',
faqPrivacy1A:
'我们不会在房产和社区数据集中存储用户个人数据。这些数据集来自官方和公开来源,用于邮编和房产研究。如果您创建账户,我们只会存储运行服务所需的信息,例如邮箱地址、许可状态、新闻邮件偏好、已保存的搜索、已保存的房产,以及通过 Stripe 处理的付款标识符。我们会根据 UK GDPR 和 Data Protection Act 2018 处理这些账户数据。',
// FAQ items — Why Perfect Postcode // FAQ items — Why Perfect Postcode
faqWhy1Q: '我已经在用 Rightmove 了,这个工具有什么额外价值?', faqWhy1Q: '我已经在用 Rightmove 了,这个工具有什么额外价值?',
faqWhy1A: faqWhy1A:
@ -600,9 +612,9 @@ const zh: Translations = {
faqPricing1Q: '花钱买一个找房工具真的值得吗?', faqPricing1Q: '花钱买一个找房工具真的值得吗?',
faqPricing1A: faqPricing1A:
'买房可能是您一生中最大的一笔支出。在做决定之前发现一个问题(嘈杂的马路、差劲的宽带、上升的犯罪率)就可能让您避免多年的后悔。而这个工具的费用还不到一箱油钱。', '买房可能是您一生中最大的一笔支出。在做决定之前发现一个问题(嘈杂的马路、差劲的宽带、上升的犯罪率)就可能让您避免多年的后悔。而这个工具的费用还不到一箱油钱。',
faqPricing2Q: '这是订阅制吗', faqPricing2Q: '终身访问是什么意思',
faqPricing2A: faqPricing2A:
'不是。一次性付款,永久使用。在找房期间密集使用,对新区域好奇时随时回来看,将来再搬家时它依然在。', '终身访问指一次付款后,您的账户可在 Perfect Postcode 服务存续期间持续访问付费地图。它不是月度或年度订阅,并包含正常的数据更新。您可以在本次找房期间使用,之后再回来查看;如果将来再次搬家,也仍然保留访问权限。',
faqPricing3Q: '免费版能用哪些功能?', faqPricing3Q: '免费版能用哪些功能?',
faqPricing3A: faqPricing3A:
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。', '免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',

View file

@ -70,10 +70,18 @@ export function parseUrlState(): {
tab?: 'properties' | 'area'; tab?: 'properties' | 'area';
travelTime?: TravelTimeInitial; travelTime?: TravelTimeInitial;
postcode?: string; postcode?: string;
share?: string;
} { } {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {}; const result: ReturnType<typeof parseUrlState> = {};
// Share-link code: grants bbox-scoped access to the area the link references
// even for unlicensed users. The backend looks the code up against PocketBase.
const share = params.get('share');
if (share && /^[a-z0-9]{1,20}$/i.test(share)) {
result.share = share;
}
// View state: separate lat/lon/zoom params // View state: separate lat/lon/zoom params
const lat = params.get('lat'); const lat = params.get('lat');
const lon = params.get('lon'); const lon = params.get('lon');
@ -146,10 +154,15 @@ export function stateToParams(
features: FeatureMeta[], features: FeatureMeta[],
selectedPOICategories: Set<string>, selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area', rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntry[] travelTimeEntries?: TravelTimeEntry[],
share?: string
): URLSearchParams { ): URLSearchParams {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (share) {
params.set('share', share);
}
if (viewState) { if (viewState) {
params.set('lat', viewState.latitude.toFixed(4)); params.set('lat', viewState.latitude.toFixed(4));
params.set('lon', viewState.longitude.toFixed(4)); params.set('lon', viewState.longitude.toFixed(4));

View file

@ -1,8 +1,7 @@
from collections import defaultdict from collections import defaultdict
import numpy as np import numpy as np
from scipy.spatial import Voronoi from scipy.spatial import QhullError, Voronoi
from scipy.spatial.qhull import QhullError
from shapely import make_valid from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon from shapely.geometry import MultiPolygon, Polygon
from shapely.ops import unary_union from shapely.ops import unary_union
@ -24,12 +23,15 @@ def compute_voronoi_regions(
# Deduplicate points, keeping one per (location, postcode) pair. # Deduplicate points, keeping one per (location, postcode) pair.
# Multiple postcodes at the same coordinate each get their own point, # Multiple postcodes at the same coordinate each get their own point,
# jittered by a tiny offset (0.01m) so Voronoi can distinguish them. # jittered by a tiny offset (0.01m) so Voronoi can distinguish them.
# Coords are rounded to mm precision for stable hashing — UPRN inputs are
# already integer metres, but the float64 cast can introduce ULP noise.
GOLDEN_ANGLE = np.pi * (3.0 - np.sqrt(5.0))
seen: dict[tuple[float, float, str], bool] = {} seen: dict[tuple[float, float, str], bool] = {}
unique_pts = [] unique_pts = []
unique_pcs = [] unique_pcs = []
coord_counts: dict[tuple[float, float], int] = defaultdict(int) coord_counts: dict[tuple[float, float], int] = defaultdict(int)
for i in range(len(points)): for i in range(len(points)):
coord = (points[i, 0], points[i, 1]) coord = (round(float(points[i, 0]), 3), round(float(points[i, 1]), 3))
key = (coord[0], coord[1], postcodes[i]) key = (coord[0], coord[1], postcodes[i])
if key not in seen: if key not in seen:
seen[key] = True seen[key] = True
@ -38,11 +40,10 @@ def compute_voronoi_regions(
if jitter_idx == 0: if jitter_idx == 0:
unique_pts.append(points[i].copy()) unique_pts.append(points[i].copy())
else: else:
# Tiny jitter so Voronoi sees distinct points (0.01m per step) # Golden-angle spacing distributes any number of jittered
# points evenly around (and outward from) the original coord.
jittered = points[i].copy() jittered = points[i].copy()
angle = ( angle = jitter_idx * GOLDEN_ANGLE
2 * np.pi * jitter_idx / max(coord_counts[coord], jitter_idx + 1)
)
jittered[0] += 0.01 * np.cos(angle) jittered[0] += 0.01 * np.cos(angle)
jittered[1] += 0.01 * np.sin(angle) jittered[1] += 0.01 * np.sin(angle)
unique_pts.append(jittered) unique_pts.append(jittered)

File diff suppressed because it is too large Load diff

View file

@ -1227,7 +1227,7 @@ mod tests {
let mid_value = 50.0; let mid_value = 50.0;
let bin = hist.bin_for_value(mid_value); let bin = hist.bin_for_value(mid_value);
assert!(bin >= 1 && bin <= 8); assert!((1..=8).contains(&bin));
} }
#[test] #[test]
@ -1276,7 +1276,7 @@ mod tests {
#[test] #[test]
fn count_skips_nan() { fn count_skips_nan() {
let values = vec![1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0]; let values = [1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
let count = values.iter().filter(|v| v.is_finite()).count(); let count = values.iter().filter(|v| v.is_finite()).count();
assert_eq!(count, 3); assert_eq!(count, 3);
} }

View file

@ -1,17 +1,189 @@
use std::time::Instant;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use serde_json::json; use parking_lot::RwLock;
use rustc_hash::FxHashMap;
use serde_json::{json, Value};
use tracing::warn;
use crate::auth::PocketBaseUser; use crate::auth::PocketBaseUser;
use crate::consts::FREE_ZONE_BOUNDS; use crate::consts::FREE_ZONE_BOUNDS;
use crate::pocketbase::get_superuser_token;
use crate::state::AppState;
const SHARE_CACHE_TTL_SECS: u64 = 300;
const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
#[derive(Clone, Copy, Debug)]
pub struct ShareBounds {
pub south: f64,
pub west: f64,
pub north: f64,
pub east: f64,
}
/// Cache: code → resolved share bounds. We cache `None` too so an invalid
/// code doesn't keep hammering PocketBase on every request from a malicious
/// or stale client.
pub struct ShareBoundsCache {
entries: RwLock<FxHashMap<String, (Option<ShareBounds>, Instant)>>,
}
impl ShareBoundsCache {
pub fn new() -> Self {
Self {
entries: RwLock::new(FxHashMap::default()),
}
}
fn get(&self, code: &str) -> Option<Option<ShareBounds>> {
let map = self.entries.read();
if let Some((bounds, created)) = map.get(code) {
if created.elapsed().as_secs() < SHARE_CACHE_TTL_SECS {
return Some(*bounds);
}
}
None
}
fn insert(&self, code: String, bounds: Option<ShareBounds>) {
let mut map = self.entries.write();
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
let now = Instant::now();
map.retain(|_, (_, created)| {
now.duration_since(*created).as_secs() < SHARE_CACHE_TTL_SECS
});
if map.len() >= SHARE_CACHE_MAX_ENTRIES {
let mut ages: Vec<Instant> = map.values().map(|(_, c)| *c).collect();
ages.sort();
let median = ages[ages.len() / 2];
map.retain(|_, (_, created)| *created >= median);
}
}
map.insert(code, (bounds, Instant::now()));
}
}
impl Default for ShareBoundsCache {
fn default() -> Self {
Self::new()
}
}
/// Resolve a share code to the bbox the share grants access to.
/// Looks up the stored params for the code in PocketBase, parses lat/lon/zoom,
/// and derives a generous bbox sized to roughly 4× the viewport at that zoom.
/// Returns `None` if the code is invalid or unknown.
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
if !is_valid_share_code(code) {
return None;
}
if let Some(cached) = state.share_cache.get(code) {
return cached;
}
let resolved = fetch_share_bounds(state, code).await;
state.share_cache.insert(code.to_string(), resolved);
resolved
}
/// Convenience: resolve `Option<&str>` share code → `Option<ShareBounds>`.
/// Skips the lookup entirely (and never touches the cache) when no code is
/// supplied or the supplied code is empty.
pub async fn resolve_share_code(state: &AppState, code: Option<&str>) -> Option<ShareBounds> {
match code {
Some(c) if !c.is_empty() => lookup_share_bounds(state, c).await,
_ => None,
}
}
fn is_valid_share_code(code: &str) -> bool {
!code.is_empty() && code.len() <= 20 && code.bytes().all(|b| b.is_ascii_alphanumeric())
}
async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
let token = match get_superuser_token(state).await {
Ok(t) => t,
Err(err) => {
warn!("share bounds lookup: superuser auth failed: {err}");
return None;
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("code=\"{code}\"");
let url = format!(
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let json: Value = resp.json().await.ok()?;
let params = json["items"]
.as_array()?
.first()?
.get("params")?
.as_str()?;
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
}
/// Pull `lat`, `lon`, `zoom` out of an already-encoded query string like
/// `lat=51.5&lon=-0.1&zoom=12&filter=...`. Returns `None` if any of the three
/// is missing or unparseable — those are the only fields we need for sizing.
fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
let mut lat: Option<f64> = None;
let mut lon: Option<f64> = None;
let mut zoom: Option<f64> = None;
for pair in params.split('&') {
let mut it = pair.splitn(2, '=');
let key = it.next()?;
let val = it.next().unwrap_or("");
match key {
"lat" => lat = val.parse().ok(),
"lon" => lon = val.parse().ok(),
"zoom" => zoom = val.parse().ok(),
_ => {}
}
}
Some((lat?, lon?, zoom?))
}
/// Derive the share bbox from the share's center lat/lon and zoom.
///
/// A viewport W pixels wide at zoom z covers `W * 360 / (256 * 2^z)` degrees
/// of longitude. For a typical 1280px-wide desktop viewport that's roughly
/// `1800 / 2^z` degrees — we use that as the half-width, so the bbox is
/// ~2 viewports per side (~4 viewports total area). Lat is scaled by 0.6
/// to roughly match the latitude compression at UK latitudes.
fn bounds_from_view(lat: f64, lon: f64, zoom: f64) -> ShareBounds {
let zoom = zoom.clamp(0.0, 20.0);
let half_lon = (1800.0 / 2.0_f64.powf(zoom)).min(180.0);
let half_lat = (half_lon * 0.6).min(85.0);
ShareBounds {
south: lat - half_lat,
north: lat + half_lat,
west: lon - half_lon,
east: lon + half_lon,
}
}
/// Check whether the user is allowed to query data at the given bounds. /// Check whether the user is allowed to query data at the given bounds.
/// Licensed users and admins bypass the check entirely. /// Licensed users and admins bypass the check entirely.
/// Free/anonymous users get 403 if bounds exceed the free zone. /// Free/anonymous users get 403 unless the bounds fall inside the free zone
/// or inside the bbox granted by a valid share code.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub fn check_license_bounds( pub fn check_license_bounds(
user: &Option<PocketBaseUser>, user: &Option<PocketBaseUser>,
bounds: (f64, f64, f64, f64), bounds: (f64, f64, f64, f64),
share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> { ) -> Result<(), axum::response::Response> {
if let Some(u) = user { if let Some(u) = user {
if u.is_admin || u.subscription == "licensed" { if u.is_admin || u.subscription == "licensed" {
@ -26,6 +198,12 @@ pub fn check_license_bounds(
return Ok(()); return Ok(());
} }
if let Some(sb) = share_bounds {
if south >= sb.south && west >= sb.west && north <= sb.north && east <= sb.east {
return Ok(());
}
}
let body = json!({ let body = json!({
"error": "license_required", "error": "license_required",
"message": "A license is required to view data outside the demo area", "message": "A license is required to view data outside the demo area",
@ -46,6 +224,7 @@ pub fn check_license_point(
user: &Option<PocketBaseUser>, user: &Option<PocketBaseUser>,
lat: f64, lat: f64,
lon: f64, lon: f64,
share_bounds: Option<ShareBounds>,
) -> Result<(), axum::response::Response> { ) -> Result<(), axum::response::Response> {
check_license_bounds(user, (lat, lon, lat, lon)) check_license_bounds(user, (lat, lon, lat, lon), share_bounds)
} }

View file

@ -387,6 +387,7 @@ async fn main() -> anyhow::Result<()> {
let token_cache = Arc::new(auth::TokenCache::new()); let token_cache = Arc::new(auth::TokenCache::new());
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new()); let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
let share_cache = Arc::new(licensing::ShareBoundsCache::new());
let app_state = AppState { let app_state = AppState {
data: property_data, data: property_data,
@ -416,6 +417,7 @@ async fn main() -> anyhow::Result<()> {
travel_time_store, travel_time_store,
token_cache, token_cache,
superuser_token_cache, superuser_token_cache,
share_cache,
ai_filters_system_prompt, ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key, google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key, stripe_secret_key: cli.stripe_secret_key,
@ -498,7 +500,10 @@ async fn main() -> anyhow::Result<()> {
post(routes::post_ai_filters).layer(ConcurrencyLimitLayer::new(5)), post(routes::post_ai_filters).layer(ConcurrencyLimitLayer::new(5)),
) )
.route("/api/streetview", get(routes::get_streetview)) .route("/api/streetview", get(routes::get_streetview))
.route("/api/newsletter", patch(routes::patch_newsletter)) .route(
"/api/newsletter",
patch(routes::patch_newsletter).layer(ConcurrencyLimitLayer::new(10)),
)
.route("/api/pricing", get(routes::get_pricing)) .route("/api/pricing", get(routes::get_pricing))
.route( .route(
"/api/checkout", "/api/checkout",
@ -512,7 +517,10 @@ async fn main() -> anyhow::Result<()> {
.route("/api/invite/{code}", get(routes::get_invite)) .route("/api/invite/{code}", get(routes::get_invite))
.route("/api/redeem-invite", post(routes::post_redeem_invite)) .route("/api/redeem-invite", post(routes::post_redeem_invite))
.route("/s/{code}", get(routes::get_short_url)) .route("/s/{code}", get(routes::get_short_url))
.route("/api/telemetry", post(routes::post_telemetry)) .route(
"/api/telemetry",
post(routes::post_telemetry).layer(ConcurrencyLimitLayer::new(20)),
)
.route( .route(
"/pb/{*rest}", "/pb/{*rest}",
any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)), any(routes::proxy_to_pocketbase).layer(ConcurrencyLimitLayer::new(10)),

View file

@ -11,6 +11,21 @@ use crate::state::AppState;
const OG_PLACEHOLDER: &str = const OG_PLACEHOLDER: &str =
r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#; r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
/// Escape a string for safe inclusion inside a double-quoted HTML attribute value.
fn escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
pub async fn og_middleware(request: Request, next: Next) -> Response { pub async fn og_middleware(request: Request, next: Next) -> Response {
let path = request.uri().path().to_string(); let path = request.uri().path().to_string();
// Capture the query string before passing the request through // Capture the query string before passing the request through
@ -46,32 +61,34 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
None => return response, None => return response,
}; };
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot) // Build OG-injected HTML (og=1 triggers heading overlay on screenshot).
// All URL components are HTML-escaped before interpolation into attributes
// because path/query are attacker-controlled.
let is_invite = path.starts_with("/invite/"); let is_invite = path.starts_with("/invite/");
let path_e = escape_attr(&path);
let query_e = escape_attr(&query_string);
let public_url_e = escape_attr(&state.public_url);
let og_image_url = if is_invite { let og_image_url = if is_invite {
// Include path= so the screenshot service navigates to /invite/CODE // Include path= so the screenshot service navigates to /invite/CODE
if query_string.is_empty() { if query_string.is_empty() {
format!("{}/api/screenshot?og=1&path={}", state.public_url, path) format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}")
} else { } else {
format!( format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{query_e}")
"{}/api/screenshot?og=1&path={}&{}",
state.public_url, path, query_string
)
} }
} else if query_string.is_empty() { } else if query_string.is_empty() {
format!("{}/api/screenshot?og=1", state.public_url) format!("{public_url_e}/api/screenshot?og=1")
} else { } else {
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string) format!("{public_url_e}/api/screenshot?og=1&amp;{query_e}")
}; };
let og_url = if query_string.is_empty() { let og_url = if query_string.is_empty() {
format!("{}{}", state.public_url, path) format!("{public_url_e}{path_e}")
} else { } else {
format!("{}{}?{}", state.public_url, path, query_string) format!("{public_url_e}{path_e}?{query_e}")
}; };
let og_logo = format!("{}/favicon.svg", state.public_url); let og_logo = format!("{public_url_e}/favicon.svg");
let (og_title, og_description) = if is_invite { let (og_title, og_description) = if is_invite {
( (

View file

@ -86,6 +86,13 @@ pub fn parse_filters(
.trim() .trim()
.parse::<f32>() .parse::<f32>()
.map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?; .map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?;
// Reject inverted ranges: keeps the selectivity sort key (max - min) non-negative
// and surfaces user error instead of silently returning zero rows.
if min.is_finite() && max.is_finite() && min > max {
return Err(format!(
"Numeric filter '{name}' has inverted range: min ({min}) > max ({max})"
));
}
numeric.push(ParsedFilter { numeric.push(ParsedFilter {
feat_idx, feat_idx,
min_u16: quant.encode_min(feat_idx, min), min_u16: quant.encode_min(feat_idx, min),
@ -94,8 +101,10 @@ pub fn parse_filters(
} }
} }
// Sort by selectivity: more selective filters first for early rejection // Sort by selectivity: more selective filters first for early rejection.
numeric.sort_unstable_by_key(|f| f.max_u16.wrapping_sub(f.min_u16)); // Use saturating_sub so a hypothetical inverted range (min > max) yields 0
// rather than wrapping to a huge u16 and corrupting the sort.
numeric.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16));
enums.sort_unstable_by_key(|f| f.allowed.len()); enums.sort_unstable_by_key(|f| f.allowed.len());
Ok((numeric, enums)) Ok((numeric, enums))
@ -418,6 +427,67 @@ mod tests {
assert_eq!(enums.len(), 1); assert_eq!(enums.len(), 1);
} }
#[test]
fn parse_filters_rejects_inverted_range() {
// Inverted bounds (min > max) would previously produce a wrapping
// selectivity key in the sort and silently match zero rows. Now we
// reject them at parse time with a clear error.
let tq = test_quant(3, 2);
let result = parse_filters(
Some("price:500:100"),
&feature_name_to_index(),
&enum_values(),
&tq.as_ref(),
);
assert!(result.is_err(), "expected inverted range to be rejected");
let err = result.unwrap_err();
assert!(err.contains("inverted range"), "got: {err}");
assert!(err.contains("price"), "got: {err}");
}
#[test]
fn parse_filters_accepts_equal_min_max() {
// min == max is a valid (degenerate) range and must still be accepted.
let tq = test_quant(3, 2);
let (numeric, _) = parse_filters(
Some("price:200:200"),
&feature_name_to_index(),
&enum_values(),
&tq.as_ref(),
)
.unwrap();
assert_eq!(numeric.len(), 1);
}
#[test]
fn selectivity_sort_handles_saturating_sub() {
// Even if a ParsedFilter is constructed directly with min_u16 > max_u16
// (bypassing parse_filters validation), the sort must not wrap to a
// huge u16 — saturating_sub clamps to 0.
let mut filters = [
ParsedFilter {
feat_idx: 0,
min_u16: 1000,
max_u16: 2000, // range = 1000
},
ParsedFilter {
feat_idx: 1,
min_u16: 5000,
max_u16: 100, // inverted; saturating gives 0 (most "selective")
},
ParsedFilter {
feat_idx: 2,
min_u16: 0,
max_u16: 500, // range = 500
},
];
filters.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16));
// Sorted ascending by saturating range: inverted (0), then 500, then 1000.
assert_eq!(filters[0].feat_idx, 1);
assert_eq!(filters[1].feat_idx, 2);
assert_eq!(filters[2].feat_idx, 0);
}
#[test] #[test]
fn parse_invalid_numeric_format_errors() { fn parse_invalid_numeric_format_errors() {
let tq = test_quant(4, 2); let tq = test_quant(4, 2);
@ -627,8 +697,7 @@ mod tests {
}, },
]; ];
let (total, impacts) = let (total, impacts) = count_filter_impacts(&filters, &[], &feature_data, 2, 0..4u32);
count_filter_impacts(&filters, &[], &feature_data, 2, (0..4u32).into_iter());
assert_eq!(total, 1); // only row 0 passes assert_eq!(total, 1); // only row 0 passes
assert_eq!(impacts[0], 1); // row 1 fails price only assert_eq!(impacts[0], 1); // row 1 fails price only
@ -661,13 +730,8 @@ mod tests {
allowed: [0u16, 1].into_iter().collect(), allowed: [0u16, 1].into_iter().collect(),
}]; }];
let (total, impacts) = count_filter_impacts( let (total, impacts) =
&num_filters, count_filter_impacts(&num_filters, &enum_filters, &feature_data, 2, 0..3u32);
&enum_filters,
&feature_data,
2,
(0..3u32).into_iter(),
);
assert_eq!(total, 1); // row 0 assert_eq!(total, 1); // row 0
assert_eq!(impacts[0], 1); // row 2 fails numeric only → impacts[0] assert_eq!(impacts[0], 1); // row 2 fails numeric only → impacts[0]
@ -678,8 +742,7 @@ mod tests {
fn filter_impacts_no_filters() { fn filter_impacts_no_filters() {
let tq = test_quant(1, 1); let tq = test_quant(1, 1);
let feature_data = vec![tq.encode(0, 100.0)]; let feature_data = vec![tq.encode(0, 100.0)];
let (total, impacts) = let (total, impacts) = count_filter_impacts(&[], &[], &feature_data, 1, 0..1u32);
count_filter_impacts(&[], &[], &feature_data, 1, (0..1u32).into_iter());
assert_eq!(total, 1); assert_eq!(total, 1);
assert!(impacts.is_empty()); assert!(impacts.is_empty());
} }

View file

@ -135,7 +135,7 @@ pub async fn get_export(
let (south, west, north, east) = let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?; require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))?; check_license_bounds(&user.0, (south, west, north, east), None)?;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters( let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds; use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{ use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters, cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters,
row_passes_filters, validate_h3_resolution, row_passes_filters, validate_h3_resolution,
@ -76,6 +76,8 @@ pub struct HexagonStatsParams {
/// shortest travel time for this mode+slug (so it has journey data). /// shortest travel time for this mode+slug (so it has journey data).
pub journey_mode: Option<String>, pub journey_mode: Option<String>,
pub journey_slug: Option<String>, pub journey_slug: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
} }
pub async fn get_hexagon_stats( pub async fn get_hexagon_stats(
@ -99,7 +101,8 @@ pub async fn get_hexagon_stats(
// License check using H3 cell bounds // License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0); let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
let h3_str = params.h3; let h3_str = params.h3;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();

View file

@ -15,7 +15,7 @@ use crate::aggregation::{Aggregator, EnumDistConfig};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST; use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData; use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds; use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{ use crate::parsing::{
cell_for_row_cached, needs_parent, parse_enum_dist, parse_field_indices, parse_filters, cell_for_row_cached, needs_parent, parse_enum_dist, parse_field_indices, parse_filters,
require_bounds, row_passes_filters, validate_h3_resolution, require_bounds, row_passes_filters, validate_h3_resolution,
@ -68,6 +68,8 @@ pub struct HexagonParams {
/// Feature name for enum distribution counting (pie chart visualization). /// Feature name for enum distribution counting (pie chart visualization).
/// When set, each cell includes `dist_{name}: [count_val0, count_val1, ...]`. /// When set, each cell includes `dist_{name}: [count_val0, count_val1, ...]`.
enum_dist: Option<String>, enum_dist: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
} }
/// Build feature maps from aggregated cell data, filtering to only cells whose /// Build feature maps from aggregated cell data, filtering to only cells whose
@ -203,7 +205,8 @@ pub async fn get_hexagons(
let (south, west, north, east) = let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?; require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters( let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -9,7 +9,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET}; use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
use crate::licensing::check_license_point; use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters, row_passes_filters}; use crate::parsing::{parse_filters, row_passes_filters};
use crate::state::SharedState; use crate::state::SharedState;
use crate::utils::normalize_postcode; use crate::utils::normalize_postcode;
@ -22,6 +22,8 @@ pub struct PostcodePropertiesParams {
pub filters: Option<String>, pub filters: Option<String>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub offset: Option<usize>, pub offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
} }
pub async fn get_postcode_properties( pub async fn get_postcode_properties(
@ -45,7 +47,13 @@ pub async fn get_postcode_properties(
}; };
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx]; let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_point(
&user.0,
centroid_lat as f64,
centroid_lon as f64,
share_bounds,
)?;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters( let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -9,7 +9,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET; use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::check_license_point; use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters}; use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
use crate::state::SharedState; use crate::state::SharedState;
use crate::utils::normalize_postcode; use crate::utils::normalize_postcode;
@ -24,6 +24,8 @@ pub struct PostcodeStatsParams {
/// Comma-separated feature names to include in stats response. /// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned. /// Only listed features are computed; if absent or empty, no features are returned.
pub fields: Option<String>, pub fields: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
} }
pub async fn get_postcode_stats( pub async fn get_postcode_stats(
@ -49,7 +51,13 @@ pub async fn get_postcode_stats(
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx]; let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid // License check using postcode centroid
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_point(
&user.0,
centroid_lat as f64,
centroid_lon as f64,
share_bounds,
)?;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters( let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -14,7 +14,7 @@ use crate::aggregation::{Aggregator, EnumDistConfig};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST; use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData; use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds; use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{ use crate::parsing::{
bounds_intersect, parse_enum_dist, parse_field_indices, parse_filters, require_bounds, bounds_intersect, parse_enum_dist, parse_field_indices, parse_filters, require_bounds,
row_passes_filters, row_passes_filters,
@ -47,6 +47,8 @@ pub struct PostcodeParams {
travel: Option<String>, travel: Option<String>,
/// Feature name for enum distribution counting (pie chart visualization). /// Feature name for enum distribution counting (pie chart visualization).
enum_dist: Option<String>, enum_dist: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
share: Option<String>,
} }
pub async fn get_postcodes( pub async fn get_postcodes(
@ -58,7 +60,8 @@ pub async fn get_postcodes(
let (south, west, north, east) = let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?; require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters( let (parsed_filters, parsed_enum_filters) = parse_filters(

View file

@ -10,9 +10,10 @@ use tracing::warn;
use crate::pocketbase::get_superuser_token; use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState}; use crate::state::{AppState, SharedState};
/// Pricing tiers: (cumulative user cap, price in pence). /// Pricing tiers: (cumulative public user cap, price in pence).
const TIERS: &[(u64, u64)] = &[(5, 99), (15, 999), (30, 2999), (50, 4999)]; const TIERS: &[(u64, u64)] = &[(50, 99), (150, 999), (250, 2999), (350, 4999)];
const FINAL_PRICE_PENCE: u64 = 9999; const FINAL_PRICE_PENCE: u64 = 9999;
const PUBLIC_LAUNCH_USER_OFFSET: u64 = 120;
#[derive(Serialize)] #[derive(Serialize)]
pub struct Tier { pub struct Tier {
@ -28,16 +29,24 @@ pub struct PricingResponse {
tiers: Vec<Tier>, tiers: Vec<Tier>,
} }
/// Determine the price (in pence) for the next user given `count` existing licensed users. fn public_licensed_count(actual_count: u64) -> u64 {
pub fn price_for_count(count: u64) -> u64 { actual_count.saturating_add(PUBLIC_LAUNCH_USER_OFFSET)
}
fn price_for_public_count(public_count: u64) -> u64 {
for &(cap, price) in TIERS { for &(cap, price) in TIERS {
if count < cap { if public_count < cap {
return price; return price;
} }
} }
FINAL_PRICE_PENCE FINAL_PRICE_PENCE
} }
/// Determine the price (in pence) for the next user given the real licensed user count.
pub fn price_for_count(count: u64) -> u64 {
price_for_public_count(public_licensed_count(count))
}
/// Count users with subscription="licensed" in PocketBase. /// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> { pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let token = get_superuser_token(state).await?; let token = get_superuser_token(state).await?;
@ -75,7 +84,8 @@ pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
} }
}; };
let current_price = price_for_count(count); let public_count = public_licensed_count(count);
let current_price = price_for_public_count(public_count);
let mut tiers = Vec::new(); let mut tiers = Vec::new();
let mut prev_cap = 0u64; let mut prev_cap = 0u64;
@ -94,9 +104,44 @@ pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
}); });
Json(PricingResponse { Json(PricingResponse {
licensed_count: count, licensed_count: public_count,
current_price_pence: current_price, current_price_pence: current_price,
tiers, tiers,
}) })
.into_response() .into_response()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_count_starts_at_launch_offset() {
assert_eq!(public_licensed_count(0), 120);
assert_eq!(public_licensed_count(7), 127);
}
#[test]
fn price_for_count_uses_public_tier_position() {
assert_eq!(price_for_count(0), 999);
assert_eq!(price_for_count(29), 999);
assert_eq!(price_for_count(30), 2999);
assert_eq!(price_for_count(129), 2999);
assert_eq!(price_for_count(130), 4999);
assert_eq!(price_for_count(229), 4999);
assert_eq!(price_for_count(230), 9999);
}
#[test]
fn price_for_public_count_uses_tier_caps() {
assert_eq!(price_for_public_count(0), 99);
assert_eq!(price_for_public_count(49), 99);
assert_eq!(price_for_public_count(50), 999);
assert_eq!(price_for_public_count(149), 999);
assert_eq!(price_for_public_count(150), 2999);
assert_eq!(price_for_public_count(249), 2999);
assert_eq!(price_for_public_count(250), 4999);
assert_eq!(price_for_public_count(349), 4999);
assert_eq!(price_for_public_count(350), 9999);
}
}

View file

@ -12,7 +12,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser; use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT}; use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
use crate::data::RenovationEvent; use crate::data::RenovationEvent;
use crate::licensing::check_license_bounds; use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{ use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters, cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution, validate_h3_resolution,
@ -26,6 +26,8 @@ pub struct HexagonPropertiesParams {
pub filters: Option<String>, pub filters: Option<String>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub offset: Option<usize>, pub offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -187,7 +189,8 @@ pub async fn get_hexagon_properties(
// License check using H3 cell bounds // License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0); let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
let h3_str = params.h3; let h3_str = params.h3;
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();

View file

@ -139,7 +139,11 @@ pub async fn get_short_url(
match params { match params {
Some(params) => { Some(params) => {
let redirect_url = format!("/dashboard?{params}"); let redirect_url = if params.is_empty() {
format!("/dashboard?share={code}")
} else {
format!("/dashboard?{params}&share={code}")
};
let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url); let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
let og_url = format!("{}/s/{code}", state.public_url); let og_url = format!("{}/s/{code}", state.public_url);
let og_title = "Perfect Postcode | Every neighbourhood in England"; let og_title = "Perfect Postcode | Every neighbourhood in England";

View file

@ -1,10 +1,13 @@
use std::sync::Arc; use std::collections::VecDeque;
use std::sync::{Arc, LazyLock};
use axum::body::Bytes; use axum::body::Bytes;
use axum::extract::State; use axum::extract::State;
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use parking_lot::Mutex;
use rustc_hash::FxHashSet;
use sha2::Sha256; use sha2::Sha256;
use tracing::{info, warn}; use tracing::{info, warn};
@ -13,6 +16,46 @@ use crate::state::SharedState;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
/// Process-local LRU of recently processed Stripe event IDs.
/// Stripe retries deliver the same event ID; we drop duplicates so we don't
/// re-run side effects (subscription writes, token cache invalidation, logs).
/// Capacity is intentionally generous: at typical webhook volumes this covers
/// far more than Stripe's retry window.
struct EventDedup {
seen: FxHashSet<String>,
queue: VecDeque<String>,
capacity: usize,
}
impl EventDedup {
fn new(capacity: usize) -> Self {
Self {
seen: FxHashSet::default(),
queue: VecDeque::with_capacity(capacity),
capacity,
}
}
/// Returns `true` if this event ID is new (and records it),
/// `false` if it was already seen recently.
fn check_and_insert(&mut self, id: &str) -> bool {
if self.seen.contains(id) {
return false;
}
self.seen.insert(id.to_string());
self.queue.push_back(id.to_string());
if self.queue.len() > self.capacity {
if let Some(old) = self.queue.pop_front() {
self.seen.remove(&old);
}
}
true
}
}
static EVENT_DEDUP: LazyLock<Mutex<EventDedup>> =
LazyLock::new(|| Mutex::new(EventDedup::new(1024)));
/// Verify Stripe webhook signature (v1 scheme). /// Verify Stripe webhook signature (v1 scheme).
fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool { fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
// Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE" // Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE"
@ -95,7 +138,16 @@ pub async fn post_stripe_webhook(
}; };
let event_type = event["type"].as_str().unwrap_or(""); let event_type = event["type"].as_str().unwrap_or("");
info!(event_type, "Received Stripe webhook"); let event_id = event["id"].as_str().unwrap_or("");
// Idempotency: drop replays/retries of an already-processed event.
// We always answer 200 so Stripe stops retrying.
if !event_id.is_empty() && !EVENT_DEDUP.lock().check_and_insert(event_id) {
info!(event_id, event_type, "Dropping duplicate Stripe webhook");
return StatusCode::OK.into_response();
}
info!(event_id, event_type, "Received Stripe webhook");
if event_type == "checkout.session.completed" { if event_type == "checkout.session.completed" {
let user_id = event["data"]["object"]["client_reference_id"] let user_id = event["data"]["object"]["client_reference_id"]

View file

@ -7,6 +7,7 @@ use crate::auth::TokenCache;
use crate::data::{ use crate::data::{
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore, OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
}; };
use crate::licensing::ShareBoundsCache;
use crate::pocketbase::SuperuserTokenCache; use crate::pocketbase::SuperuserTokenCache;
use crate::routes::FeaturesResponse; use crate::routes::FeaturesResponse;
use crate::utils::GridIndex; use crate::utils::GridIndex;
@ -45,6 +46,9 @@ pub struct AppState {
pub token_cache: Arc<TokenCache>, pub token_cache: Arc<TokenCache>,
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting /// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
pub superuser_token_cache: Arc<SuperuserTokenCache>, pub superuser_token_cache: Arc<SuperuserTokenCache>,
/// Cached share-link bbox lookups (5min TTL); used to grant unlicensed
/// users access to the area their share link references.
pub share_cache: Arc<ShareBoundsCache>,
// --- Config (cheap to clone) --- // --- Config (cheap to clone) ---
/// URL of the screenshot service (e.g. http://screenshot:8002) /// URL of the screenshot service (e.g. http://screenshot:8002)

4
video/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
output
auth.json

94
video/package-lock.json generated Normal file
View file

@ -0,0 +1,94 @@
{
"name": "video",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.49.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

22
video/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "video",
"version": "1.0.0",
"private": true,
"description": "Scripted Playwright recording of the dashboard for the homepage hero and social ads.",
"type": "module",
"scripts": {
"build": "tsc",
"setup-auth": "tsc && node dist/auth.js",
"record": "tsc && node dist/record.js",
"record:vertical": "tsc && ASPECT=9x16 node dist/record.js",
"encode": "ffmpeg -y -i output/recording.webm -c:v libx264 -pix_fmt yuv420p -crf 16 -preset slow -movflags +faststart output/recording.mp4",
"render": "./render.sh"
},
"dependencies": {
"playwright": "^1.49.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}

150
video/render.sh Executable file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env bash
#
# End-to-end re-render of the dashboard demo video.
#
# Defaults assume you run from inside this repo's vscode-server container
# (where host.docker.internal reaches the docker-compose stack). Override
# any URL/credential via env vars at the top.
#
# Usage:
# ./render.sh # full pipeline (uses cached auth.json if fresh)
# ./render.sh --fresh-auth # force re-auth even if auth.json exists
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
# FORCE_AUTH=1 ./render.sh # same as --fresh-auth
# APP_URL=http://localhost:3001 ./render.sh # override frontend URL
set -euo pipefail
# -- config (override via env) -------------------------------------------------
APP_URL="${APP_URL:-http://host.docker.internal:3001}"
PB_URL="${PB_URL:-http://host.docker.internal:8090}"
API_URL="${API_URL:-http://host.docker.internal:8001}"
PB_EMAIL="${PB_EMAIL:-demo-video@local.test}"
PB_PASSWORD="${PB_PASSWORD:-DemoVideoPass123!}"
MAX_DURATION_S="${MAX_DURATION_S:-15}"
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
for arg in "$@"; do
case "$arg" in
--fresh-auth) FRESH_AUTH=1 ;;
--no-encode) DO_ENCODE=0 ;;
-h|--help)
sed -n '3,18p' "$0"
exit 0 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
done
cd "$(dirname "$0")"
# -- helpers ------------------------------------------------------------------
say() { printf '\n[render] %s\n' "$*"; }
fail() { printf '\n[render] FAIL: %s\n' "$*" >&2; exit 1; }
http_code() {
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 5 "$1" || echo "000"
}
wait_for() {
local url="$1" desc="$2" timeout="${3:-90}"
say "Waiting for $desc ($url)"
for i in $(seq 1 "$timeout"); do
if [ "$(http_code "$url")" = "200" ]; then
say " ready after ${i}s"
return 0
fi
sleep 1
done
fail "$desc not reachable after ${timeout}s"
}
# -- stack health -------------------------------------------------------------
say "Checking stack health"
fe_code="$(http_code "$APP_URL/")"
api_code="$(http_code "$API_URL/api/features")"
pb_code="$(http_code "$PB_URL/api/health")"
echo " frontend=$fe_code api=$api_code pocketbase=$pb_code"
if [ "$fe_code" != "200" ] || [ "$pb_code" != "200" ]; then
fail "Stack down. From the repo root run: docker compose up -d"
fi
if [ "$api_code" != "200" ]; then
wait_for "$API_URL/api/features" "Rust API" 120
fi
# -- node deps ----------------------------------------------------------------
if [ ! -d node_modules ]; then
say "Installing npm deps"
npm install --no-audit --no-fund
fi
# Chromium binary lives in Playwright's cache; install if missing.
if ! npx --no-install playwright --version >/dev/null 2>&1 \
|| [ ! -d "$HOME/.cache/ms-playwright" ] \
|| ! find "$HOME/.cache/ms-playwright" -maxdepth 1 -name "chromium-*" -print -quit | grep -q .; then
say "Installing Playwright Chromium"
npx playwright install chromium
fi
# -- build --------------------------------------------------------------------
say "Compiling TypeScript"
./node_modules/.bin/tsc
# -- auth ---------------------------------------------------------------------
need_auth=0
if [ "$FRESH_AUTH" = "1" ] || [ ! -f auth.json ]; then
need_auth=1
else
# File mtime check, portable: if older than TTL, refresh.
if [ "$(find auth.json -mmin "+$((AUTH_TTL_HOURS * 60))" -print 2>/dev/null)" ]; then
say "auth.json is older than ${AUTH_TTL_HOURS}h, will refresh"
need_auth=1
fi
fi
if [ "$need_auth" = "1" ]; then
say "Minting fresh auth.json (user: $PB_EMAIL)"
PB_URL="$PB_URL" PB_EMAIL="$PB_EMAIL" PB_PASSWORD="$PB_PASSWORD" \
APP_URL="$APP_URL" \
node dist/auth.js
else
say "Reusing existing auth.json"
fi
# -- record -------------------------------------------------------------------
say "Recording"
mkdir -p output
# Wipe last run's leaking artifacts so the rename step picks up *this* run.
rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed
APP_URL="$APP_URL" MAX_DURATION_S="$MAX_DURATION_S" node dist/record.js
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
fi
# -- encode -------------------------------------------------------------------
if [ "$DO_ENCODE" = "1" ]; then
if ! command -v ffmpeg >/dev/null 2>&1; then
fail "ffmpeg not on PATH; rerun with --no-encode if you only need the WebM"
fi
say "Encoding to MP4"
ffmpeg -y -loglevel warning -i output/recording.webm \
-c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart \
output/recording.mp4
fi
# -- report -------------------------------------------------------------------
say "Done"
if command -v ffprobe >/dev/null 2>&1; then
for f in output/recording.webm output/recording.mp4; do
[ -f "$f" ] || continue
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
done
else
ls -la output/recording.* 2>/dev/null || true
fi

97
video/src/auth.ts Normal file
View file

@ -0,0 +1,97 @@
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';
import { APP_URL, AUTH_STATE_PATH } from './config.js';
/**
* Auth setup. Two modes:
*
* 1. Programmatic (preferred for CI / non-interactive runs): set
* PB_URL, PB_EMAIL, PB_PASSWORD env vars. We hit the PocketBase REST
* auth-with-password endpoint, then hand-write a Playwright storageState
* file with the resulting token in localStorage["pb_auth"]. The PocketBase
* JS SDK reads that key on boot and treats us as logged in bit-equivalent
* to a real UI login.
*
* 2. Interactive: no env vars, we open a headed browser, you log in by hand,
* press Enter, and we serialize the resulting cookies + localStorage.
* Works on a developer laptop; doesn't work in headless environments.
*/
interface PbAuthResponse {
token: string;
record: Record<string, unknown>;
}
async function programmatic() {
const email = process.env.PB_EMAIL!;
const password = process.env.PB_PASSWORD!;
// Driving the login through the app itself ensures the PocketBase SDK's
// LocalAuthStore sees the token via its own write path. Hand-writing
// localStorage["pb_auth"] sometimes races with the SDK's module-time read.
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
await page.goto(APP_URL);
await page.evaluate(
async ({ email, password }) => {
const res = await fetch('/pb/api/collections/users/auth-with-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) throw new Error(`login ${res.status} ${await res.text()}`);
const data = await res.json();
// The SDK's LocalAuthStore default storageKey is "pocketbase_auth",
// not "pb_auth" (which is just the cookie name in BaseAuthStore).
localStorage.setItem(
'pocketbase_auth',
JSON.stringify({ token: data.token, record: data.record })
);
// Skip the react-joyride product tour — its spotlight overlay
// intercepts pointer events and breaks the recording.
localStorage.setItem('tutorial_completed', '1');
},
{ email, password }
);
await context.storageState({ path: AUTH_STATE_PATH });
await browser.close();
console.log(`Saved ${AUTH_STATE_PATH} via in-app PocketBase login (user: ${email}).`);
}
async function interactive() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
await page.goto(APP_URL);
console.log('');
console.log(' → Log in via the UI in the opened browser window.');
console.log(' → Once you see the dashboard, press Enter in this terminal.');
console.log('');
await new Promise<void>((resolve) => {
process.stdin.resume();
process.stdin.once('data', () => resolve());
});
await context.storageState({ path: AUTH_STATE_PATH });
console.log(`Saved storage state to ${AUTH_STATE_PATH}`);
await browser.close();
process.exit(0);
}
async function main() {
if (process.env.PB_URL && process.env.PB_EMAIL && process.env.PB_PASSWORD) {
await programmatic();
process.exit(0);
}
await interactive();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

50
video/src/config.ts Normal file
View file

@ -0,0 +1,50 @@
export const APP_URL = process.env.APP_URL ?? 'http://host.docker.internal:3001';
export const DASHBOARD_PATH = '/dashboard';
export const AUTH_STATE_PATH = 'auth.json';
export const OUTPUT_DIR = 'output';
const aspect = process.env.ASPECT ?? '16x9';
export const VIEWPORT =
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
export const PROMPT_TEXT =
process.env.PROMPT_TEXT ?? 'Near Kings Cross, EPC C+, under £600k';
// Filter the AI stub will "return". Keys must match real feature names from
// /api/features. Pulled from the running server's schema.
export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = {
'Estimated current price': [0, 600000],
'Number of bedrooms & living rooms': [4, 6],
'Property type': ['Detached', 'Semi-Detached', 'Terraced'],
'Distance to nearest train or tube station (km)': [0, 1.0],
};
// Slider we'll drag in scene 3. Must be a numeric (range) feature, and must
// already be in STUBBED_FILTERS so the card is mounted by the time we drag.
export const DRAG_FILTER_NAME =
process.env.DRAG_FILTER_NAME ?? 'Estimated current price';
// Fraction of the track to drag the right thumb to (0..1 from the left).
export const DRAG_TO_FRACTION = 0.55;
// London-ish view used for the cold open.
export const COLD_OPEN_VIEW = '#lat=51.535&lon=-0.105&zoom=11';
// Hard cap on the trimmed output. Scene-time overhead (CDP roundtrips,
// boundingBox calls, layout settling) varies run-to-run, so we trim to a
// deterministic length even if total scene wall time exceeds it.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 15);
// Slow down all interactions and animations by this factor while recording,
// then speed the output back up by the same factor in ffmpeg. The visible
// animation speed in the final video is unchanged, but each visual frame had
// N× more wall time to render → fewer dropped frames, smoother motion.
//
// 1 = no slow-down (choppy on software GL)
// 2 = double recording length, ~2× more unique frames in output (recommended)
// 3-4 = even smoother, slower to produce; diminishing returns past 4
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3));
// Target fps of the FINAL output. We force ffmpeg to interpolate up to this
// rate so the speed-up doesn't leave gaps.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 60);

201
video/src/dom.ts Normal file
View file

@ -0,0 +1,201 @@
import type { Page } from 'playwright';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
* native cursor is hidden so what the viewer sees is entirely our element.
*
* Design choice: the cursor listens to mousemove rather than being driven from
* the Node side. That keeps a single source of truth Playwright's real mouse
* and the visual is pure CSS, animated by the browser's compositor.
*/
export async function installCursor(page: Page): Promise<void> {
await page.addStyleTag({
content: `
*, *::before, *::after { cursor: none !important; }
#__demo-cursor {
position: fixed;
top: 0; left: 0;
width: 22px; height: 22px;
pointer-events: none;
z-index: 2147483646;
transform: translate(-2px, -2px);
transition: transform 60ms linear, scale 120ms ease-out;
will-change: transform;
}
#__demo-cursor svg {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
}
#__demo-cursor.click { scale: 0.85; }
.__demo-ripple {
position: fixed;
pointer-events: none;
z-index: 2147483645;
width: 0; height: 0;
border-radius: 50%;
border: 2px solid rgba(20, 184, 166, 0.9);
background: rgba(20, 184, 166, 0.18);
transform: translate(-50%, -50%);
animation: __demo-ripple 600ms ease-out forwards;
}
@keyframes __demo-ripple {
0% { width: 0; height: 0; opacity: 1; }
100% { width: 64px; height: 64px; opacity: 0; }
}
#__demo-vignette {
position: fixed; inset: 0;
pointer-events: none;
background: radial-gradient(circle at center, transparent 40%, rgba(0,0,0,0.55) 100%);
z-index: 2147483640;
opacity: 1;
transition: opacity 1000ms ease-out;
}
#__demo-vignette.gone { opacity: 0; }
#__demo-caption {
position: fixed;
left: 50%;
bottom: 7%;
transform: translate(-50%, 24px);
padding: 14px 22px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.78);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #f0fdfa;
font: 500 22px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.01em;
box-shadow: 0 14px 40px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.08);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease-out, transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
}
#__demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
#__demo-outro {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(2, 6, 23, 0);
z-index: 2147483642;
pointer-events: none;
transition: background 700ms ease-out;
}
#__demo-outro.visible { background: rgba(2, 6, 23, 0.78); backdrop-filter: blur(8px); }
#__demo-outro .card {
text-align: center;
color: white;
opacity: 0;
transform: translateY(12px) scale(0.98);
transition: opacity 700ms ease-out 120ms, transform 700ms cubic-bezier(0.22,1,0.36,1) 120ms;
}
#__demo-outro.visible .card { opacity: 1; transform: translateY(0) scale(1); }
#__demo-outro h1 {
font: 700 64px/1.05 ui-sans-serif, system-ui, sans-serif;
margin: 0 0 12px;
background: linear-gradient(90deg, #5eead4, #14b8a6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: -0.02em;
}
#__demo-outro p { font: 400 24px/1.4 ui-sans-serif, system-ui, sans-serif; color: #cbd5e1; margin: 0 0 18px; }
#__demo-outro .url { font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; color: #5eead4; }
`,
});
await page.evaluate(() => {
const cursor = document.createElement('div');
cursor.id = '__demo-cursor';
cursor.innerHTML = `
<svg viewBox="0 0 22 22" width="22" height="22" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2 L2 18 L7 13 L10 20 L13 19 L10 12 L17 12 Z"
fill="white" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/>
</svg>`;
document.body.appendChild(cursor);
const vignette = document.createElement('div');
vignette.id = '__demo-vignette';
document.body.appendChild(vignette);
const caption = document.createElement('div');
caption.id = '__demo-caption';
document.body.appendChild(caption);
window.addEventListener(
'mousemove',
(e) => {
cursor.style.transform = `translate(${e.clientX - 2}px, ${e.clientY - 2}px)`;
},
{ passive: true, capture: true }
);
window.addEventListener(
'mousedown',
(e) => {
cursor.classList.add('click');
const r = document.createElement('div');
r.className = '__demo-ripple';
r.style.left = `${e.clientX}px`;
r.style.top = `${e.clientY}px`;
document.body.appendChild(r);
setTimeout(() => r.remove(), 650);
},
{ passive: true, capture: true }
);
window.addEventListener(
'mouseup',
() => cursor.classList.remove('click'),
{ passive: true, capture: true }
);
});
}
export async function clearVignette(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById('__demo-vignette')?.classList.add('gone');
});
}
export async function showCaption(page: Page, text: string): Promise<void> {
await page.evaluate((t) => {
const el = document.getElementById('__demo-caption');
if (!el) return;
el.textContent = t;
el.classList.add('visible');
}, text);
}
export async function hideCaption(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById('__demo-caption')?.classList.remove('visible');
});
}
export async function showOutro(
page: Page,
brand: string,
tagline: string,
url: string
): Promise<void> {
await page.evaluate(
({ brand, tagline, url }) => {
const el = document.createElement('div');
el.id = '__demo-outro';
el.innerHTML = `
<div class="card">
<h1>${brand}</h1>
<p>${tagline}</p>
<div class="url">${url}</div>
</div>`;
document.body.appendChild(el);
// Force reflow so the transition fires.
void el.offsetHeight;
el.classList.add('visible');
},
{ brand, tagline, url }
);
}

128
video/src/motion.ts Normal file
View file

@ -0,0 +1,128 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
// All timing primitives multiply by RECORD_SCALE. Scenes call them with
// "human-time" durations; the actual wall-clock pause is N× longer so the
// renderer has more time per visual frame. ffmpeg later speeds the output
// back up, so the *visible* animation speed in the final video is unchanged.
export const sleep = (ms: number) =>
new Promise<void>((r) => setTimeout(r, ms * RECORD_SCALE));
// Cubic ease-in-out: slow start and end, fast middle. Reads as "natural" motion.
export const easeInOut = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// Slight overshoot then settle — gives clicks a tactile feel when paired with ripple.
export const easeOutBack = (t: number): number => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};
interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
}
/**
* Move the real mouse from its current position to (x, y) along an eased path.
* The injected cursor follows via its mousemove listener no explicit visual sync needed.
*/
export async function smoothMove(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ durationMs = 600, ease = easeInOut }: MoveOptions = {}
): Promise<void> {
// Step count scales with RECORD_SCALE so we get more cursor positions per
// unit of visible animation — each one is a chance for the renderer to
// sample. CDP roundtrips cap us at ~60 commands/s, so 60fps × RECORD_SCALE
// is the practical ceiling.
const fps = 60;
const wallDuration = durationMs * RECORD_SCALE;
const steps = Math.max(2, Math.round((wallDuration / 1000) * fps));
const stepWaitMs = wallDuration / steps;
for (let i = 1; i <= steps; i++) {
const t = ease(i / steps);
const x = from.x + (to.x - from.x) * t;
const y = from.y + (to.y - from.y) * t;
await page.mouse.move(x, y);
// Use a non-scaling sleep here — we already factored RECORD_SCALE in.
await new Promise((r) => setTimeout(r, stepWaitMs));
}
}
/**
* "Fake" type: progressively set the textarea value from inside the browser,
* dispatching React-compatible input events. Looks identical to keyboard.type
* but runs in one CDP roundtrip instead of N (where N = char count). On a
* 37-char prompt this is ~1s instead of ~3s.
*/
export async function fakeType(
page: Page,
selector: string,
text: string,
delayMs: number
): Promise<void> {
// Scale browser-side typing by RECORD_SCALE too, so the typing animation
// has more wall time per character to render.
const scaledDelay = delayMs * RECORD_SCALE;
await page.evaluate(
({ selector, text, delayMs }) => {
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
if (!ta) throw new Error('textarea not found: ' + selector);
ta.focus();
// React tracks the textarea value by hooking the descriptor; we have to
// call the prototype setter directly so React sees the change.
const proto = Object.getPrototypeOf(ta);
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (!setValue) throw new Error('no value setter on textarea');
return new Promise<void>((resolve) => {
let i = 0;
const id = window.setInterval(() => {
i += 1;
setValue.call(ta, text.slice(0, i));
ta.dispatchEvent(new Event('input', { bubbles: true }));
if (i >= text.length) {
window.clearInterval(id);
resolve();
}
}, delayMs);
});
},
{ selector, text, delayMs: scaledDelay }
);
}
/**
* Drag the right-hand thumb of a Radix slider to a target track fraction.
* Returns the final cursor position so callers can chain a smoothMove afterwards.
*/
export async function smoothDragSliderThumb(
page: Page,
thumbSelector: string,
trackSelector: string,
fromCursor: { x: number; y: number },
toFraction: number,
durationMs = 1100
): Promise<{ x: number; y: number }> {
const thumbBox = await page.locator(thumbSelector).boundingBox();
const trackBox = await page.locator(trackSelector).boundingBox();
if (!thumbBox || !trackBox) throw new Error('slider not found');
const thumbCx = thumbBox.x + thumbBox.width / 2;
const thumbCy = thumbBox.y + thumbBox.height / 2;
const targetX = trackBox.x + trackBox.width * toFraction;
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 500 });
await page.mouse.down();
await smoothMove(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };
}

93
video/src/probe.ts Normal file
View file

@ -0,0 +1,93 @@
import { chromium } from 'playwright';
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH, VIEWPORT } from './config.js';
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
});
const page = await context.newPage();
page.on('request', (r) => {
if (r.url().includes('auth-refresh')) {
console.log('REQ', r.method(), r.url(), 'headers:', JSON.stringify(r.headers()));
}
});
page.on('response', async (r) => {
if (r.url().includes('auth-refresh')) {
const body = await r.text().catch(() => '');
console.log('RES', r.status(), r.url(), 'body:', body.slice(0, 200));
}
});
await page.goto(`${APP_URL}${DASHBOARD_PATH}`, { waitUntil: 'networkidle' });
await new Promise((r) => setTimeout(r, 1500));
await page.screenshot({ path: 'output/probe-1-initial.png', fullPage: false });
const ls = await page.evaluate(() => {
const v = localStorage.getItem('pocketbase_auth');
let parsed: unknown = null;
try { parsed = v ? JSON.parse(v) : null; } catch {}
return {
raw: v?.slice(0, 80),
hasToken: !!(parsed as { token?: string })?.token,
hasRecord: !!(parsed as { record?: unknown })?.record,
hasModel: !!(parsed as { model?: unknown })?.model,
};
});
console.log('pb_auth localStorage:', ls);
const refreshTest = await page.evaluate(async () => {
const stored = JSON.parse(localStorage.getItem('pb_auth') ?? '{}');
const r = await fetch('/pb/api/collections/users/auth-refresh', {
method: 'POST',
headers: { Authorization: stored.token },
});
return { status: r.status, body: (await r.text()).slice(0, 200) };
});
console.log('refresh test:', refreshTest);
// Try the SDK path directly. Reach into the Vite-served module graph.
const sdkRefresh = await page.evaluate(async () => {
type W = Window & { pb?: { collection: (n: string) => { authRefresh: () => Promise<unknown> }, authStore: { isValid: boolean, token: string, record: unknown } } };
const w = window as W;
if (!w.pb) return { error: 'window.pb not exposed' };
const before = { isValid: w.pb.authStore.isValid, hasToken: !!w.pb.authStore.token };
try {
const out = await w.pb.collection('users').authRefresh();
return { before, ok: true, out };
} catch (e) {
return { before, ok: false, error: String(e) };
}
});
console.log('SDK refresh:', sdkRefresh);
const aiCount = await page.locator('[data-tutorial="ai-filters"]').count();
const aiVisible = await page.locator('[data-tutorial="ai-filters"]').first().isVisible().catch(() => false);
const aiBtnCount = await page.locator('[data-tutorial="ai-filters"] button').count();
const filterCount = await page.locator('[data-filter-name]').count();
const filterNames = await page.locator('[data-filter-name]').evaluateAll((els) =>
els.map((e) => e.getAttribute('data-filter-name'))
);
const tutorialOverlay = await page.locator('[role="dialog"], [data-tutorial-step], .tutorial-overlay').count();
console.log({ aiCount, aiVisible, aiBtnCount, filterCount, filterNames, tutorialOverlay });
// Try clicking the AI button and check whether textarea appears
const aiBtn = page.locator('[data-tutorial="ai-filters"] button').first();
if (await aiBtn.isVisible()) {
await aiBtn.click();
await new Promise((r) => setTimeout(r, 600));
const taCount = await page.locator('[data-tutorial="ai-filters"] textarea').count();
console.log({ afterClick_taCount: taCount });
await page.screenshot({ path: 'output/probe-2-after-click.png' });
}
await browser.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

220
video/src/record.ts Normal file
View file

@ -0,0 +1,220 @@
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, renameSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import {
APP_URL,
AUTH_STATE_PATH,
COLD_OPEN_VIEW,
DASHBOARD_PATH,
MAX_DURATION_S,
OUTPUT_DIR,
OUTPUT_FPS,
RECORD_SCALE,
STUBBED_FILTERS,
VIEWPORT,
} from './config.js';
import { installCursor } from './dom.js';
import {
sceneAiPrompt,
sceneColdOpen,
sceneOutro,
scenePropertyReveal,
sceneSliderControl,
type SceneCtx,
} from './scenes.js';
import { sleep } from './motion.js';
/**
* Stub the AI endpoint. The real backend calls Gemini and takes 25s; for a
* 15-second video we want sub-second response so the map reacts crisply with
* the typed prompt still on screen. Returning canned filters also makes every
* recording bit-identical.
*/
async function stubAiFilters(page: import('playwright').Page) {
await page.route('**/api/ai-filters', async (route) => {
// Small delay so the loading indicator is visible (looks like real AI work).
await new Promise((r) => setTimeout(r, 400));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: [],
notes: '',
match_count: 1247,
}),
});
});
}
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(
`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first to log in once.`
);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
// Headless Chromium otherwise loses the WebGL context mid-render when
// deck.gl pushes large buffers; SwiftShader is software GL but stable.
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
// Lift Chromium's animation/raster rate caps so RECORD_SCALE actually
// gets us extra frames per second of wall time. Without these, Chromium
// throttles offscreen rendering and the slow-down is wasted.
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: 2,
recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT },
});
// Vite's dev server pushes HMR updates over a "vite-hmr" WebSocket. If a
// module isn't accept-marked the client triggers a FULL page reload — which
// mid-recording resets the React tree and re-shows "Connecting to server…".
// Disable the client-side HMR socket entirely.
await context.addInitScript(() => {
// Block the vite-hmr WebSocket so HMR push messages never arrive.
const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, {
construct(target, args) {
const proto = (args[1] as string | string[] | undefined) ?? '';
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
if (protoStr.includes('vite-hmr')) {
return Object.assign(Object.create(RealWS.prototype), {
readyState: RealWS.CONNECTING,
send() {}, close() {},
addEventListener() {}, removeEventListener() {},
dispatchEvent: () => true,
});
}
return Reflect.construct(target, args);
},
});
// Belt-and-braces: even if an HMR push slips through (e.g. via a different
// transport in a later Vite version), neutralize the full-reload fallback.
const noop = () => {};
Object.defineProperty(window.location, 'reload', { value: noop, configurable: true });
});
const page = await context.newPage();
// recordVideo starts the moment the page is created. We want the final clip
// to begin at the cold-open scene, not include the navigation/settle phase.
// Track when the recording started and when the scenes start, so we can
// ffmpeg-trim post-hoc.
const recordStartMs = Date.now();
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') {
console.log(`[browser ${m.type()}] ${m.text()}`);
}
});
page.on('response', (r) => {
const u = r.url();
if (r.status() === 401 || u.includes('ai-filters')) {
console.log(`[net] ${r.status()} ${r.request().method()} ${u}`);
}
});
page.on('request', (r) => {
const u = r.url();
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await stubAiFilters(page);
const url = `${APP_URL}${DASHBOARD_PATH}${COLD_OPEN_VIEW}`;
await page.goto(url, { waitUntil: 'networkidle' });
// Settle: deck.gl tiles, postcode aggregations, sidebar mount.
await sleep(800);
await installCursor(page);
// Park cursor near top-left so its first move (to the AI box) is visible.
const ctx: SceneCtx = { page, cursor: { x: 80, y: 90 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
const sceneStartMs = Date.now();
await sceneColdOpen(ctx);
await sceneAiPrompt(ctx);
await sceneSliderControl(ctx);
await scenePropertyReveal(ctx);
await sceneOutro(ctx);
const sceneEndMs = Date.now();
await page.close();
await context.close();
await browser.close();
// Playwright names recordings by guid; rename the most recent one.
const files = readdirSync(OUTPUT_DIR)
.filter((f) => f.endsWith('.webm') && f.startsWith('page@'))
.map((f) => ({ f, t: statSync(join(OUTPUT_DIR, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
if (!files[0]) {
console.error('no recorded webm found');
process.exit(1);
}
const rawPath = join(OUTPUT_DIR, files[0].f);
const trimmedPath = join(OUTPUT_DIR, 'recording.webm');
const sceneSpan = (sceneEndMs - sceneStartMs) / 1000;
// The trim window is in *recording wall time*, which is RECORD_SCALE× the
// visible duration. After ffmpeg setpts speeds it back up, the final clip
// will be exactly MAX_DURATION_S seconds.
const wallCap = MAX_DURATION_S * RECORD_SCALE;
const trimEnd = (sceneEndMs - recordStartMs) / 1000;
const wallDuration = Math.min(sceneSpan, wallCap);
const trimStart = trimEnd - wallDuration;
const finalDuration = wallDuration / RECORD_SCALE;
if (sceneSpan > wallCap) {
console.log(
`Scene wall time was ${sceneSpan.toFixed(2)}s (cap ${wallCap.toFixed(2)}s at scale ${RECORD_SCALE}); trimming to last ${MAX_DURATION_S}s (anchored to outro).`
);
}
// Trim AND speed up AND interpolate to OUTPUT_FPS in one pass.
// - -ss + -t: trim window in raw recording's wall time.
// - setpts=PTS/RECORD_SCALE: speed up the clip back to "human time".
// - minterpolate at fps=OUTPUT_FPS: synthesize intermediate frames so the
// sped-up output runs smoothly at 60fps even if raw was 25fps.
// - libvpx-vp9 with -deadline good gives a tight WebM that the encode step
// can re-mux to MP4 quickly.
execSync(
`ffmpeg -y -ss ${trimStart.toFixed(3)} -i "${rawPath}" -t ${wallDuration.toFixed(3)} ` +
`-vf "setpts=PTS/${RECORD_SCALE},minterpolate=fps=${OUTPUT_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" ` +
`-r ${OUTPUT_FPS} ` +
`-c:v libvpx-vp9 -b:v 6M -deadline good -cpu-used 2 ` +
`"${trimmedPath}"`,
{ stdio: 'inherit' }
);
// Drop the untrimmed file once we've extracted the scenes.
try {
statSync(rawPath) && renameSync(rawPath, rawPath + '.untrimmed');
} catch {
/* ignore */
}
console.log(`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s @ ${OUTPUT_FPS}fps, scale=${RECORD_SCALE})`);
console.log('Run "npm run encode" to produce output/recording.mp4');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

125
video/src/scenes.ts Normal file
View file

@ -0,0 +1,125 @@
import type { Page } from 'playwright';
import {
PROMPT_TEXT,
DRAG_FILTER_NAME,
DRAG_TO_FRACTION,
} from './config.js';
import {
clearVignette,
hideCaption,
showCaption,
showOutro,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
export interface SceneCtx {
page: Page;
cursor: { x: number; y: number };
}
/** Cold open. Vignette fades; cursor parks at a "natural" rest position. */
export async function sceneColdOpen(ctx: SceneCtx): Promise<void> {
await clearVignette(ctx.page);
await ctx.page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await sleep(1100);
}
/**
* AI prompt scene: click the collapsed AI box, type the prompt, submit,
* watch the (stubbed) response apply.
*/
export async function sceneAiPrompt(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Describe the area you want.');
const aiButton = page.locator('[data-tutorial="ai-filters"] button').first();
const btnBox = await aiButton.boundingBox();
if (!btnBox) throw new Error('AI button not found');
const target = { x: btnBox.x + btnBox.width / 2, y: btnBox.y + btnBox.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 400 });
ctx.cursor = target;
await page.mouse.click(target.x, target.y);
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
await textarea.waitFor({ state: 'visible', timeout: 3000 });
await sleep(120);
const taBox = await textarea.boundingBox();
if (taBox) {
const into = { x: taBox.x + 30, y: taBox.y + taBox.height / 2 };
await smoothMove(page, ctx.cursor, into, { durationMs: 220 });
ctx.cursor = into;
}
// fakeType runs the typing animation inside the browser to avoid CDP
// round-trip overhead per keystroke (which can quadruple total typing time).
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 35);
await sleep(180);
await page.keyboard.press('Enter');
await sleep(700);
await hideCaption(page);
await sleep(150);
}
/**
* Slider scene: pan to a numeric filter's right thumb and drag it inward.
* The whole point: the user sees the map react in real time to a human action,
* driving home that AI sets a starting point but you stay in control.
*/
export async function sceneSliderControl(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'You stay in control.');
const card = page.locator(`[data-filter-name="${DRAG_FILTER_NAME}"]`);
await card.waitFor({ state: 'visible', timeout: 3000 });
await card.scrollIntoViewIfNeeded();
await sleep(120);
const thumbSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [role="slider"] >> nth=1`;
const trackSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [data-orientation="horizontal"] >> nth=0`;
ctx.cursor = await smoothDragSliderThumb(
page,
thumbSelector,
trackSelector,
ctx.cursor,
DRAG_TO_FRACTION,
1100
);
await sleep(550);
await hideCaption(page);
await sleep(150);
}
/** Property reveal: click a postcode on the map to open the side pane with charts. */
export async function scenePropertyReveal(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const target = {
x: 360 + (viewport.width - 360) * 0.55,
y: viewport.height * 0.5,
};
await smoothMove(page, ctx.cursor, target, { durationMs: 500 });
ctx.cursor = target;
await page.mouse.click(target.x, target.y);
await sleep(1300);
}
/** Outro: full-screen logo card with brand + URL. */
export async function sceneOutro(ctx: SceneCtx): Promise<void> {
await showOutro(
ctx.page,
'Perfect Postcodes',
'Find where you actually want to live.',
'perfectpostcodes.com'
);
await sleep(1800);
}

17
video/tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"]
}