Small changes and fix zooming

This commit is contained in:
Andras Schmelczer 2026-05-05 20:30:04 +01:00
parent c69bb0d614
commit 329685a4ee
16 changed files with 823 additions and 202 deletions

View file

@ -1,10 +1,13 @@
# Stage 1: Build frontend
FROM node:22-slim AS frontend
FROM node:22-bookworm-slim AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
RUN apt-get update \
&& npx puppeteer browsers install chrome --install-deps \
&& rm -rf /var/lib/apt/lists/*
COPY frontend/ ./
RUN npm run build:no-prerender
RUN npm run build
# Stage 2: Build Rust server
FROM rust:1.84-bookworm AS server

File diff suppressed because it is too large Load diff

View file

@ -62,6 +62,7 @@
"prettier": "^3.2.0",
"puppeteer": "^24.0.0",
"react-refresh": "^0.18.0",
"sharp": "^0.34.5",
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.0",
"ts-loader": "^9.5.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -5,5 +5,9 @@ Disallow: /metrics
Disallow: /health
Disallow: /pb/
Disallow: /s/
Disallow: /account
Disallow: /saved
Disallow: /invites
Disallow: /invite/
Sitemap: https://perfect-postcode.co.uk/sitemap.xml

View file

@ -87,13 +87,11 @@ function DeckOverlay({
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
const prevLayersRef = useRef(layers);
const prevTooltipRef = useRef(getTooltip);
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
prevLayersRef.current = layers;
prevTooltipRef.current = getTooltip;
overlay.setProps({ layers, getTooltip });
}
useEffect(() => {
overlay.setProps({ layers: layers.filter(Boolean), getTooltip });
}, [overlay, layers, getTooltip]);
return null;
}
@ -376,7 +374,7 @@ export default memo(function Map({
</div>
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 50,
@ -385,7 +383,7 @@ export default memo(function Map({
}}
>
<button
className="absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
onClick={clearPopupInfo}
>
<CloseIcon className="w-3 h-3" />

View file

@ -21,6 +21,17 @@ export type Page =
| 'dashboard'
| 'learn'
| 'pricing'
| 'property-price-map'
| 'postcode-property-search'
| 'commute-property-search'
| 'school-property-search'
| 'postcode-checker'
| 'birmingham-property-search'
| 'manchester-property-search'
| 'bristol-property-search'
| 'data-sources'
| 'methodology'
| 'privacy-security'
| 'account'
| 'saved'
| 'invites'
@ -31,6 +42,17 @@ export const PAGE_PATHS: Record<Page, string> = {
dashboard: '/dashboard',
learn: '/learn',
pricing: '/pricing',
'property-price-map': '/property-price-map',
'postcode-property-search': '/postcode-property-search',
'commute-property-search': '/commute-property-search',
'school-property-search': '/school-property-search',
'postcode-checker': '/postcode-checker',
'birmingham-property-search': '/property-search/birmingham',
'manchester-property-search': '/property-search/manchester',
'bristol-property-search': '/property-search/bristol',
'data-sources': '/data-sources',
methodology: '/methodology',
'privacy-security': '/privacy-security',
saved: '/saved',
invites: '/invites',
account: '/account',
@ -134,7 +156,7 @@ export default function Header({
onClick={(e) => navLink('home', e)}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">{t('header.appName')}</span>
<span className="text-lg font-semibold text-teal-300">{t('header.appName')}</span>
</a>
{/* Desktop nav */}

View file

@ -1,6 +1,10 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, type LanguageCode } from '../../i18n';
import {
changeLanguage as changeAppLanguage,
SUPPORTED_LANGUAGES,
type LanguageCode,
} from '../../i18n';
export default function LanguageDropdown() {
const { i18n } = useTranslation();
@ -20,8 +24,8 @@ export default function LanguageDropdown() {
}, [open]);
const changeLanguage = (code: LanguageCode) => {
i18n.changeLanguage(code);
localStorage.setItem('language', code);
void changeAppLanguage(code);
setOpen(false);
};

View file

@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import type { Page } from './Header';
import { PAGE_PATHS } from './Header';
import type { AuthUser } from '../../hooks/useAuth';
import { SUPPORTED_LANGUAGES } from '../../i18n';
import { changeLanguage as changeAppLanguage, SUPPORTED_LANGUAGES } from '../../i18n';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { CheckIcon } from './icons/CheckIcon';
@ -161,8 +161,8 @@ export default function MobileMenu({
<button
key={lang.code}
onClick={() => {
i18n.changeLanguage(lang.code);
localStorage.setItem('language', lang.code);
void changeAppLanguage(lang.code);
}}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
i18n.language === lang.code

View file

@ -39,6 +39,7 @@ interface UseMapDataOptions {
features: FeatureMeta[];
viewFeature: string | null;
activeFeature: string | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
@ -50,6 +51,7 @@ export function useMapData({
features,
viewFeature,
activeFeature,
pinnedFeature,
travelTimeEntries,
shareCode,
}: UseMapDataOptions) {
@ -83,6 +85,10 @@ export function useMapData({
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
const pinnedDataViewFeature = useMemo(
() => (pinnedFeature ? (getSchoolBackendFeatureName(pinnedFeature) ?? pinnedFeature) : null),
[pinnedFeature]
);
// Determine if the current viewFeature is an enum (for enum_dist param)
const viewFeatureIsEnum = useMemo(
@ -95,6 +101,7 @@ export function useMapData({
(): string => buildFilterString(filters, features),
[filters, features]
);
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
// Build the travel param string from entries with destinations.
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
@ -122,6 +129,37 @@ export function useMapData({
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
const boundsParam = useMemo(
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
[bounds]
);
const dataRequestKey = useMemo(
() =>
bounds
? [
usePostcodeView ? 'postcodes' : 'hexagons',
resolution,
boundsParam,
filtersParam,
dataViewFeature ?? '',
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
travelParam,
shareCode ?? '',
].join('|')
: '',
[
bounds,
boundsParam,
dataViewFeature,
filtersParam,
resolution,
shareCode,
travelParam,
usePostcodeView,
viewFeatureIsEnum,
]
);
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
// Keep activeFeatureRef in sync
useEffect(() => {
@ -219,12 +257,11 @@ export function useMapData({
setLoading(true);
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
const requestKey = dataRequestKey;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
const params = new URLSearchParams({ bounds: boundsParam });
if (filtersParam) params.set('filters', filtersParam);
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
@ -254,12 +291,13 @@ export function useMapData({
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
setLoadedDataKey(requestKey);
} else {
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
bounds: boundsParam,
});
if (filtersStr) params.set('filters', filtersStr);
if (filtersParam) params.set('filters', filtersParam);
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
@ -289,6 +327,7 @@ export function useMapData({
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
setLoadedDataKey(requestKey);
}
// Clear drag data when committed fetch completes and we're not mid-drag
@ -315,7 +354,9 @@ export function useMapData({
resolution,
bounds,
filters,
buildFilterParam,
filtersParam,
boundsParam,
dataRequestKey,
dataViewFeature,
viewFeatureIsEnum,
usePostcodeView,
@ -377,8 +418,8 @@ export function useMapData({
];
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
// Live color range for the legend and hex coloring.
const liveColorRange = useMemo((): [number, number] | null => {
if (!dataViewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
@ -396,6 +437,61 @@ export function useMapData({
return null;
}, [dataViewFeature, features, dataRange]);
const isEyePreviewingPinnedFeature =
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
const [frozenPreviewRange, setFrozenPreviewRange] = useState<{
feature: string;
range: [number, number];
} | null>(null);
useEffect(() => {
setFrozenPreviewRange((prev) => {
if (!pinnedDataViewFeature) return prev ? null : prev;
return prev?.feature === pinnedDataViewFeature ? prev : null;
});
}, [pinnedDataViewFeature]);
useEffect(() => {
if (!isEyePreviewingPinnedFeature || !pinnedDataViewFeature) return;
const meta = pinnedDataViewFeature.startsWith('tt_')
? null
: features.find((f) => f.name === pinnedDataViewFeature);
const rangeToFreeze =
dataRange && loadedDataKey === dataRequestKey
? dataRange
: meta?.type === 'enum' && liveColorRange
? liveColorRange
: null;
if (!rangeToFreeze) return;
setFrozenPreviewRange((prev) =>
prev?.feature === pinnedDataViewFeature
? prev
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
);
}, [
dataRange,
dataRequestKey,
features,
isEyePreviewingPinnedFeature,
loadedDataKey,
liveColorRange,
pinnedDataViewFeature,
]);
const colorRange = useMemo((): [number, number] | null => {
if (
isEyePreviewingPinnedFeature &&
frozenPreviewRange &&
frozenPreviewRange.feature === dataViewFeature
) {
return frozenPreviewRange.range;
}
return liveColorRange;
}, [dataViewFeature, frozenPreviewRange, isEyePreviewingPinnedFeature, liveColorRange]);
const handleViewChange = useCallback(
({
resolution: newRes,

View file

@ -1,9 +1,12 @@
import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Step, CallBackProps } from 'react-joyride';
import { ACTIONS, EVENTS, STATUS } from 'react-joyride';
const STORAGE_KEY = 'tutorial_completed';
const JOYRIDE_ACTION_CLOSE = 'close';
const JOYRIDE_EVENT_STEP_AFTER = 'step:after';
const JOYRIDE_STATUS_FINISHED = 'finished';
const JOYRIDE_STATUS_SKIPPED = 'skipped';
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
const { t } = useTranslation();
@ -67,12 +70,12 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
const handleCallback = useCallback((data: CallBackProps) => {
const { status, action, type } = data;
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
if (status === JOYRIDE_STATUS_FINISHED || status === JOYRIDE_STATUS_SKIPPED) {
localStorage.setItem(STORAGE_KEY, '1');
setRun(false);
}
// Also stop if user closes a tooltip via the X button
if (action === ACTIONS.CLOSE && type === EVENTS.STEP_AFTER) {
if (action === JOYRIDE_ACTION_CLOSE && type === JOYRIDE_EVENT_STEP_AFTER) {
localStorage.setItem(STORAGE_KEY, '1');
setRun(false);
}

View file

@ -332,8 +332,8 @@ const en = {
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: 'For buyers asking “where should I even look?”',
heroTitle1: 'Find the postcodes',
heroTitle2: 'that fit your life',
heroTitle1: 'Find the postcodes that',
heroTitle2: 'fit your life',
heroTitle3: 'Not just the areas you already know.',
heroSubtitle:
'From London boroughs to commuter towns and regional cities, England has too many places to research one by one.',
@ -341,7 +341,7 @@ const en = {
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans Englands postcodes and reveals the places that actually fit, including areas you would never have typed into a listing portal.',
exploreTheMap: 'Find my matching postcodes',
seeTheDifference: 'See how it works',
showcaseHeader: 'Product showcase',
showcaseHeader: 'How it works',
showcaseContext: 'How Perfect Postcode works',
showcaseStep1Tab: 'Filter',
showcaseStep1Title: 'Turn vague needs into a tight search',
@ -362,8 +362,8 @@ const en = {
showcaseStep3Title: 'Inspect why a postcode made the cut',
showcaseStep3Body:
'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
showcaseStep3HeaderArea: 'Penge · SE20',
showcaseStep3HeaderFit: 'Strong fit · 7/8',
showcaseStep3HeaderArea: 'Your perfect postcode',
showcaseStep3HeaderFit: 'Neighbourhood evidence',
showcaseStep3Stat1Label: 'Sold price trend',
showcaseStep3Stat2Label: 'Crime rate',
showcaseStep3Stat2Value: 'Below borough avg.',

View file

@ -101,65 +101,6 @@ h3 {
}
}
/* Cereal aside — hover to reveal */
@keyframes cereal-wobble {
0%,
100% {
transform: rotate(0deg);
}
15% {
transform: rotate(-8deg);
}
30% {
transform: rotate(6deg);
}
45% {
transform: rotate(-4deg);
}
60% {
transform: rotate(2deg);
}
80% {
transform: rotate(-1deg);
}
}
.cereal-wobble {
transform-origin: bottom center;
}
.group:hover .cereal-wobble {
animation: cereal-wobble 0.8s ease-in-out;
}
.cereal-reveal {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 0.5s ease-out,
color 0.2s ease;
}
.group:hover .cereal-reveal {
grid-template-rows: 1fr;
}
.cereal-reveal > * {
overflow: hidden;
}
.cereal-text {
opacity: 0;
transition:
opacity 0.4s ease-out,
color 0.2s ease;
}
.group:hover .cereal-text {
opacity: 1;
transition-delay: 0.2s, 0s;
}
/* Aurora gradient animation for pricing hero */
@keyframes aurora-1 {
0%,

View file

@ -127,11 +127,7 @@ async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds>
return None;
}
let json: Value = resp.json().await.ok()?;
let params = json["items"]
.as_array()?
.first()?
.get("params")?
.as_str()?;
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))
}

View file

@ -298,16 +298,12 @@ async fn main() -> anyhow::Result<()> {
let poi_category_groups = poi_data.category_groups()?;
// Read index.html at startup for crawler OG injection (only when --dist is provided)
let index_html = if let Some(ref dist) = cli.dist {
let index_path = dist.join("index.html");
let html = std::fs::read_to_string(&index_path)
.with_context(|| format!("Failed to read {}", index_path.display()))?;
info!("Loaded index.html for OG injection");
Some(html)
let is_dev = if cli.dist.is_some() {
info!("Static frontend serving enabled");
false
} else {
info!("No --dist provided; static serving and OG injection disabled");
None
info!("No --dist provided; static serving disabled");
true
};
let http_client = reqwest::Client::builder()
@ -406,8 +402,7 @@ async fn main() -> anyhow::Result<()> {
features_response,
screenshot_url: cli.screenshot_url,
public_url: cli.public_url,
is_dev: index_html.is_none(),
index_html,
is_dev,
http_client,
pocketbase_url: cli.pocketbase_url,
pocketbase_admin_email: cli.pocketbase_admin_email,

View file

@ -57,8 +57,6 @@ pub struct AppState {
pub public_url: String,
/// True when --dist is not provided (no static serving, relaxed auth checks)
pub is_dev: bool,
/// Contents of index.html read at startup, used for crawler OG injection (None when --dist omitted)
pub index_html: Option<String>,
/// Shared HTTP client for proxying to the screenshot service and PocketBase
pub http_client: reqwest::Client,
/// PocketBase server URL for authentication (e.g. http://localhost:8090)