Small changes and fix zooming
This commit is contained in:
parent
c69bb0d614
commit
329685a4ee
16 changed files with 823 additions and 202 deletions
|
|
@ -1,10 +1,13 @@
|
||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM node:22-slim AS frontend
|
FROM node:22-bookworm-slim AS frontend
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
RUN apt-get update \
|
||||||
|
&& npx puppeteer browsers install chrome --install-deps \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build:no-prerender
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build Rust server
|
# Stage 2: Build Rust server
|
||||||
FROM rust:1.84-bookworm AS server
|
FROM rust:1.84-bookworm AS server
|
||||||
|
|
|
||||||
744
frontend/package-lock.json
generated
744
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -62,6 +62,7 @@
|
||||||
"prettier": "^3.2.0",
|
"prettier": "^3.2.0",
|
||||||
"puppeteer": "^24.0.0",
|
"puppeteer": "^24.0.0",
|
||||||
"react-refresh": "^0.18.0",
|
"react-refresh": "^0.18.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"ts-loader": "^9.5.0",
|
"ts-loader": "^9.5.0",
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
|
|
@ -5,5 +5,9 @@ Disallow: /metrics
|
||||||
Disallow: /health
|
Disallow: /health
|
||||||
Disallow: /pb/
|
Disallow: /pb/
|
||||||
Disallow: /s/
|
Disallow: /s/
|
||||||
|
Disallow: /account
|
||||||
|
Disallow: /saved
|
||||||
|
Disallow: /invites
|
||||||
|
Disallow: /invite/
|
||||||
|
|
||||||
Sitemap: https://perfect-postcode.co.uk/sitemap.xml
|
Sitemap: https://perfect-postcode.co.uk/sitemap.xml
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,11 @@ function DeckOverlay({
|
||||||
getTooltip: any;
|
getTooltip: any;
|
||||||
}) {
|
}) {
|
||||||
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
|
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
|
||||||
const prevLayersRef = useRef(layers);
|
|
||||||
const prevTooltipRef = useRef(getTooltip);
|
useEffect(() => {
|
||||||
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
|
overlay.setProps({ layers: layers.filter(Boolean), getTooltip });
|
||||||
prevLayersRef.current = layers;
|
}, [overlay, layers, getTooltip]);
|
||||||
prevTooltipRef.current = getTooltip;
|
|
||||||
overlay.setProps({ layers, getTooltip });
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,7 +374,7 @@ export default memo(function Map({
|
||||||
</div>
|
</div>
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
left: popupInfo.x,
|
left: popupInfo.x,
|
||||||
top: popupInfo.y - 50,
|
top: popupInfo.y - 50,
|
||||||
|
|
@ -385,7 +383,7 @@ export default memo(function Map({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<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}
|
onClick={clearPopupInfo}
|
||||||
>
|
>
|
||||||
<CloseIcon className="w-3 h-3" />
|
<CloseIcon className="w-3 h-3" />
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,17 @@ export type Page =
|
||||||
| 'dashboard'
|
| 'dashboard'
|
||||||
| 'learn'
|
| 'learn'
|
||||||
| 'pricing'
|
| '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'
|
| 'account'
|
||||||
| 'saved'
|
| 'saved'
|
||||||
| 'invites'
|
| 'invites'
|
||||||
|
|
@ -31,6 +42,17 @@ export const PAGE_PATHS: Record<Page, string> = {
|
||||||
dashboard: '/dashboard',
|
dashboard: '/dashboard',
|
||||||
learn: '/learn',
|
learn: '/learn',
|
||||||
pricing: '/pricing',
|
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',
|
saved: '/saved',
|
||||||
invites: '/invites',
|
invites: '/invites',
|
||||||
account: '/account',
|
account: '/account',
|
||||||
|
|
@ -134,7 +156,7 @@ export default function Header({
|
||||||
onClick={(e) => navLink('home', e)}
|
onClick={(e) => navLink('home', e)}
|
||||||
>
|
>
|
||||||
<LogoIcon className="w-5 h-5 text-teal-400" />
|
<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>
|
</a>
|
||||||
|
|
||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export default function LanguageDropdown() {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
@ -20,8 +24,8 @@ export default function LanguageDropdown() {
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const changeLanguage = (code: LanguageCode) => {
|
const changeLanguage = (code: LanguageCode) => {
|
||||||
i18n.changeLanguage(code);
|
|
||||||
localStorage.setItem('language', code);
|
localStorage.setItem('language', code);
|
||||||
|
void changeAppLanguage(code);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import type { Page } from './Header';
|
import type { Page } from './Header';
|
||||||
import { PAGE_PATHS } from './Header';
|
import { PAGE_PATHS } from './Header';
|
||||||
import type { AuthUser } from '../../hooks/useAuth';
|
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 { DownloadIcon } from './icons/DownloadIcon';
|
||||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||||
import { CheckIcon } from './icons/CheckIcon';
|
import { CheckIcon } from './icons/CheckIcon';
|
||||||
|
|
@ -161,8 +161,8 @@ export default function MobileMenu({
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
i18n.changeLanguage(lang.code);
|
|
||||||
localStorage.setItem('language', 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 ${
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
|
||||||
i18n.language === lang.code
|
i18n.language === lang.code
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ interface UseMapDataOptions {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
viewFeature: string | null;
|
viewFeature: string | null;
|
||||||
activeFeature: string | null;
|
activeFeature: string | null;
|
||||||
|
pinnedFeature: string | null;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
/** Share-link code from the URL; appended to data fetches so the backend
|
/** Share-link code from the URL; appended to data fetches so the backend
|
||||||
* grants bbox-scoped access for unlicensed recipients. */
|
* grants bbox-scoped access for unlicensed recipients. */
|
||||||
|
|
@ -50,6 +51,7 @@ export function useMapData({
|
||||||
features,
|
features,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
|
pinnedFeature,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
shareCode,
|
shareCode,
|
||||||
}: UseMapDataOptions) {
|
}: UseMapDataOptions) {
|
||||||
|
|
@ -83,6 +85,10 @@ export function useMapData({
|
||||||
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
|
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
|
||||||
[viewFeature]
|
[viewFeature]
|
||||||
);
|
);
|
||||||
|
const pinnedDataViewFeature = useMemo(
|
||||||
|
() => (pinnedFeature ? (getSchoolBackendFeatureName(pinnedFeature) ?? pinnedFeature) : null),
|
||||||
|
[pinnedFeature]
|
||||||
|
);
|
||||||
|
|
||||||
// Determine if the current viewFeature is an enum (for enum_dist param)
|
// Determine if the current viewFeature is an enum (for enum_dist param)
|
||||||
const viewFeatureIsEnum = useMemo(
|
const viewFeatureIsEnum = useMemo(
|
||||||
|
|
@ -95,6 +101,7 @@ export function useMapData({
|
||||||
(): string => buildFilterString(filters, features),
|
(): string => buildFilterString(filters, features),
|
||||||
[filters, features]
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
|
||||||
|
|
||||||
// Build the travel param string from entries with destinations.
|
// Build the travel param string from entries with destinations.
|
||||||
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
|
// 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 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
|
// Keep activeFeatureRef in sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -219,12 +257,11 @@ export function useMapData({
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const requestKey = dataRequestKey;
|
||||||
const filtersStr = buildFilterParam();
|
|
||||||
|
|
||||||
if (usePostcodeView) {
|
if (usePostcodeView) {
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsParam });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersParam) params.set('filters', filtersParam);
|
||||||
params.set(
|
params.set(
|
||||||
'fields',
|
'fields',
|
||||||
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||||||
|
|
@ -254,12 +291,13 @@ export function useMapData({
|
||||||
const json: { features: PostcodeFeature[] } = await res.json();
|
const json: { features: PostcodeFeature[] } = await res.json();
|
||||||
setPostcodeData(json.features);
|
setPostcodeData(json.features);
|
||||||
setRawData([]);
|
setRawData([]);
|
||||||
|
setLoadedDataKey(requestKey);
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
resolution: resolution.toString(),
|
resolution: resolution.toString(),
|
||||||
bounds: boundsStr,
|
bounds: boundsParam,
|
||||||
});
|
});
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersParam) params.set('filters', filtersParam);
|
||||||
params.set(
|
params.set(
|
||||||
'fields',
|
'fields',
|
||||||
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
|
||||||
|
|
@ -289,6 +327,7 @@ export function useMapData({
|
||||||
const json: ApiResponse = await res.json();
|
const json: ApiResponse = await res.json();
|
||||||
setRawData(json.features);
|
setRawData(json.features);
|
||||||
setPostcodeData([]);
|
setPostcodeData([]);
|
||||||
|
setLoadedDataKey(requestKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear drag data when committed fetch completes and we're not mid-drag
|
// Clear drag data when committed fetch completes and we're not mid-drag
|
||||||
|
|
@ -315,7 +354,9 @@ export function useMapData({
|
||||||
resolution,
|
resolution,
|
||||||
bounds,
|
bounds,
|
||||||
filters,
|
filters,
|
||||||
buildFilterParam,
|
filtersParam,
|
||||||
|
boundsParam,
|
||||||
|
dataRequestKey,
|
||||||
dataViewFeature,
|
dataViewFeature,
|
||||||
viewFeatureIsEnum,
|
viewFeatureIsEnum,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
|
|
@ -377,8 +418,8 @@ export function useMapData({
|
||||||
];
|
];
|
||||||
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||||||
|
|
||||||
// Color range for the legend and hex coloring
|
// Live color range for the legend and hex coloring.
|
||||||
const colorRange = useMemo((): [number, number] | null => {
|
const liveColorRange = useMemo((): [number, number] | null => {
|
||||||
if (!dataViewFeature) return null;
|
if (!dataViewFeature) return null;
|
||||||
|
|
||||||
// Travel time keys: use dataRange directly (no FeatureMeta)
|
// Travel time keys: use dataRange directly (no FeatureMeta)
|
||||||
|
|
@ -396,6 +437,61 @@ export function useMapData({
|
||||||
return null;
|
return null;
|
||||||
}, [dataViewFeature, features, dataRange]);
|
}, [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(
|
const handleViewChange = useCallback(
|
||||||
({
|
({
|
||||||
resolution: newRes,
|
resolution: newRes,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Step, CallBackProps } from 'react-joyride';
|
import type { Step, CallBackProps } from 'react-joyride';
|
||||||
import { ACTIONS, EVENTS, STATUS } from 'react-joyride';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'tutorial_completed';
|
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) {
|
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -67,12 +70,12 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
||||||
const handleCallback = useCallback((data: CallBackProps) => {
|
const handleCallback = useCallback((data: CallBackProps) => {
|
||||||
const { status, action, type } = data;
|
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');
|
localStorage.setItem(STORAGE_KEY, '1');
|
||||||
setRun(false);
|
setRun(false);
|
||||||
}
|
}
|
||||||
// Also stop if user closes a tooltip via the X button
|
// 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');
|
localStorage.setItem(STORAGE_KEY, '1');
|
||||||
setRun(false);
|
setRun(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -332,8 +332,8 @@ const en = {
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroEyebrow: 'For buyers asking “where should I even look?”',
|
heroEyebrow: 'For buyers asking “where should I even look?”',
|
||||||
heroTitle1: 'Find the postcodes',
|
heroTitle1: 'Find the postcodes that',
|
||||||
heroTitle2: 'that fit your life',
|
heroTitle2: 'fit your life',
|
||||||
heroTitle3: 'Not just the areas you already know.',
|
heroTitle3: 'Not just the areas you already know.',
|
||||||
heroSubtitle:
|
heroSubtitle:
|
||||||
'From London boroughs to commuter towns and regional cities, England has too many places to research one by one.',
|
'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 England’s postcodes and reveals the places that actually fit, including areas you would never have typed into a listing portal.',
|
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans England’s postcodes and reveals the places that actually fit, including areas you would never have typed into a listing portal.',
|
||||||
exploreTheMap: 'Find my matching postcodes',
|
exploreTheMap: 'Find my matching postcodes',
|
||||||
seeTheDifference: 'See how it works',
|
seeTheDifference: 'See how it works',
|
||||||
showcaseHeader: 'Product showcase',
|
showcaseHeader: 'How it works',
|
||||||
showcaseContext: 'How Perfect Postcode works',
|
showcaseContext: 'How Perfect Postcode works',
|
||||||
showcaseStep1Tab: 'Filter',
|
showcaseStep1Tab: 'Filter',
|
||||||
showcaseStep1Title: 'Turn vague needs into a tight search',
|
showcaseStep1Title: 'Turn vague needs into a tight search',
|
||||||
|
|
@ -362,8 +362,8 @@ const en = {
|
||||||
showcaseStep3Title: 'Inspect why a postcode made the cut',
|
showcaseStep3Title: 'Inspect why a postcode made the cut',
|
||||||
showcaseStep3Body:
|
showcaseStep3Body:
|
||||||
'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
|
'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',
|
showcaseStep3HeaderArea: 'Your perfect postcode',
|
||||||
showcaseStep3HeaderFit: 'Strong fit · 7/8',
|
showcaseStep3HeaderFit: 'Neighbourhood evidence',
|
||||||
showcaseStep3Stat1Label: 'Sold price trend',
|
showcaseStep3Stat1Label: 'Sold price trend',
|
||||||
showcaseStep3Stat2Label: 'Crime rate',
|
showcaseStep3Stat2Label: 'Crime rate',
|
||||||
showcaseStep3Stat2Value: 'Below borough avg.',
|
showcaseStep3Stat2Value: 'Below borough avg.',
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/* Aurora gradient animation for pricing hero */
|
||||||
@keyframes aurora-1 {
|
@keyframes aurora-1 {
|
||||||
0%,
|
0%,
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,7 @@ async fn fetch_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds>
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let json: Value = resp.json().await.ok()?;
|
let json: Value = resp.json().await.ok()?;
|
||||||
let params = json["items"]
|
let params = json["items"].as_array()?.first()?.get("params")?.as_str()?;
|
||||||
.as_array()?
|
|
||||||
.first()?
|
|
||||||
.get("params")?
|
|
||||||
.as_str()?;
|
|
||||||
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
|
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,16 +298,12 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let poi_category_groups = poi_data.category_groups()?;
|
let poi_category_groups = poi_data.category_groups()?;
|
||||||
|
|
||||||
// Read index.html at startup for crawler OG injection (only when --dist is provided)
|
let is_dev = if cli.dist.is_some() {
|
||||||
let index_html = if let Some(ref dist) = cli.dist {
|
info!("Static frontend serving enabled");
|
||||||
let index_path = dist.join("index.html");
|
false
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
info!("No --dist provided; static serving and OG injection disabled");
|
info!("No --dist provided; static serving disabled");
|
||||||
None
|
true
|
||||||
};
|
};
|
||||||
|
|
||||||
let http_client = reqwest::Client::builder()
|
let http_client = reqwest::Client::builder()
|
||||||
|
|
@ -406,8 +402,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
features_response,
|
features_response,
|
||||||
screenshot_url: cli.screenshot_url,
|
screenshot_url: cli.screenshot_url,
|
||||||
public_url: cli.public_url,
|
public_url: cli.public_url,
|
||||||
is_dev: index_html.is_none(),
|
is_dev,
|
||||||
index_html,
|
|
||||||
http_client,
|
http_client,
|
||||||
pocketbase_url: cli.pocketbase_url,
|
pocketbase_url: cli.pocketbase_url,
|
||||||
pocketbase_admin_email: cli.pocketbase_admin_email,
|
pocketbase_admin_email: cli.pocketbase_admin_email,
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,6 @@ pub struct AppState {
|
||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
/// True when --dist is not provided (no static serving, relaxed auth checks)
|
/// True when --dist is not provided (no static serving, relaxed auth checks)
|
||||||
pub is_dev: bool,
|
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
|
/// Shared HTTP client for proxying to the screenshot service and PocketBase
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue