lgtm
This commit is contained in:
parent
084117cea8
commit
a8de0a614d
36 changed files with 1329 additions and 522 deletions
|
|
@ -38,7 +38,7 @@ services:
|
|||
SCREENSHOT_URL: http://screenshot:8002
|
||||
GEMINI_API_KEY: AIzaSyC2mQDcEwILHM3uOE2C-lxUQbQrKTX9Xi4
|
||||
GEMINI_MODEL: gemini-3-flash-preview
|
||||
PUBLIC_URL: https://perfect-postcodes.co.uk
|
||||
PUBLIC_URL: http://localhost:3001
|
||||
GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY"
|
||||
STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3
|
||||
STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0
|
||||
|
|
|
|||
|
|
@ -81,9 +81,6 @@ const SAME_AS_EN_VALUE_ALLOWLIST = new Set([
|
|||
const FORBIDDEN_VISIBLE_STRINGS = [
|
||||
['without this filter', 'filters.filtersOut'],
|
||||
['Connecting to server...', 'common.connectingToServer'],
|
||||
['Property saved!', 'toasts.propertySaved'],
|
||||
['View saved', 'toasts.viewSaved'],
|
||||
["Don't show again", 'toasts.dontShowAgain'],
|
||||
['Close pane', 'common.closePane'],
|
||||
['Points of interest', 'poiPane.pointsOfInterest'],
|
||||
['No data', 'common.noData'],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||
import {
|
||||
apiUrl,
|
||||
authHeaders,
|
||||
assertOk,
|
||||
shortenUrl,
|
||||
prewarmScreenshot,
|
||||
paramsWithLanguage,
|
||||
} from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { formatRelativeTime, formatNumber } from '../../lib/format';
|
||||
import { summarizeParams } from '../../lib/url-state';
|
||||
|
|
@ -193,7 +200,7 @@ function SavedSearchesTab({
|
|||
onUpdateName: (id: string, name: string) => void;
|
||||
onOpen: (params: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
|
@ -213,18 +220,21 @@ function SavedSearchesTab({
|
|||
|
||||
const handleShare = useCallback(
|
||||
async (params: string, id: string) => {
|
||||
prewarmScreenshot(params);
|
||||
prewarmScreenshot(params, i18n.language);
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
const shortUrl = await shortenUrl(params, i18n.language);
|
||||
doCopy(shortUrl, id);
|
||||
} catch {
|
||||
doCopy(`${window.location.origin}/dashboard?${params}`, id);
|
||||
doCopy(
|
||||
`${window.location.origin}/dashboard?${paramsWithLanguage(params, i18n.language)}`,
|
||||
id
|
||||
);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
},
|
||||
[doCopy]
|
||||
[doCopy, i18n.language]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -354,6 +364,36 @@ export function SavedPage({
|
|||
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(
|
||||
window.location.hash === '#shared-links' ? 'shared-links' : 'searches'
|
||||
);
|
||||
const [shareLinks, setShareLinks] = useState<ShareLinkListItem[]>([]);
|
||||
const [shareLinksLoading, setShareLinksLoading] = useState(false);
|
||||
const [shareLinksError, setShareLinksError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setShareLinksLoading(true);
|
||||
setShareLinksError(null);
|
||||
|
||||
fetch(apiUrl('share-links'), authHeaders())
|
||||
.then((res) => {
|
||||
assertOk(res, 'Fetch share links');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { links: ShareLinkListItem[] }) => {
|
||||
if (!cancelled) setShareLinks(data.links);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setShareLinksLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabClass = (tab: string) =>
|
||||
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
|
|
@ -375,6 +415,11 @@ export function SavedPage({
|
|||
</button>
|
||||
<button className={tabClass('shared-links')} onClick={() => setActiveTab('shared-links')}>
|
||||
{t('accountPage.shareLinksTitle')}
|
||||
{shareLinks.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
|
||||
{shareLinks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -388,7 +433,12 @@ export function SavedPage({
|
|||
onOpen={onOpenSearch}
|
||||
/>
|
||||
) : (
|
||||
<ShareLinksSection showTitle={false} />
|
||||
<ShareLinksSection
|
||||
links={shareLinks}
|
||||
loading={shareLinksLoading}
|
||||
error={shareLinksError}
|
||||
showTitle={false}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
@ -505,40 +555,20 @@ function InviteTable({
|
|||
);
|
||||
}
|
||||
|
||||
function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) {
|
||||
function ShareLinksSection({
|
||||
links,
|
||||
loading,
|
||||
error,
|
||||
showTitle = true,
|
||||
}: {
|
||||
links: ShareLinkListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [links, setLinks] = useState<ShareLinkListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(apiUrl('share-links'), authHeaders())
|
||||
.then((res) => {
|
||||
assertOk(res, 'Fetch share links');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { links: ShareLinkListItem[] }) => {
|
||||
if (!cancelled) setLinks(data.links);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch share links');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = (url: string, code: string) => {
|
||||
copyToClipboard(url, () => {
|
||||
setCopiedCode(code);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
FilterExclusion,
|
||||
HexagonStatsResponse,
|
||||
} from '../../types';
|
||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import {
|
||||
formatValue,
|
||||
|
|
@ -61,6 +66,21 @@ function normalizePercentageSegments<T extends { value: number }>(segments: T[])
|
|||
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
|
||||
}
|
||||
|
||||
function filterValueFormat(feature?: FeatureMeta) {
|
||||
if (!feature) return undefined;
|
||||
return {
|
||||
prefix: feature.prefix,
|
||||
suffix: feature.suffix,
|
||||
raw: feature.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function formatExclusionPercent(value: number): string {
|
||||
const percent = value * 100;
|
||||
if (percent < 10) return `${percent.toFixed(1)}%`;
|
||||
return `${Math.round(percent)}%`;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
stats,
|
||||
globalFeatures,
|
||||
|
|
@ -103,6 +123,36 @@ export default function AreaPane({
|
|||
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
||||
[globalFeatures]
|
||||
);
|
||||
const travelEntryByField = useMemo(() => {
|
||||
const map = new Map<string, TravelTimeEntry>();
|
||||
for (const entry of travelTimeEntries ?? []) {
|
||||
map.set(travelFieldKey(entry), entry);
|
||||
}
|
||||
return map;
|
||||
}, [travelTimeEntries]);
|
||||
const filterExclusions = stats?.filter_exclusions ?? [];
|
||||
|
||||
const getExclusionLabel = (exclusion: FilterExclusion) => {
|
||||
const travelEntry = travelEntryByField.get(exclusion.name);
|
||||
if (travelEntry) return t('areaPane.travelTo', { destination: travelEntry.label });
|
||||
return ts(exclusion.name);
|
||||
};
|
||||
|
||||
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
|
||||
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.min')}`;
|
||||
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
|
||||
};
|
||||
|
||||
const getExclusionAdjustment = (exclusion: FilterExclusion) => {
|
||||
if (exclusion.direction === 'allow_value') {
|
||||
return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') });
|
||||
}
|
||||
if (exclusion.value == null) return '';
|
||||
const value = formatExclusionValue(exclusion, exclusion.value);
|
||||
return exclusion.direction === 'lower_min'
|
||||
? t('areaPane.lowerMinTo', { value })
|
||||
: t('areaPane.raiseMaxTo', { value });
|
||||
};
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
|
|
@ -205,6 +255,31 @@ export default function AreaPane({
|
|||
>
|
||||
{t('areaPane.showAllStats')}
|
||||
</button>
|
||||
{filterExclusions.length > 0 && (
|
||||
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
|
||||
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
|
||||
<ol className="mt-1.5 space-y-1.5">
|
||||
{filterExclusions.map((exclusion) => (
|
||||
<li
|
||||
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
||||
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{getExclusionLabel(exclusion)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-amber-700 dark:text-amber-200">
|
||||
{formatExclusionPercent(exclusion.relative_difference)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
||||
{getExclusionAdjustment(exclusion)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canViewProperties && (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
buildRightmoveExactPostcodeRedirectUrl,
|
||||
buildPropertySearchUrls,
|
||||
H3_RADIUS_MILES,
|
||||
POSTCODE_RADIUS_MILES,
|
||||
type HexagonLocation,
|
||||
} from '../../lib/external-search';
|
||||
import outcodeIds from '../../lib/rightmove-outcodes.json';
|
||||
|
|
@ -36,8 +37,10 @@ export default function ExternalSearchLinks({
|
|||
if (!location.isPostcode || !location.postcode) return urls.rightmove;
|
||||
return buildRightmoveExactPostcodeRedirectUrl(location.postcode, urls.rightmove);
|
||||
}, [location.isPostcode, location.postcode, urls?.rightmove]);
|
||||
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
|
||||
const radiusMiles = location.isPostcode
|
||||
? POSTCODE_RADIUS_MILES
|
||||
: (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = `${radiusMiles}mi radius`;
|
||||
|
||||
if (!urls) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { memo, useState, useMemo, useRef, useCallback, useEffect, type FormEvent
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { findActiveFilterElement } from '../../lib/active-filter-scroll';
|
||||
import { buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
|
|
@ -486,9 +487,7 @@ export default memo(function Filters({
|
|||
const name = pendingScrollRef.current;
|
||||
if (!name) return;
|
||||
pendingScrollRef.current = null;
|
||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
||||
`[data-filter-name="${CSS.escape(name)}"]`
|
||||
);
|
||||
const el = findActiveFilterElement(scrollRef.current, name);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ interface MapProps {
|
|||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
hideLocationSearch?: boolean;
|
||||
hideTopCardsWhenNarrow?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
densityLabel?: string;
|
||||
totalCount?: number;
|
||||
|
|
@ -82,6 +83,17 @@ interface Dimensions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
const DESKTOP_TOP_CARD_WIDTH = 300;
|
||||
const DESKTOP_TOP_CARD_GAP = 8;
|
||||
const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24;
|
||||
const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH =
|
||||
DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
|
||||
const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH =
|
||||
DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
|
||||
const DESKTOP_TOP_CARD_CLASS = 'w-[300px]';
|
||||
const DESKTOP_LOCATION_SEARCH_INPUT_CLASS =
|
||||
'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500';
|
||||
|
||||
type MapContainerStyle = CSSProperties & {
|
||||
'--map-mobile-bottom-inset'?: string;
|
||||
};
|
||||
|
|
@ -208,6 +220,23 @@ function getRenderedViewState(map: MapRef | null): ViewState | null {
|
|||
};
|
||||
}
|
||||
|
||||
function getRenderedVisibleCenter(
|
||||
map: MapRef | null,
|
||||
dimensions: Dimensions,
|
||||
bottomScreenInset: number
|
||||
): Pick<ViewState, 'latitude' | 'longitude'> | null {
|
||||
if (!map || dimensions.width <= 0 || dimensions.height <= 0) return null;
|
||||
|
||||
const visibleBottomInset = clamp(bottomScreenInset, 0, dimensions.height);
|
||||
const visibleCenterY = (dimensions.height - visibleBottomInset) / 2;
|
||||
const center = map.unproject([dimensions.width / 2, visibleCenterY]);
|
||||
|
||||
return {
|
||||
longitude: center.lng,
|
||||
latitude: center.lat,
|
||||
};
|
||||
}
|
||||
|
||||
function DeckOverlay({
|
||||
layers,
|
||||
getTooltip,
|
||||
|
|
@ -260,6 +289,7 @@ export default memo(function Map({
|
|||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
hideLocationSearch = false,
|
||||
hideTopCardsWhenNarrow = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
densityLabel: densityLabelProp,
|
||||
totalCount: totalCountProp,
|
||||
|
|
@ -319,6 +349,9 @@ export default memo(function Map({
|
|||
const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset);
|
||||
const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight);
|
||||
const resolution = zoomToResolution(renderedViewState.zoom);
|
||||
const renderedVisibleCenter =
|
||||
getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
|
||||
renderedViewState;
|
||||
|
||||
onViewChange({
|
||||
resolution,
|
||||
|
|
@ -326,6 +359,8 @@ export default memo(function Map({
|
|||
zoom: renderedViewState.zoom,
|
||||
latitude: renderedViewState.latitude,
|
||||
longitude: renderedViewState.longitude,
|
||||
visibleLatitude: renderedVisibleCenter.latitude,
|
||||
visibleLongitude: renderedVisibleCenter.longitude,
|
||||
});
|
||||
};
|
||||
frame = window.requestAnimationFrame(emit);
|
||||
|
|
@ -389,6 +424,19 @@ export default memo(function Map({
|
|||
() => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
|
||||
[bottomScreenInset]
|
||||
);
|
||||
const hideDesktopTopCardsForWidth =
|
||||
hideTopCardsWhenNarrow &&
|
||||
dimensions.width > 0 &&
|
||||
dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH;
|
||||
const stackDesktopTopCards =
|
||||
hideTopCardsWhenNarrow &&
|
||||
dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH &&
|
||||
dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH;
|
||||
const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth;
|
||||
const showLegend = !hideLegend && !hideDesktopTopCardsForWidth;
|
||||
const desktopTopCardsLayoutClass = stackDesktopTopCards
|
||||
? 'flex-col items-start'
|
||||
: 'items-start justify-between';
|
||||
|
||||
const {
|
||||
layers,
|
||||
|
|
@ -452,11 +500,11 @@ export default memo(function Map({
|
|||
<div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
|
||||
{/* Center: Logo card with hero text */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10">
|
||||
<LogoIcon className="w-24 h-24 text-teal-400" />
|
||||
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10 max-w-[1040px]">
|
||||
<LogoIcon className="w-24 h-24 shrink-0 text-teal-400" />
|
||||
<span
|
||||
className="font-bold text-white whitespace-nowrap"
|
||||
style={{ fontSize: '5rem' }}
|
||||
className="font-bold text-white/50"
|
||||
style={{ fontSize: '4rem', lineHeight: 1.05, maxWidth: '760px' }}
|
||||
>
|
||||
{t('map.ogTitle')}
|
||||
</span>
|
||||
|
|
@ -494,74 +542,83 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-3 left-3 right-3 z-20 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
|
||||
{!hideLocationSearch && (
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
/>
|
||||
)}
|
||||
{!hideLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={t('travel.travelTime', {
|
||||
mode: modes.label(
|
||||
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
||||
),
|
||||
})}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
/>
|
||||
) : colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
||||
: ts(colorFeatureMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
suffix={colorFeatureMeta.suffix}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel={densityLabel}
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={
|
||||
totalCountProp ??
|
||||
(usePostcodeView ? postcodeCountRange.total : countRange.total)
|
||||
}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
{(showLocationSearch || showLegend) && (
|
||||
<div
|
||||
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${desktopTopCardsLayoutClass}`}
|
||||
>
|
||||
{showLocationSearch && (
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={t('travel.travelTime', {
|
||||
mode: modes.label(
|
||||
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
||||
),
|
||||
})}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
) : colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
||||
: ts(colorFeatureMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
suffix={colorFeatureMeta.suffix}
|
||||
raw={colorFeatureMeta.raw}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel={densityLabel}
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={
|
||||
totalCountProp ??
|
||||
(usePostcodeView ? postcodeCountRange.total : countRange.total)
|
||||
}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function FeatureActions({
|
|||
const mapLabel = isPinned ? t('filters.clearColourMap') : t('filters.colourMap');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<div className="flex items-center gap-2 md:gap-0.5 shrink-0">
|
||||
{feature.detail &&
|
||||
onShowInfo &&
|
||||
(showText ? (
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default function UpgradeModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||
{/* Close button */}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
|
||||
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import {
|
||||
apiUrl,
|
||||
buildFilterString,
|
||||
logNonAbortError,
|
||||
authHeaders,
|
||||
isAbortError,
|
||||
} from '../lib/api';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam } from '../lib/travel-params';
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
|
|
@ -18,32 +25,34 @@ export function useFilterCounts(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
bounds: Bounds | null,
|
||||
travelTimeEntries: TravelTimeEntry[]
|
||||
travelTimeEntries: TravelTimeEntry[],
|
||||
shareCode?: string
|
||||
) {
|
||||
const [impacts, setImpacts] = useState<Record<string, number>>({});
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Build the travel param string (same format as useMapData)
|
||||
const travelParam = useMemo(() => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let seg = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) seg += ':best';
|
||||
if (entry.timeRange) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
segments.push(seg);
|
||||
}
|
||||
return segments.join('|');
|
||||
return buildTravelParam(travelTimeEntries);
|
||||
}, [travelTimeEntries]);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!bounds) {
|
||||
abortRef.current?.abort();
|
||||
setImpacts({});
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCount = Object.keys(filters).length;
|
||||
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
|
||||
if (filterCount === 0 && !hasTravelFilters) {
|
||||
abortRef.current?.abort();
|
||||
setImpacts({});
|
||||
setTotal(0);
|
||||
return;
|
||||
|
|
@ -61,6 +70,7 @@ export function useFilterCounts(
|
|||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
const res = await fetch(
|
||||
apiUrl('filter-counts', params),
|
||||
|
|
@ -68,6 +78,7 @@ export function useFilterCounts(
|
|||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: FilterCountsResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setImpacts(json.impacts);
|
||||
setTotal(json.total);
|
||||
} catch (err) {
|
||||
|
|
@ -79,8 +90,9 @@ export function useFilterCounts(
|
|||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [filters, features, bounds, travelParam, travelTimeEntries]);
|
||||
}, [filters, features, bounds, travelParam, travelTimeEntries, shareCode]);
|
||||
|
||||
// Cancel in-flight on unmount
|
||||
useEffect(() => {
|
||||
|
|
|
|||
58
frontend/src/hooks/useFilters.test.ts
Normal file
58
frontend/src/hooks/useFilters.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useFilters } from './useFilters';
|
||||
import type { FeatureMeta } from '../types';
|
||||
|
||||
vi.mock('../lib/analytics', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useFilters', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
|
||||
it('activates slider preview on pointer down without committing unchanged clicks', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: { price: [0, 100] },
|
||||
features,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('price');
|
||||
expect(result.current.viewSource).toBe('drag');
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([0, 100]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
result.current.handleDragChange([10, 90]);
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('price');
|
||||
expect(result.current.viewSource).toBe('drag');
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([10, 90]);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,8 @@ import { act, renderHook } from '@testing-library/react';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMapData } from './useMapData';
|
||||
import type { ApiResponse, Bounds, ViewChangeParams } from '../types';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import type { ApiResponse, Bounds, FeatureMeta, ViewChangeParams } from '../types';
|
||||
|
||||
vi.mock('../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
|
|
@ -32,6 +33,7 @@ async function flushPromises() {
|
|||
|
||||
describe('useMapData', () => {
|
||||
const requests: Array<{ url: string; resolve: (response: Response) => void }> = [];
|
||||
const noTravelTimeEntries: TravelTimeEntry[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -59,7 +61,7 @@ describe('useMapData', () => {
|
|||
viewFeature: null,
|
||||
activeFeature: null,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: [],
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -93,4 +95,264 @@ describe('useMapData', () => {
|
|||
|
||||
expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]);
|
||||
});
|
||||
|
||||
it('stores the visible map center separately from the rendered map center', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMapData({
|
||||
filters: {},
|
||||
features: [],
|
||||
viewFeature: null,
|
||||
activeFeature: null,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange({
|
||||
...viewChange({ south: 1, west: 1, north: 2, east: 2 }),
|
||||
latitude: 51.5,
|
||||
longitude: -0.1,
|
||||
visibleLatitude: 51.6,
|
||||
visibleLongitude: -0.2,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.currentView).toEqual({ latitude: 51.5, longitude: -0.1, zoom: 10 });
|
||||
expect(result.current.currentVisibleView).toEqual({
|
||||
latitude: 51.6,
|
||||
longitude: -0.2,
|
||||
zoom: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the colour range to drag preview data while a slider is active', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
const filters = { price: [20, 80] as [number, number] };
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeFeature }: { activeFeature: string | null }) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{ initialProps: { activeFeature: null as string | null } }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange(viewChange(bounds));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
requests[0].resolve(
|
||||
response([
|
||||
{ h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
|
||||
|
||||
await act(async () => {
|
||||
rerender({ activeFeature: 'price' });
|
||||
await flushPromises();
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
requests[1].resolve(
|
||||
response([
|
||||
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([
|
||||
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
]);
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||
});
|
||||
|
||||
it('does not reuse cached drag preview data when the drag request changes', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
const committedData = [
|
||||
{ h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
|
||||
];
|
||||
const previewData = [
|
||||
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
filters,
|
||||
activeFeature,
|
||||
}: {
|
||||
filters: Record<string, [number, number]>;
|
||||
activeFeature: string | null;
|
||||
}) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
filters: { price: [20, 80] as [number, number] },
|
||||
activeFeature: null as string | null,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange(viewChange(bounds));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
await act(async () => {
|
||||
requests[0].resolve(response(committedData));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [20, 80] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
requests[1].resolve(response(previewData));
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.data).toEqual(previewData);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [10, 90] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(committedData);
|
||||
});
|
||||
|
||||
it('resets a pinned colour range after a slider commits a new filter', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
filters,
|
||||
activeFeature,
|
||||
}: {
|
||||
filters: Record<string, [number, number]>;
|
||||
activeFeature: string | null;
|
||||
}) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: 'price',
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
filters: { price: [20, 80] as [number, number] },
|
||||
activeFeature: null as string | null,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange(viewChange(bounds));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
await act(async () => {
|
||||
requests[0].resolve(
|
||||
response([
|
||||
{ h3: 'old-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'old-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [20, 80] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [10, 90] },
|
||||
activeFeature: null,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
const committedRequest = requests[requests.length - 1];
|
||||
await act(async () => {
|
||||
committedRequest.resolve(
|
||||
response([
|
||||
{ h3: 'new-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'new-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
|||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="map-anchor"]',
|
||||
target: '[data-tutorial="map"]',
|
||||
title: t('tutorial.step3Title'),
|
||||
content: t('tutorial.step3Content'),
|
||||
placement: 'top' as const,
|
||||
|
|
|
|||
|
|
@ -34,17 +34,31 @@ function getStoredLanguage(): LanguageCode | null {
|
|||
}
|
||||
}
|
||||
|
||||
function getUrlLanguage(): LanguageCode | null {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const value = new URLSearchParams(window.location.search).get('lang');
|
||||
return value ? toSupportedLanguage(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserLanguages(): readonly string[] {
|
||||
if (typeof navigator === 'undefined') return [];
|
||||
return navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||
}
|
||||
|
||||
function detectLanguage(): LanguageCode {
|
||||
// 1. Explicit user choice (persisted from the language dropdown)
|
||||
// 1. Explicit URL language, used by generated screenshot/OG image URLs.
|
||||
const urlLanguage = getUrlLanguage();
|
||||
if (urlLanguage) return urlLanguage;
|
||||
|
||||
// 2. Explicit user choice (persisted from the language dropdown)
|
||||
const stored = getStoredLanguage();
|
||||
if (stored) return stored;
|
||||
|
||||
// 2. Browser preference (navigator.languages falls back to navigator.language)
|
||||
// 3. Browser preference (navigator.languages falls back to navigator.language)
|
||||
for (const tag of getBrowserLanguages()) {
|
||||
const language = toSupportedLanguage(tag);
|
||||
if (language) return language;
|
||||
|
|
|
|||
|
|
@ -80,13 +80,6 @@ const de: Translations = {
|
|||
home: 'Startseite',
|
||||
},
|
||||
|
||||
// ── Toasts ─────────────────────────────────────────
|
||||
toasts: {
|
||||
propertySaved: 'Immobilie gespeichert!',
|
||||
viewSaved: 'Gespeicherte ansehen',
|
||||
dontShowAgain: 'Nicht erneut anzeigen',
|
||||
},
|
||||
|
||||
// ── SEO Page Chrome ────────────────────────────────
|
||||
seo: {
|
||||
breadcrumb: 'Breadcrumb',
|
||||
|
|
@ -563,8 +556,8 @@ const de: Translations = {
|
|||
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
|
||||
'Marketing-, Methodik-, Leitfaden- und Supportseiten sind indexierbar. Dashboard, Konto, gespeicherte Suchen, Einladungen und Einladungsrouten werden gegebenenfalls als „noindex“ markiert oder für den Crawler-Zugriff blockiert.',
|
||||
'Saved search data is account-scoped': 'Gespeicherte Suchdaten sind kontobezogen',
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'Gespeicherte Suchen und Eigenschaften sind für die Verwendung durch angemeldete Benutzer vorgesehen. Sie sind nicht in der öffentlichen Sitemap enthalten und sollten nicht als öffentlicher Inhalt gecrawlt werden können.',
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'Gespeicherte Suchen und geteilte Links sind für angemeldete Benutzer vorgesehen. Sie sind nicht in der öffentlichen Sitemap enthalten und sollten nicht als öffentlicher Inhalt gecrawlt werden können.',
|
||||
'Search measurement without exposing private data':
|
||||
'Suchmessung ohne Offenlegung privater Daten',
|
||||
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
|
||||
|
|
@ -797,8 +790,6 @@ const de: Translations = {
|
|||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Unbekannte Adresse',
|
||||
unsaveProperty: 'Immobilie nicht mehr merken',
|
||||
saveProperty: 'Immobilie merken',
|
||||
estValue: 'Gesch. Wert:',
|
||||
type: 'Typ:',
|
||||
builtForm: 'Bauweise:',
|
||||
|
|
@ -838,6 +829,11 @@ const de: Translations = {
|
|||
showAllStatsFallback:
|
||||
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
|
||||
showAllStats: 'Alle Immobilien anzeigen',
|
||||
closestBlockingFilters: 'Nächste Filter, die dieses Gebiet ausschließen',
|
||||
lowerMinTo: 'Minimum auf {{value}} senken',
|
||||
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
|
||||
allowCategory: '{{value}} zulassen',
|
||||
travelTo: 'Fahrt zu {{destination}}',
|
||||
viewProperties: '{{count}} Immobilien ansehen',
|
||||
viewPropertiesShort: 'Immobilien ansehen',
|
||||
priceHistory: 'Preisentwicklung',
|
||||
|
|
@ -1276,20 +1272,11 @@ const de: Translations = {
|
|||
noSavedSearches: 'Noch keine gespeicherten Suchen',
|
||||
noSavedSearchesDesc:
|
||||
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
|
||||
noSavedProperties: 'Noch keine gespeicherten Immobilien',
|
||||
noSavedPropertiesDesc:
|
||||
'Merke dir Immobilien während du erkundest und erstelle deine Auswahlliste, ohne den Überblick zu verlieren.',
|
||||
openPostcode: 'Postleitzahl öffnen',
|
||||
clickToRename: 'Klicken zum Umbenennen',
|
||||
notesPlaceholder: 'Notiere deine Gedanken...',
|
||||
deleteSearch: 'Suche löschen',
|
||||
deleteSearchConfirm:
|
||||
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
deleteProperty: 'Immobilie löschen',
|
||||
deletePropertyConfirm:
|
||||
'Möchtest du diese gespeicherte Immobilie wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
bed: 'Schlafz.',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1337,12 +1324,6 @@ const de: Translations = {
|
|||
failedToValidate: 'Einladungslink konnte nicht validiert werden',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Entfernt',
|
||||
savedProperty: 'Gespeichert',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'gerade eben',
|
||||
|
|
|
|||
|
|
@ -78,13 +78,6 @@ const en = {
|
|||
home: 'Home',
|
||||
},
|
||||
|
||||
// ── Toasts ─────────────────────────────────────────
|
||||
toasts: {
|
||||
propertySaved: 'Property saved!',
|
||||
viewSaved: 'View saved',
|
||||
dontShowAgain: "Don't show again",
|
||||
},
|
||||
|
||||
// ── SEO Page Chrome ────────────────────────────────
|
||||
seo: {
|
||||
breadcrumb: 'Breadcrumb',
|
||||
|
|
@ -544,8 +537,8 @@ const en = {
|
|||
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
|
||||
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.',
|
||||
'Saved search data is account-scoped': 'Saved search data is account-scoped',
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.',
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.',
|
||||
'Search measurement without exposing private data':
|
||||
'Search measurement without exposing private data',
|
||||
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
|
||||
|
|
@ -772,8 +765,6 @@ const en = {
|
|||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Unknown Address',
|
||||
unsaveProperty: 'Unsave property',
|
||||
saveProperty: 'Save property',
|
||||
estValue: 'Est. value:',
|
||||
type: 'Type:',
|
||||
builtForm: 'Built form:',
|
||||
|
|
@ -811,6 +802,11 @@ const en = {
|
|||
showAllStatsFallback:
|
||||
'Switch to all properties to inspect this area without the active filters.',
|
||||
showAllStats: 'Show all properties',
|
||||
closestBlockingFilters: 'Closest filters excluding this area',
|
||||
lowerMinTo: 'Lower minimum to {{value}}',
|
||||
raiseMaxTo: 'Raise maximum to {{value}}',
|
||||
allowCategory: 'Allow {{value}}',
|
||||
travelTo: 'Travel to {{destination}}',
|
||||
viewProperties: 'View {{count}} Properties',
|
||||
viewPropertiesShort: 'View properties',
|
||||
priceHistory: 'Price History',
|
||||
|
|
@ -1186,7 +1182,7 @@ const en = {
|
|||
// FAQ items — Privacy and Data Protection
|
||||
faqPrivacy1Q: 'Do you store personal data about me?',
|
||||
faqPrivacy1A:
|
||||
'The property and neighbourhood data doesn’t contain your personal details. If you create an account, we store only what’s needed to run the service, such as your email address, access status, newsletter choice, saved searches, saved properties, and payment records handled by Stripe. We handle account data under UK privacy law.',
|
||||
'The property and neighbourhood data doesn’t contain your personal details. If you create an account, we store only what’s needed to run the service, such as your email address, access status, newsletter choice, saved searches, shared links, and payment records handled by Stripe. We handle account data under UK privacy law.',
|
||||
// FAQ items — Why Perfect Postcode
|
||||
faqWhy1Q: 'What does this show that listing portals usually don’t?',
|
||||
faqWhy1A:
|
||||
|
|
@ -1241,19 +1237,10 @@ const en = {
|
|||
noSavedSearches: 'No saved searches yet',
|
||||
noSavedSearchesDesc:
|
||||
'Save your filters and map view so you can pick up exactly where you left off.',
|
||||
noSavedProperties: 'No saved properties yet',
|
||||
noSavedPropertiesDesc:
|
||||
'Bookmark properties as you explore and build your shortlist without losing track.',
|
||||
openPostcode: 'Open postcode',
|
||||
clickToRename: 'Click to rename',
|
||||
notesPlaceholder: 'Jot down your thoughts...',
|
||||
deleteSearch: 'Delete search',
|
||||
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This can’t be undone.',
|
||||
deleteProperty: 'Delete property',
|
||||
deletePropertyConfirm:
|
||||
'Are you sure you want to delete this saved property? This can’t be undone.',
|
||||
bed: 'bed',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1301,12 +1288,6 @@ const en = {
|
|||
failedToValidate: 'Failed to validate invite link',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Unsave',
|
||||
savedProperty: 'Saved',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'just now',
|
||||
|
|
|
|||
|
|
@ -80,13 +80,6 @@ const fr: Translations = {
|
|||
home: 'Accueil',
|
||||
},
|
||||
|
||||
// ── Toasts ─────────────────────────────────────────
|
||||
toasts: {
|
||||
propertySaved: 'Bien enregistré !',
|
||||
viewSaved: 'Voir l’enregistrement',
|
||||
dontShowAgain: 'Ne plus afficher',
|
||||
},
|
||||
|
||||
// ── SEO Page Chrome ────────────────────────────────
|
||||
seo: {
|
||||
breadcrumb: 'Fil d’Ariane',
|
||||
|
|
@ -566,8 +559,8 @@ const fr: Translations = {
|
|||
"Les pages marketing, méthodologie, guide et support sont indexables. Le tableau de bord, le compte, les recherches enregistrées, les invitations et les itinéraires d'invitation sont marqués comme non indexés ou bloqués pour l'accès du robot, le cas échéant.",
|
||||
'Saved search data is account-scoped':
|
||||
'Les données de recherche enregistrées sont limitées au compte',
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'Les recherches et propriétés enregistrées sont destinées à une utilisation connectée. Ils ne sont pas inclus dans le plan du site public et ne doivent pas être explorables en tant que contenu public.',
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'Les recherches enregistrées et les liens partagés sont destinés à une utilisation connectée. Ils ne sont pas inclus dans le plan du site public et ne doivent pas être explorables en tant que contenu public.',
|
||||
'Search measurement without exposing private data':
|
||||
'Rechercher des mesures sans exposer de données privées',
|
||||
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
|
||||
|
|
@ -802,8 +795,6 @@ const fr: Translations = {
|
|||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Adresse inconnue',
|
||||
unsaveProperty: 'Retirer des favoris',
|
||||
saveProperty: 'Ajouter aux favoris',
|
||||
estValue: 'Valeur estimée :',
|
||||
type: 'Type :',
|
||||
builtForm: 'Forme du bâti :',
|
||||
|
|
@ -843,6 +834,11 @@ const fr: Translations = {
|
|||
showAllStatsFallback:
|
||||
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
|
||||
showAllStats: 'Afficher toutes les propriétés',
|
||||
closestBlockingFilters: 'Filtres les plus proches qui excluent cette zone',
|
||||
lowerMinTo: 'Abaisser le minimum à {{value}}',
|
||||
raiseMaxTo: 'Augmenter le maximum à {{value}}',
|
||||
allowCategory: 'Autoriser {{value}}',
|
||||
travelTo: 'Trajet vers {{destination}}',
|
||||
viewProperties: 'Voir {{count}} propriétés',
|
||||
viewPropertiesShort: 'Voir les propriétés',
|
||||
priceHistory: 'Historique des prix',
|
||||
|
|
@ -1280,20 +1276,11 @@ const fr: Translations = {
|
|||
noSavedSearches: 'Aucune recherche enregistrée',
|
||||
noSavedSearchesDesc:
|
||||
'Enregistrez vos filtres et la vue de la carte pour reprendre exactement là où vous vous étiez arrêté.',
|
||||
noSavedProperties: 'Aucune propriété enregistrée',
|
||||
noSavedPropertiesDesc:
|
||||
'Ajoutez des propriétés en favoris au fil de votre exploration et constituez votre sélection sans rien perdre de vue.',
|
||||
openPostcode: 'Ouvrir le code postal',
|
||||
clickToRename: 'Cliquez pour renommer',
|
||||
notesPlaceholder: 'Notez vos impressions...',
|
||||
deleteSearch: 'Supprimer la recherche',
|
||||
deleteSearchConfirm:
|
||||
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
|
||||
deleteProperty: 'Supprimer la propriété',
|
||||
deletePropertyConfirm:
|
||||
'Êtes-vous sûr de vouloir supprimer cette propriété enregistrée ? Cette action est irréversible.',
|
||||
bed: 'ch.',
|
||||
epc: 'DPE',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1341,12 +1328,6 @@ const fr: Translations = {
|
|||
failedToValidate: 'Échec de la validation du lien d’invitation',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Retirer',
|
||||
savedProperty: 'Enregistré',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'à l’instant',
|
||||
|
|
|
|||
|
|
@ -76,12 +76,6 @@ const hi: Translations = {
|
|||
home: 'होम',
|
||||
},
|
||||
|
||||
toasts: {
|
||||
propertySaved: 'संपत्ति सहेजी गई!',
|
||||
viewSaved: 'सहेजी हुई देखें',
|
||||
dontShowAgain: 'फिर न दिखाएं',
|
||||
},
|
||||
|
||||
seo: {
|
||||
breadcrumb: 'ब्रेडक्रम्ब',
|
||||
reviewDataSources: 'डेटा स्रोत देखें',
|
||||
|
|
@ -542,8 +536,8 @@ const hi: Translations = {
|
|||
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
|
||||
'मार्केटिंग, कार्यप्रणाली, मार्गदर्शिका और सहायता पृष्ठ अनुक्रमित किए जा सकते हैं। डैशबोर्ड, खाता, सहेजी गई खोजें, आमंत्रण और आमंत्रण मार्गों को नोइंडेक्स के रूप में चिह्नित किया जाता है या जहां उपयुक्त हो क्रॉलर पहुंच से अवरुद्ध कर दिया जाता है।',
|
||||
'Saved search data is account-scoped': 'सहेजा गया खोज डेटा खाता-क्षेत्र है',
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'सहेजी गई खोजें और संपत्तियां साइन-इन उपयोग के लिए हैं। वे सार्वजनिक साइटमैप में शामिल नहीं हैं और उन्हें सार्वजनिक सामग्री के रूप में क्रॉल नहीं किया जाना चाहिए।',
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'सहेजी गई खोजें और साझा लिंक साइन-इन उपयोग के लिए हैं। वे सार्वजनिक साइटमैप में शामिल नहीं हैं और उन्हें सार्वजनिक सामग्री के रूप में क्रॉल नहीं किया जाना चाहिए।',
|
||||
'Search measurement without exposing private data': 'निजी डेटा को उजागर किए बिना माप खोजें',
|
||||
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
|
||||
'समग्र विश्लेषण और खोज कंसोल डेटा का उपयोग करके सार्वजनिक पृष्ठों पर एसईओ माप होना चाहिए। निजी क्वेरी पैरामीटर और खाता दृश्य अनुक्रमणीय लैंडिंग पृष्ठ नहीं बनने चाहिए।',
|
||||
|
|
@ -761,8 +755,6 @@ const hi: Translations = {
|
|||
|
||||
propertyCard: {
|
||||
unknownAddress: 'अज्ञात पता',
|
||||
unsaveProperty: 'संपत्ति को असहेजें',
|
||||
saveProperty: 'संपत्ति सहेजें',
|
||||
estValue: 'अनु. मूल्य:',
|
||||
type: 'प्रकार:',
|
||||
builtForm: 'निर्माण रूप:',
|
||||
|
|
@ -800,6 +792,11 @@ const hi: Translations = {
|
|||
showAllStatsFallback:
|
||||
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
|
||||
showAllStats: 'सभी संपत्तियां दिखाएं',
|
||||
closestBlockingFilters: 'इस क्षेत्र को बाहर करने वाले निकटतम फिल्टर',
|
||||
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
|
||||
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
|
||||
allowCategory: '{{value}} की अनुमति दें',
|
||||
travelTo: '{{destination}} तक यात्रा',
|
||||
viewProperties: '{{count}} संपत्तियां देखें',
|
||||
viewPropertiesShort: 'संपत्तियां देखें',
|
||||
priceHistory: 'कीमत इतिहास',
|
||||
|
|
@ -1204,20 +1201,11 @@ const hi: Translations = {
|
|||
noSavedSearches: 'अभी कोई सहेजी गई खोज नहीं',
|
||||
noSavedSearchesDesc:
|
||||
'अपने फिल्टर और मानचित्र दृश्य सहेजें ताकि आप ठीक वहीं से फिर शुरू कर सकें जहां छोड़ा था.',
|
||||
noSavedProperties: 'अभी कोई सहेजी गई संपत्ति नहीं',
|
||||
noSavedPropertiesDesc:
|
||||
'खोजते समय संपत्तियां बुकमार्क करें और बिना ट्रैक खोए अपनी शॉर्टलिस्ट बनाएं.',
|
||||
openPostcode: 'पोस्टकोड खोलें',
|
||||
clickToRename: 'नाम बदलने के लिए क्लिक करें',
|
||||
notesPlaceholder: 'अपने विचार लिखें...',
|
||||
deleteSearch: 'खोज हटाएं',
|
||||
deleteSearchConfirm:
|
||||
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
|
||||
deleteProperty: 'संपत्ति हटाएं',
|
||||
deletePropertyConfirm:
|
||||
'क्या आप वाकई यह सहेजी गई संपत्ति हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
|
||||
bed: 'बेडरूम',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
invitesPage: {
|
||||
|
|
@ -1263,11 +1251,6 @@ const hi: Translations = {
|
|||
failedToValidate: 'आमंत्रण लिंक सत्यापित नहीं हो सका',
|
||||
},
|
||||
|
||||
mapPage: {
|
||||
unsavedProperty: 'असहेजें',
|
||||
savedProperty: 'सहेजा गया',
|
||||
},
|
||||
|
||||
format: {
|
||||
justNow: 'अभी',
|
||||
minutesAgo: '{{count}} मिनट पहले',
|
||||
|
|
|
|||
|
|
@ -80,13 +80,6 @@ const hu: Translations = {
|
|||
home: 'Főoldal',
|
||||
},
|
||||
|
||||
// ── Toasts ─────────────────────────────────────────
|
||||
toasts: {
|
||||
propertySaved: 'Ingatlan mentve!',
|
||||
viewSaved: 'Mentett megtekintése',
|
||||
dontShowAgain: 'Ne mutasd újra',
|
||||
},
|
||||
|
||||
// ── SEO Page Chrome ────────────────────────────────
|
||||
seo: {
|
||||
breadcrumb: 'Morzsanavigáció',
|
||||
|
|
@ -552,8 +545,8 @@ const hu: Translations = {
|
|||
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
|
||||
'A marketing, a módszertan, az útmutató és a támogatási oldalak indexelhetők. Az irányítópult, a fiók, a mentett keresések, a meghívók és a meghívási útvonalak noindex-szel vannak megjelölve, vagy adott esetben blokkolva vannak a feltérképező robot számára.',
|
||||
'Saved search data is account-scoped': 'A mentett keresési adatok fiókra vonatkoznak',
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'A mentett keresések és tulajdonságok bejelentkezett használatra szolgálnak. Nem szerepelnek a nyilvános webhelytérképen, és nyilvános tartalomként nem térképezhetők fel.',
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'A mentett keresések és megosztott hivatkozások bejelentkezett használatra szolgálnak. Nem szerepelnek a nyilvános webhelytérképen, és nyilvános tartalomként nem térképezhetők fel.',
|
||||
'Search measurement without exposing private data':
|
||||
'A mérési adatok keresése személyes adatok felfedése nélkül',
|
||||
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
|
||||
|
|
@ -784,8 +777,6 @@ const hu: Translations = {
|
|||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Ismeretlen cím',
|
||||
unsaveProperty: 'Ingatlan mentésének visszavonása',
|
||||
saveProperty: 'Ingatlan mentése',
|
||||
estValue: 'Becsült érték:',
|
||||
type: 'Típus:',
|
||||
builtForm: 'Épületforma:',
|
||||
|
|
@ -824,6 +815,11 @@ const hu: Translations = {
|
|||
showAllStatsFallback:
|
||||
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
|
||||
showAllStats: 'Összes ingatlan mutatása',
|
||||
closestBlockingFilters: 'A területet kizáró legközelebbi szűrők',
|
||||
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
|
||||
raiseMaxTo: 'Maximum növelése erre: {{value}}',
|
||||
allowCategory: '{{value}} engedélyezése',
|
||||
travelTo: 'Utazás ide: {{destination}}',
|
||||
viewProperties: '{{count}} ingatlan megtekintése',
|
||||
viewPropertiesShort: 'Ingatlanok megtekintése',
|
||||
priceHistory: 'Ártörténet',
|
||||
|
|
@ -1258,20 +1254,11 @@ const hu: Translations = {
|
|||
noSavedSearches: 'Még nincsenek mentett keresések',
|
||||
noSavedSearchesDesc:
|
||||
'Mentsd el a szűrőket és a térképnézetet, hogy pontosan ott folytasd, ahol abbahagytad.',
|
||||
noSavedProperties: 'Még nincsenek mentett ingatlanok',
|
||||
noSavedPropertiesDesc:
|
||||
'Jelöld meg az ingatlanokat felfedezés közben, és építsd a rövid listádat elvesztés nélkül.',
|
||||
openPostcode: 'Irányítószám megnyitása',
|
||||
clickToRename: 'Kattints az átnevezéshez',
|
||||
notesPlaceholder: 'Írd le a gondolataidat...',
|
||||
deleteSearch: 'Keresés törlése',
|
||||
deleteSearchConfirm:
|
||||
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
|
||||
deleteProperty: 'Ingatlan törlése',
|
||||
deletePropertyConfirm:
|
||||
'Biztosan törölni szeretnéd ezt a mentett ingatlant? Ez nem vonható vissza.',
|
||||
bed: 'háló',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1321,12 +1308,6 @@ const hu: Translations = {
|
|||
failedToValidate: 'Nem sikerült a meghívó hivatkozás érvényesítése',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Eltávolítás',
|
||||
savedProperty: 'Mentve',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'az imént',
|
||||
|
|
|
|||
|
|
@ -79,13 +79,6 @@ const zh: Translations = {
|
|||
home: '首页',
|
||||
},
|
||||
|
||||
// ── Toasts ─────────────────────────────────────────
|
||||
toasts: {
|
||||
propertySaved: '房产已保存!',
|
||||
viewSaved: '查看已保存',
|
||||
dontShowAgain: '不再显示',
|
||||
},
|
||||
|
||||
// ── SEO Page Chrome ────────────────────────────────
|
||||
seo: {
|
||||
breadcrumb: '面包屑导航',
|
||||
|
|
@ -511,8 +504,8 @@ const zh: Translations = {
|
|||
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
|
||||
'营销、方法、指南和支持页面都是可索引的。仪表板、帐户、已保存的搜索、邀请和邀请路线被标记为 noindex 或在适当的情况下阻止爬网程序访问。',
|
||||
'Saved search data is account-scoped': '保存的搜索数据是帐户范围内的',
|
||||
'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'保存的搜索和属性仅供登录使用。它们不包含在公共站点地图中,也不应作为公共内容进行抓取。',
|
||||
'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.':
|
||||
'保存的搜索和共享链接仅供登录使用。它们不包含在公共站点地图中,也不应作为公共内容进行抓取。',
|
||||
'Search measurement without exposing private data': '搜索测量而不暴露私人数据',
|
||||
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.':
|
||||
'SEO 测量应该使用聚合分析和 Search Console 数据在公共页面上进行。私有查询参数和帐户视图不应成为可索引的登陆页面。',
|
||||
|
|
@ -734,8 +727,6 @@ const zh: Translations = {
|
|||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: '地址未知',
|
||||
unsaveProperty: '取消收藏',
|
||||
saveProperty: '收藏房产',
|
||||
estValue: '估计价值:',
|
||||
type: '类型:',
|
||||
builtForm: '建筑形式:',
|
||||
|
|
@ -771,6 +762,11 @@ const zh: Translations = {
|
|||
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
|
||||
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
|
||||
showAllStats: '显示全部房产',
|
||||
closestBlockingFilters: '最接近的排除此区域的筛选条件',
|
||||
lowerMinTo: '将最小值降至 {{value}}',
|
||||
raiseMaxTo: '将最大值提高至 {{value}}',
|
||||
allowCategory: '允许 {{value}}',
|
||||
travelTo: '前往 {{destination}} 的出行',
|
||||
viewProperties: '查看 {{count}} 处房产',
|
||||
viewPropertiesShort: '查看房产',
|
||||
priceHistory: '价格历史',
|
||||
|
|
@ -1187,17 +1183,10 @@ const zh: Translations = {
|
|||
searches: '搜索',
|
||||
noSavedSearches: '暂无保存的搜索',
|
||||
noSavedSearchesDesc: '保存您的筛选条件和地图视图,随时从上次的位置继续浏览。',
|
||||
noSavedProperties: '暂无保存的房产',
|
||||
noSavedPropertiesDesc: '在浏览过程中收藏房产,建立您的候选名单,不会遗漏任何一处。',
|
||||
openPostcode: '打开邮编',
|
||||
clickToRename: '点击重命名',
|
||||
notesPlaceholder: '记下您的想法...',
|
||||
deleteSearch: '删除搜索',
|
||||
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
|
||||
deleteProperty: '删除房产',
|
||||
deletePropertyConfirm: '确定要删除这个保存的房产吗?此操作无法撤销。',
|
||||
bed: '卧室',
|
||||
epc: '能源评级',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1245,12 +1234,6 @@ const zh: Translations = {
|
|||
failedToValidate: '验证邀请链接失败',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: '取消收藏',
|
||||
savedProperty: '已收藏',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: '刚刚',
|
||||
|
|
|
|||
23
frontend/src/lib/active-filter-scroll.test.ts
Normal file
23
frontend/src/lib/active-filter-scroll.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { findActiveFilterElement } from './active-filter-scroll';
|
||||
|
||||
describe('findActiveFilterElement', () => {
|
||||
it('returns the newest matching repeated filter card', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = `
|
||||
<div data-filter-name="Schools" data-card="first"></div>
|
||||
<div data-filter-name="price" data-card="price"></div>
|
||||
<div data-filter-name="Schools" data-card="latest"></div>
|
||||
`;
|
||||
|
||||
expect(findActiveFilterElement(root, 'Schools')?.dataset.card).toBe('latest');
|
||||
});
|
||||
|
||||
it('matches filter names with selector-special characters', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<div data-filter-name="Political vote share:%25%20Labour:1"></div>';
|
||||
|
||||
expect(findActiveFilterElement(root, 'Political vote share:%25%20Labour:1')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -718,7 +718,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
},
|
||||
{
|
||||
title: 'Saved search data is account-scoped',
|
||||
body: 'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.',
|
||||
body: 'Saved searches and shared links are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.',
|
||||
},
|
||||
{
|
||||
title: 'Search measurement without exposing private data',
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export interface ViewChangeParams {
|
|||
zoom: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
visibleLatitude?: number;
|
||||
visibleLongitude?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
|
|
@ -196,10 +198,23 @@ export interface PricePoint {
|
|||
price: number;
|
||||
}
|
||||
|
||||
export interface FilterExclusion {
|
||||
name: string;
|
||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||
direction: 'lower_min' | 'raise_max' | 'allow_value';
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
category?: string;
|
||||
relative_difference: number;
|
||||
rejected_count: number;
|
||||
}
|
||||
|
||||
export interface HexagonStatsResponse {
|
||||
count: number;
|
||||
numeric_features: NumericFeatureStats[];
|
||||
enum_features: EnumFeatureStats[];
|
||||
price_history?: PricePoint[];
|
||||
central_postcode?: string;
|
||||
filter_exclusions?: FilterExclusion[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,17 @@ if (!APP_URL) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!CACHE_DIR) {
|
||||
const CACHE_ENABLED = parseOptionalBoolEnv(
|
||||
"SCREENSHOT_CACHE_ENABLED",
|
||||
!isDevelopmentAppUrl(APP_URL),
|
||||
);
|
||||
|
||||
if (CACHE_ENABLED && !CACHE_DIR) {
|
||||
console.error("Error: CACHE_DIR environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cache = new ScreenshotCache(CACHE_DIR);
|
||||
const cache = CACHE_ENABLED ? new ScreenshotCache(CACHE_DIR as string) : null;
|
||||
const app = express();
|
||||
app.set("trust proxy", true);
|
||||
|
||||
|
|
@ -55,6 +60,28 @@ function parseRequiredPositiveIntEnv(name: string): number {
|
|||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalBoolEnv(name: string, defaultValue: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw === "") return defaultValue;
|
||||
if (raw === "1" || raw.toLowerCase() === "true") return true;
|
||||
if (raw === "0" || raw.toLowerCase() === "false") return false;
|
||||
throw new Error(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
function isDevelopmentAppUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
return (
|
||||
url.hostname === "localhost" ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "frontend" ||
|
||||
url.port === "3001"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function grantScreenshotSlot(): ReleaseScreenshotSlot {
|
||||
activeScreenshots += 1;
|
||||
let released = false;
|
||||
|
|
@ -159,24 +186,26 @@ app.get("/screenshot", async (req, res) => {
|
|||
const { pagePath, qs } = buildScreenshotRequest(
|
||||
req.query as Record<string, unknown>,
|
||||
);
|
||||
if (pagePath !== "/") qs.set("path", pagePath);
|
||||
|
||||
// Include auth status in cache key so authenticated screenshots
|
||||
// (with hexagons outside free zone) are cached separately
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) qs.set("_auth", "1");
|
||||
const cacheKey = cache.buildKey(qs);
|
||||
qs.delete("_auth");
|
||||
qs.delete("path");
|
||||
|
||||
// Check cache first
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
res.setHeader("Content-Type", "image/jpeg");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.setHeader("X-Cache", "HIT");
|
||||
cached.pipe(res);
|
||||
return;
|
||||
let cacheKey: string | null = null;
|
||||
if (cache) {
|
||||
const cacheParams = new URLSearchParams(qs);
|
||||
if (pagePath !== "/") cacheParams.set("path", pagePath);
|
||||
// Include auth status in cache key so authenticated screenshots
|
||||
// (with hexagons outside free zone) are cached separately
|
||||
if (authHeader) cacheParams.set("_auth", "1");
|
||||
cacheKey = cache.buildKey(cacheParams);
|
||||
|
||||
// Check cache first
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
res.setHeader("Content-Type", "image/jpeg");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.setHeader("X-Cache", "HIT");
|
||||
cached.pipe(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowScreenshotRequest(req)) {
|
||||
|
|
@ -199,11 +228,11 @@ app.get("/screenshot", async (req, res) => {
|
|||
const jpeg = await takeScreenshot(url, authHeader);
|
||||
|
||||
// Cache it
|
||||
cache.set(cacheKey, jpeg);
|
||||
if (cache && cacheKey) cache.set(cacheKey, jpeg);
|
||||
|
||||
res.setHeader("Content-Type", "image/jpeg");
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.setHeader("X-Cache", "MISS");
|
||||
res.setHeader("Cache-Control", cache ? "public, max-age=86400" : "no-store");
|
||||
res.setHeader("X-Cache", cache ? "MISS" : "BYPASS");
|
||||
res.send(jpeg);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
|
|
@ -220,7 +249,8 @@ app.get("/screenshot", async (req, res) => {
|
|||
const server = app.listen(PORT, () => {
|
||||
console.log(`Screenshot service listening on port ${PORT}`);
|
||||
console.log(` APP_URL: ${APP_URL}`);
|
||||
console.log(` CACHE_DIR: ${CACHE_DIR}`);
|
||||
console.log(` CACHE_ENABLED: ${CACHE_ENABLED}`);
|
||||
if (cache) console.log(` CACHE_DIR: ${CACHE_DIR}`);
|
||||
console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`);
|
||||
console.log(
|
||||
` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
|
|||
tt: 'transit:kings-cross:Kings Cross:b:0:30',
|
||||
share: 'abc123',
|
||||
pc: 'SW1A 1AA',
|
||||
lang: 'fr-CA',
|
||||
});
|
||||
|
||||
assert.equal(result.pagePath, '/invite/abc123');
|
||||
|
|
@ -60,6 +61,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
|
|||
]);
|
||||
assert.equal(result.qs.get('share'), 'abc123');
|
||||
assert.equal(result.qs.get('pc'), 'SW1A 1AA');
|
||||
assert.equal(result.qs.get('lang'), 'fr');
|
||||
});
|
||||
|
||||
test('buildScreenshotRequest safely passes through future dashboard parameters', () => {
|
||||
|
|
@ -110,3 +112,7 @@ test('buildScreenshotRequest rejects reserved screenshot service parameters', ()
|
|||
test('buildScreenshotRequest rejects unsafe passthrough parameter names', () => {
|
||||
assert.throws(() => buildScreenshotRequest({ 'filter[]': 'Feature:0:1' }), ValidationError);
|
||||
});
|
||||
|
||||
test('buildScreenshotRequest rejects unsupported languages', () => {
|
||||
assert.throws(() => buildScreenshotRequest({ og: '1', lang: 'es' }), ValidationError);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
|
|||
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
|
||||
const QUERY_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
||||
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
|
||||
const SUPPORTED_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
|
||||
const REPEATED_KEYS = [
|
||||
'filter',
|
||||
'school',
|
||||
|
|
@ -78,6 +79,16 @@ function assertSafeKey(key: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function toSupportedLanguage(value: string): string | null {
|
||||
const lower = value.toLowerCase();
|
||||
if (SUPPORTED_LANGUAGES.has(lower)) return lower;
|
||||
|
||||
const prefix = lower.split('-')[0];
|
||||
if (SUPPORTED_LANGUAGES.has(prefix)) return prefix;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendSafeValues(qs: URLSearchParams, query: Query, key: string): void {
|
||||
assertSafeKey(key);
|
||||
for (const value of repeatedStrings(query, key)) {
|
||||
|
|
@ -118,6 +129,7 @@ export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest
|
|||
'zoom',
|
||||
'tab',
|
||||
'og',
|
||||
'lang',
|
||||
'path',
|
||||
...PASSTHROUGH_SINGLE_KEYS,
|
||||
...REPEATED_KEYS,
|
||||
|
|
@ -143,6 +155,16 @@ export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest
|
|||
qs.set('og', og);
|
||||
}
|
||||
|
||||
const lang = firstString(query, 'lang');
|
||||
if (lang != null) {
|
||||
assertSafeValue('lang', lang);
|
||||
const supported = toSupportedLanguage(lang);
|
||||
if (!supported) {
|
||||
validationError('lang is invalid');
|
||||
}
|
||||
qs.set('lang', supported);
|
||||
}
|
||||
|
||||
for (const key of PASSTHROUGH_SINGLE_KEYS) {
|
||||
const value = firstString(query, key);
|
||||
if (value == null) continue;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pub enum CheckoutStart {
|
|||
|
||||
pub enum CheckoutCompletion {
|
||||
Grant(VerifiedCheckout),
|
||||
AlreadyHandled,
|
||||
AlreadyHandled(VerifiedCheckout),
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +48,6 @@ struct PendingCheckout {
|
|||
id: String,
|
||||
user_id: String,
|
||||
stripe_session_id: String,
|
||||
stripe_payment_intent_id: String,
|
||||
checkout_url: String,
|
||||
amount_pence: u64,
|
||||
expected_total_pence: u64,
|
||||
|
|
@ -258,10 +257,8 @@ pub async fn verify_checkout_completion(
|
|||
}
|
||||
};
|
||||
|
||||
if checkout.status == "completed" {
|
||||
return Ok(CheckoutCompletion::AlreadyHandled);
|
||||
}
|
||||
if checkout.status != "pending" && checkout.status != "expired" {
|
||||
let already_completed = checkout.status == "completed";
|
||||
if !already_completed && checkout.status != "pending" && checkout.status != "expired" {
|
||||
return Ok(CheckoutCompletion::Rejected(format!(
|
||||
"checkout reservation is {}",
|
||||
checkout.status
|
||||
|
|
@ -332,14 +329,20 @@ pub async fn verify_checkout_completion(
|
|||
));
|
||||
}
|
||||
|
||||
Ok(CheckoutCompletion::Grant(VerifiedCheckout {
|
||||
let verified = VerifiedCheckout {
|
||||
reservation_id: checkout.id,
|
||||
user_id: checkout.user_id,
|
||||
stripe_session_id: session_id.to_string(),
|
||||
payment_intent_id: payment_intent_id.to_string(),
|
||||
paid_amount_pence: amount_total,
|
||||
referral_invite_id: checkout.referral_invite_id,
|
||||
}))
|
||||
};
|
||||
|
||||
if already_completed {
|
||||
Ok(CheckoutCompletion::AlreadyHandled(verified))
|
||||
} else {
|
||||
Ok(CheckoutCompletion::Grant(verified))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_checkout_completed(
|
||||
|
|
@ -421,14 +424,6 @@ async fn complete_verified_checkout_locked(
|
|||
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
|
||||
}
|
||||
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_completed(
|
||||
state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
&checkout.payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
if !checkout.referral_invite_id.is_empty() {
|
||||
mark_referral_invite_used(
|
||||
state,
|
||||
|
|
@ -438,6 +433,14 @@ async fn complete_verified_checkout_locked(
|
|||
)
|
||||
.await?;
|
||||
}
|
||||
grant_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_completed(
|
||||
state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
&checkout.payment_intent_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -459,65 +462,10 @@ pub async fn grant_license_with_pricing_lock(
|
|||
result
|
||||
}
|
||||
|
||||
pub async fn reverse_license_for_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||
}
|
||||
|
||||
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||
let pricing_lock = acquire_pocketbase_lock(
|
||||
state,
|
||||
CHECKOUT_PRICING_LOCK_NAME,
|
||||
CHECKOUT_PRICING_LOCK_TTL_SECS,
|
||||
)
|
||||
.await?;
|
||||
let result = reverse_license_for_payment_intent_locked(state, payment_intent_id, reason).await;
|
||||
if let Err(err) = pricing_lock.release().await {
|
||||
warn!("Failed to release checkout pricing lock: {err}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn reverse_license_for_payment_intent_locked(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if checkout.stripe_payment_intent_id != payment_intent_id {
|
||||
return Err(anyhow!("checkout payment intent mismatch"));
|
||||
}
|
||||
if checkout.status == "refunded" || checkout.status == "disputed" {
|
||||
return Ok(Some(checkout.user_id));
|
||||
}
|
||||
if checkout.status != "completed" {
|
||||
return Ok(Some(checkout.user_id));
|
||||
}
|
||||
|
||||
let reversed_status = if reason.contains("dispute") {
|
||||
"disputed"
|
||||
} else {
|
||||
"refunded"
|
||||
};
|
||||
revoke_license(state, &checkout.user_id).await?;
|
||||
mark_checkout_reversed(state, &checkout.id, reversed_status, reason).await?;
|
||||
Ok(Some(checkout.user_id))
|
||||
}
|
||||
|
||||
pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
set_user_subscription(state, user_id, "licensed").await
|
||||
}
|
||||
|
||||
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
set_user_subscription(state, user_id, "free").await
|
||||
}
|
||||
|
||||
async fn set_user_subscription(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
|
|
@ -768,20 +716,13 @@ async fn count_active_pending_checkouts(state: &AppState, now: u64) -> anyhow::R
|
|||
async fn find_active_checkout_for_user(
|
||||
state: &AppState,
|
||||
user_id: &str,
|
||||
_discount_coupon_id: &str,
|
||||
_referral_invite_id: &str,
|
||||
discount_coupon_id: &str,
|
||||
referral_invite_id: &str,
|
||||
now: u64,
|
||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||
if !is_safe_pocketbase_id(user_id) {
|
||||
return Err(anyhow!("invalid PocketBase user id"));
|
||||
}
|
||||
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!(
|
||||
"status=\"pending\" && expires_at_unix>={now} && user=\"{}\"",
|
||||
user_id
|
||||
);
|
||||
let filter = active_checkout_filter(user_id, discount_coupon_id, referral_invite_id, now)?;
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
|
|
@ -804,6 +745,27 @@ async fn find_active_checkout_for_user(
|
|||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
fn active_checkout_filter(
|
||||
user_id: &str,
|
||||
discount_coupon_id: &str,
|
||||
referral_invite_id: &str,
|
||||
now: u64,
|
||||
) -> anyhow::Result<String> {
|
||||
if !is_safe_pocketbase_id(user_id) {
|
||||
return Err(anyhow!("invalid PocketBase user id"));
|
||||
}
|
||||
if !discount_coupon_id.is_empty() && !is_safe_stripe_session_id(discount_coupon_id) {
|
||||
return Err(anyhow!("invalid Stripe coupon id"));
|
||||
}
|
||||
if !referral_invite_id.is_empty() && !is_safe_pocketbase_id(referral_invite_id) {
|
||||
return Err(anyhow!("invalid PocketBase referral invite id"));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"status=\"pending\" && expires_at_unix>={now} && user=\"{user_id}\" && discount_coupon_id=\"{discount_coupon_id}\" && referral_invite_id=\"{referral_invite_id}\""
|
||||
))
|
||||
}
|
||||
|
||||
async fn expire_stale_pending_checkouts(state: &AppState, now: u64) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
|
@ -1076,31 +1038,6 @@ async fn mark_checkout_status(
|
|||
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
||||
}
|
||||
|
||||
async fn mark_checkout_reversed(
|
||||
state: &AppState,
|
||||
reservation_id: &str,
|
||||
status: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"status": status,
|
||||
"reversal_reason": reason,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success(resp)
|
||||
.await
|
||||
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
|
||||
}
|
||||
|
||||
async fn find_checkout_by_stripe_session(
|
||||
state: &AppState,
|
||||
stripe_session_id: &str,
|
||||
|
|
@ -1130,35 +1067,6 @@ async fn find_checkout_by_stripe_session(
|
|||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
async fn find_checkout_by_payment_intent(
|
||||
state: &AppState,
|
||||
payment_intent_id: &str,
|
||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||
let token = get_superuser_token(state).await?;
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("stripe_payment_intent_id=\"{}\"", payment_intent_id);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
ensure_success_ref(&resp).await?;
|
||||
|
||||
let body: Value = resp.json().await?;
|
||||
let item = body["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.cloned();
|
||||
|
||||
item.map(parse_pending_checkout).transpose()
|
||||
}
|
||||
|
||||
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||
Ok(PendingCheckout {
|
||||
id: item["id"]
|
||||
|
|
@ -1173,10 +1081,6 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
|||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
stripe_payment_intent_id: item["stripe_payment_intent_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
checkout_url: item["checkout_url"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
|
|
@ -1242,3 +1146,40 @@ async fn ensure_success_ref(resp: &reqwest::Response) -> anyhow::Result<()> {
|
|||
|
||||
Err(anyhow!("upstream returned {}", resp.status()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn active_checkout_filter_includes_empty_context_for_standard_checkout() {
|
||||
let filter = active_checkout_filter("abc123", "", "", 42).unwrap();
|
||||
assert_eq!(
|
||||
filter,
|
||||
"status=\"pending\" && expires_at_unix>=42 && user=\"abc123\" && discount_coupon_id=\"\" && referral_invite_id=\"\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_checkout_filter_includes_referral_context() {
|
||||
let filter = active_checkout_filter("user123", "coupon_30", "invite123", 99).unwrap();
|
||||
assert_eq!(
|
||||
filter,
|
||||
"status=\"pending\" && expires_at_unix>=99 && user=\"user123\" && discount_coupon_id=\"coupon_30\" && referral_invite_id=\"invite123\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_checkout_filter_rejects_unsafe_context_values() {
|
||||
assert!(active_checkout_filter("user123", "bad\"coupon", "", 1).is_err());
|
||||
assert!(active_checkout_filter("user123", "", "bad-invite", 1).is_err());
|
||||
assert!(active_checkout_filter("bad-user", "", "", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_total_for_referral_discount_rounds_down_like_stripe_amount_math() {
|
||||
assert_eq!(expected_total_for_checkout(999, Some("coupon_30")), 699);
|
||||
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
|
||||
assert_eq!(expected_total_for_checkout(999, None), 999);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
server-rs/src/language.rs
Normal file
101
server-rs/src/language.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
pub const DEFAULT_LANGUAGE: &str = "en";
|
||||
|
||||
const SUPPORTED_LANGUAGES: &[&str] = &["en", "fr", "de", "zh", "hi", "hu"];
|
||||
|
||||
pub fn supported_language(value: &str) -> Option<&'static str> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
if value.is_empty() || value == "*" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let primary = value.split('-').next().unwrap_or("");
|
||||
SUPPORTED_LANGUAGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|language| value == *language || primary == *language)
|
||||
}
|
||||
|
||||
pub fn language_from_accept_language(header: Option<&str>) -> &'static str {
|
||||
let Some(header) = header else {
|
||||
return DEFAULT_LANGUAGE;
|
||||
};
|
||||
|
||||
let mut best_language = None;
|
||||
let mut best_quality = 0.0f32;
|
||||
|
||||
for item in header.split(',') {
|
||||
let mut parts = item.split(';');
|
||||
let tag = parts.next().unwrap_or("").trim();
|
||||
let mut quality = 1.0f32;
|
||||
|
||||
for param in parts {
|
||||
let param = param.trim();
|
||||
if let Some(value) = param.strip_prefix("q=") {
|
||||
quality = value.parse::<f32>().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
if quality <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(language) = supported_language(tag) {
|
||||
if quality > best_quality {
|
||||
best_language = Some(language);
|
||||
best_quality = quality;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_language.unwrap_or(DEFAULT_LANGUAGE)
|
||||
}
|
||||
|
||||
pub fn query_string_with_language(query_string: &str, language: &str) -> String {
|
||||
if url::form_urlencoded::parse(query_string.as_bytes()).any(|(key, _)| key == "lang") {
|
||||
return query_string.to_string();
|
||||
}
|
||||
|
||||
if query_string.is_empty() {
|
||||
format!("lang={language}")
|
||||
} else {
|
||||
format!("{query_string}&lang={language}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_browser_language_tags_to_supported_languages() {
|
||||
assert_eq!(supported_language("fr-CA"), Some("fr"));
|
||||
assert_eq!(supported_language("zh-Hans-CN"), Some("zh"));
|
||||
assert_eq!(supported_language("es"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_highest_quality_supported_language() {
|
||||
assert_eq!(
|
||||
language_from_accept_language(Some("en-US;q=0.6, fr-FR;q=0.9, de;q=0.7")),
|
||||
"fr"
|
||||
);
|
||||
assert_eq!(
|
||||
language_from_accept_language(Some("es-ES, hi-IN;q=0.8")),
|
||||
"hi"
|
||||
);
|
||||
assert_eq!(language_from_accept_language(Some("es-ES")), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_language_without_overriding_explicit_query_language() {
|
||||
assert_eq!(query_string_with_language("", "de"), "lang=de");
|
||||
assert_eq!(
|
||||
query_string_with_language("lat=51.5&lon=-0.1", "de"),
|
||||
"lat=51.5&lon=-0.1&lang=de"
|
||||
);
|
||||
assert_eq!(
|
||||
query_string_with_language("lang=fr&lat=1", "de"),
|
||||
"lang=fr&lat=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,16 @@ use parking_lot::RwLock;
|
|||
use rustc_hash::FxHashMap;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::auth::PocketBaseUser;
|
||||
use crate::consts::FREE_ZONE_BOUNDS;
|
||||
use crate::consts::{
|
||||
FREE_ZONE_BOUNDS, MAX_SHARE_LAT_SPAN, MAX_SHARE_LON_SPAN, MAX_SHARE_ZOOM, MIN_SHARE_ZOOM,
|
||||
SHARE_CACHE_MAX_ENTRIES, SHARE_CACHE_TTL_SECS,
|
||||
};
|
||||
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,
|
||||
|
|
@ -72,9 +73,8 @@ impl Default for ShareBoundsCache {
|
|||
}
|
||||
|
||||
/// 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.
|
||||
/// Looks up an explicit server-created grant on the short URL record. Legacy
|
||||
/// records that only stored raw params intentionally grant no access.
|
||||
pub async fn lookup_share_bounds(state: &AppState, code: &str) -> Option<ShareBounds> {
|
||||
if !is_valid_share_code(code) {
|
||||
return None;
|
||||
|
|
@ -127,29 +127,68 @@ 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()?;
|
||||
parse_view_from_params(params).map(|(lat, lon, zoom)| bounds_from_view(lat, lon, zoom))
|
||||
let item = json["items"].as_array()?.first()?;
|
||||
let bounds = ShareBounds {
|
||||
south: number_field(item, "share_south")?,
|
||||
west: number_field(item, "share_west")?,
|
||||
north: number_field(item, "share_north")?,
|
||||
east: number_field(item, "share_east")?,
|
||||
};
|
||||
is_valid_share_bounds(bounds).then_some(bounds)
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
fn number_field(item: &Value, field: &str) -> Option<f64> {
|
||||
item.get(field)?.as_f64().filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
/// Build share params and bounds for a new share code. If the source view is
|
||||
/// broader than a share grant may cover, clamp the stored zoom around the same
|
||||
/// center so recipients open inside the created grant instead of being blocked
|
||||
/// on first load.
|
||||
pub fn share_params_and_bounds_from_params(params: &str) -> Option<(String, ShareBounds)> {
|
||||
let mut lat: Option<f64> = None;
|
||||
let mut lon: Option<f64> = None;
|
||||
let mut zoom: Option<f64> = None;
|
||||
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(),
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"lat" => lat = value.parse().ok(),
|
||||
"lon" => lon = value.parse().ok(),
|
||||
"zoom" => zoom = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
pairs.push((key.into_owned(), value.into_owned()));
|
||||
}
|
||||
Some((lat?, lon?, zoom?))
|
||||
|
||||
let lat = lat?;
|
||||
let lon = lon?;
|
||||
let zoom = zoom?;
|
||||
if !lat.is_finite()
|
||||
|| !lon.is_finite()
|
||||
|| !zoom.is_finite()
|
||||
|| !(-90.0..=90.0).contains(&lat)
|
||||
|| !(-180.0..=180.0).contains(&lon)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
|
||||
let bounds = bounds_from_view(lat, lon, zoom);
|
||||
if !is_valid_share_bounds(bounds) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in pairs {
|
||||
if key == "zoom" {
|
||||
out.append_pair(&key, &format!("{zoom:.1}"));
|
||||
} else {
|
||||
out.append_pair(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
Some((out.finish(), bounds))
|
||||
}
|
||||
|
||||
/// Derive the share bbox from the share's center lat/lon and zoom.
|
||||
|
|
@ -160,17 +199,30 @@ fn parse_view_from_params(params: &str) -> Option<(f64, f64, f64)> {
|
|||
/// ~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);
|
||||
let zoom = zoom.clamp(MIN_SHARE_ZOOM, MAX_SHARE_ZOOM);
|
||||
let half_lon = (1800.0 / 2.0_f64.powf(zoom))
|
||||
.min(MAX_SHARE_LON_SPAN / 2.0)
|
||||
.min(180.0);
|
||||
let half_lat = (half_lon * 0.6).min(MAX_SHARE_LAT_SPAN / 2.0).min(85.0);
|
||||
ShareBounds {
|
||||
south: lat - half_lat,
|
||||
north: lat + half_lat,
|
||||
west: lon - half_lon,
|
||||
east: lon + half_lon,
|
||||
south: (lat - half_lat).max(-90.0),
|
||||
north: (lat + half_lat).min(90.0),
|
||||
west: (lon - half_lon).max(-180.0),
|
||||
east: (lon + half_lon).min(180.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid_share_bounds(bounds: ShareBounds) -> bool {
|
||||
let values = [bounds.south, bounds.west, bounds.north, bounds.east];
|
||||
values.iter().all(|value| value.is_finite())
|
||||
&& bounds.south >= -90.0
|
||||
&& bounds.north <= 90.0
|
||||
&& bounds.west >= -180.0
|
||||
&& bounds.east <= 180.0
|
||||
&& bounds.south <= bounds.north
|
||||
&& bounds.west <= bounds.east
|
||||
}
|
||||
|
||||
/// Check whether the user is allowed to query data at the given bounds.
|
||||
/// Licensed users and admins bypass the check entirely.
|
||||
/// Free/anonymous users get 403 unless the bounds fall inside the free zone
|
||||
|
|
@ -224,3 +276,73 @@ pub fn check_license_point(
|
|||
) -> Result<(), axum::response::Response> {
|
||||
check_license_bounds(user, (lat, lon, lat, lon), share_bounds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_close(actual: f64, expected: f64) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-9,
|
||||
"expected {actual} to be close to {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_clamps_over_broad_view_to_center() {
|
||||
let (params, bounds) =
|
||||
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=4.2&filter=price%3A1%3A2")
|
||||
.unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
assert!(parsed.contains(&("zoom".to_string(), "11.0".to_string())));
|
||||
assert!(parsed.contains(&("filter".to_string(), "price:1:2".to_string())));
|
||||
assert_close((bounds.south + bounds.north) / 2.0, 51.5);
|
||||
assert_close((bounds.west + bounds.east) / 2.0, -0.1);
|
||||
assert!(bounds.north - bounds.south <= MAX_SHARE_LAT_SPAN);
|
||||
assert!(bounds.east - bounds.west <= MAX_SHARE_LON_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_keeps_specific_zoom_inside_limit() {
|
||||
let (params, bounds) =
|
||||
share_params_and_bounds_from_params("lat=51.5&lon=-0.1&zoom=13.3").unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
assert!(parsed.contains(&("zoom".to_string(), "13.3".to_string())));
|
||||
assert!(bounds.north - bounds.south < MAX_SHARE_LAT_SPAN);
|
||||
assert!(bounds.east - bounds.west < MAX_SHARE_LON_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_consumption_accepts_bounds_larger_than_creation_limit() {
|
||||
assert!(is_valid_share_bounds(ShareBounds {
|
||||
south: 40.0,
|
||||
west: -10.0,
|
||||
north: 60.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_consumption_still_rejects_malformed_bounds() {
|
||||
assert!(!is_valid_share_bounds(ShareBounds {
|
||||
south: 60.0,
|
||||
west: -10.0,
|
||||
north: 40.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
assert!(!is_valid_share_bounds(ShareBounds {
|
||||
south: 40.0,
|
||||
west: -181.0,
|
||||
north: 60.0,
|
||||
east: 10.0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use axum::middleware::Next;
|
|||
use axum::response::Response;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::state::AppState;
|
||||
|
||||
const OG_PLACEHOLDER: &str =
|
||||
|
|
@ -140,19 +141,19 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
|||
"/saved" => Some(SeoPage {
|
||||
canonical_path: "/saved",
|
||||
title: "Perfect Postcode account",
|
||||
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
|
||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||
indexable: false,
|
||||
}),
|
||||
"/invites" => Some(SeoPage {
|
||||
canonical_path: "/invites",
|
||||
title: "Perfect Postcode account",
|
||||
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
|
||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||
indexable: false,
|
||||
}),
|
||||
"/account" => Some(SeoPage {
|
||||
canonical_path: "/account",
|
||||
title: "Perfect Postcode account",
|
||||
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
|
||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||
indexable: false,
|
||||
}),
|
||||
_ if path.starts_with("/invite/") => Some(SeoPage {
|
||||
|
|
@ -223,9 +224,17 @@ fn not_found_response(public_url: &str, path: &str) -> Response {
|
|||
response
|
||||
}
|
||||
|
||||
fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &str) -> String {
|
||||
fn route_seo_tags(
|
||||
page: &SeoPage,
|
||||
path: &str,
|
||||
query_string: &str,
|
||||
public_url: &str,
|
||||
language: &str,
|
||||
) -> String {
|
||||
let path_e = escape_attr(path);
|
||||
let query_e = escape_attr(query_string);
|
||||
let screenshot_query_string = query_string_with_language(query_string, language);
|
||||
let screenshot_query_e = escape_attr(&screenshot_query_string);
|
||||
let public_url_e = escape_attr(public_url.trim_end_matches('/'));
|
||||
let canonical_path_e = escape_attr(page.canonical_path);
|
||||
let title_e = escape_attr(page.title);
|
||||
|
|
@ -233,15 +242,15 @@ fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &s
|
|||
|
||||
let is_invite = path.starts_with("/invite/");
|
||||
let og_image_url = if is_invite {
|
||||
if query_string.is_empty() {
|
||||
if screenshot_query_string.is_empty() {
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}")
|
||||
} else {
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}&{query_e}")
|
||||
format!("{public_url_e}/api/screenshot?og=1&path={path_e}&{screenshot_query_e}")
|
||||
}
|
||||
} else if query_string.is_empty() {
|
||||
} else if screenshot_query_string.is_empty() {
|
||||
format!("{public_url_e}/api/screenshot?og=1")
|
||||
} else {
|
||||
format!("{public_url_e}/api/screenshot?og=1&{query_e}")
|
||||
format!("{public_url_e}/api/screenshot?og=1&{screenshot_query_e}")
|
||||
};
|
||||
|
||||
let canonical_url = format!("{public_url_e}{canonical_path_e}");
|
||||
|
|
@ -313,6 +322,12 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
let path = request.uri().path().to_string();
|
||||
// Capture the query string before passing the request through
|
||||
let query_string = request.uri().query().unwrap_or("").to_string();
|
||||
let language = language_from_accept_language(
|
||||
request
|
||||
.headers()
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
|
||||
// Get state from extensions
|
||||
let state = request.extensions().get::<Arc<AppState>>().cloned();
|
||||
|
|
@ -362,7 +377,7 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
};
|
||||
|
||||
let html = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url);
|
||||
let tags = route_seo_tags(&page, &path, &query_string, &state.public_url, language);
|
||||
let html = inject_tags(html, &page, &tags);
|
||||
parts.headers.remove(header::CONTENT_LENGTH);
|
||||
Response::from_parts(parts, Body::from(html))
|
||||
|
|
|
|||
|
|
@ -80,6 +80,28 @@ pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCod
|
|||
|
||||
let (south, west, north, east) = (parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
if ![south, west, north, east]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid bounds: values must be finite numbers".into(),
|
||||
));
|
||||
}
|
||||
if !(-90.0..=90.0).contains(&south) || !(-90.0..=90.0).contains(&north) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid bounds: latitude must be between -90 and 90".into(),
|
||||
));
|
||||
}
|
||||
if !(-180.0..=180.0).contains(&west) || !(-180.0..=180.0).contains(&east) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid bounds: longitude must be between -180 and 180".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate that bounds are not inverted
|
||||
if south > north {
|
||||
return Err((
|
||||
|
|
@ -112,8 +134,8 @@ mod tests {
|
|||
(1.0, 2.0, 3.0, 4.0)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bounds("-51.5, -0.1, 51.6, 0.2").unwrap(),
|
||||
(-51.5, -0.1, 51.6, 0.2)
|
||||
parse_bounds("51.5, -0.1, 51.6, 0.2").unwrap(),
|
||||
(51.5, -0.1, 51.6, 0.2)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +155,18 @@ mod tests {
|
|||
assert!(parse_bounds("51.0,0.5,52.0,-0.5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_rejects_non_finite_values() {
|
||||
assert!(parse_bounds("NaN,0,1,1").is_err());
|
||||
assert!(parse_bounds("0,0,inf,1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_accepts_world_sized_bounds() {
|
||||
assert!(parse_bounds("-90,-180,90,180").is_ok());
|
||||
assert!(parse_bounds("35.8,-45.0,67.2,45.0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_applies_buffer() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use crate::state::SharedState;
|
|||
|
||||
const FILTER_GROUP_ORDER: &[&str] = &["Transport", "Property prices", "Properties", "Amenities"];
|
||||
const LAST_FILTER_GROUPS: &[&str] = &["Area development"];
|
||||
const POI_DISTANCE_SLIDER_MIN_KM: f32 = 0.0;
|
||||
const POI_DISTANCE_SLIDER_MAX_KM: f32 = 5.0;
|
||||
|
||||
fn is_empty(val: &str) -> bool {
|
||||
val.is_empty()
|
||||
|
|
@ -163,8 +165,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
|||
let is_park = category.eq_ignore_ascii_case("park");
|
||||
dynamic_poi_features.push(FeatureInfo::Numeric {
|
||||
name: name.clone(),
|
||||
min: stats.slider_min,
|
||||
max: stats.slider_max,
|
||||
min: POI_DISTANCE_SLIDER_MIN_KM,
|
||||
max: POI_DISTANCE_SLIDER_MAX_KM,
|
||||
step: 0.1,
|
||||
histogram: stats.histogram.clone(),
|
||||
description: if is_park {
|
||||
|
|
@ -187,7 +189,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
|||
prefix: "",
|
||||
suffix: " km",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
absolute: true,
|
||||
});
|
||||
} else if let Some(category) = features::dynamic_poi_count_category(name) {
|
||||
let stats = &data.poi_metrics.feature_stats[feat_idx];
|
||||
|
|
|
|||
|
|
@ -193,13 +193,31 @@ async fn verify_is_admin(
|
|||
Ok(body["is_admin"].as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
async fn lookup_unused_invite(
|
||||
fn redeemable_invite_filter(code: &str, user_id: &str) -> Result<String, &'static str> {
|
||||
validate_invite_code(code)?;
|
||||
if user_id.is_empty()
|
||||
|| user_id.len() > 32
|
||||
|| !user_id.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
{
|
||||
return Err("Invalid user id");
|
||||
}
|
||||
Ok(format!(
|
||||
"code=\"{}\" && (used_by_id=\"\" || used_by_id=\"{}\")",
|
||||
code, user_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn lookup_redeemable_invite(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, Response> {
|
||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
|
||||
let filter = match redeemable_invite_filter(code, user_id) {
|
||||
Ok(filter) => filter,
|
||||
Err(msg) => return Err((StatusCode::BAD_REQUEST, msg).into_response()),
|
||||
};
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
|
|
@ -590,7 +608,7 @@ pub async fn post_redeem_invite(
|
|||
}
|
||||
};
|
||||
|
||||
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
|
||||
let invite = match lookup_redeemable_invite(&state, pb_url, &token, &req.code, &user.id).await {
|
||||
Ok(Some(invite)) => invite,
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
|
|
@ -617,13 +635,17 @@ pub async fn post_redeem_invite(
|
|||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
let used_by_id = invite["used_by_id"].as_str().unwrap_or_default();
|
||||
if !used_by_id.is_empty() && used_by_id != user.id {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response();
|
||||
}
|
||||
|
||||
if invite_type == "admin" {
|
||||
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -635,6 +657,10 @@ pub async fn post_redeem_invite(
|
|||
.into_response();
|
||||
}
|
||||
|
||||
if !used_by_id.is_empty() {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response();
|
||||
}
|
||||
|
||||
match active_referral_checkout_user(&state, invite_id).await {
|
||||
Ok(Some(active_user_id)) if active_user_id != user.id => {
|
||||
return (
|
||||
|
|
@ -663,6 +689,26 @@ pub async fn post_redeem_invite(
|
|||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
|
||||
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
|
||||
assert_eq!(
|
||||
filter,
|
||||
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redeemable_invite_filter_rejects_unsafe_values() {
|
||||
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
|
||||
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// List invites. Users only see invites they created, including admins.
|
||||
pub async fn get_invites(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,22 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::Json;
|
||||
use axum::Extension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::licensing::{check_license_point, resolve_share_code};
|
||||
use crate::state::SharedState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JourneyQuery {
|
||||
postcode: String,
|
||||
mode: String,
|
||||
slug: String,
|
||||
share: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -26,16 +32,30 @@ pub struct JourneyResponse {
|
|||
|
||||
pub async fn get_journey(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
query: axum::extract::Query<JourneyQuery>,
|
||||
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
|
||||
) -> Result<Json<JourneyResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
let store = &state.travel_time_store;
|
||||
let postcode = normalize_postcode(&query.postcode);
|
||||
|
||||
let pc_idx = state
|
||||
.postcode_data
|
||||
.postcode_to_idx
|
||||
.get(&postcode)
|
||||
.copied()
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Postcode not found").into_response())?;
|
||||
let (lat, lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
|
||||
check_license_point(&user.0, lat as f64, lon as f64, share_bounds)?;
|
||||
|
||||
if !store.has_destination(&query.mode, &query.slug) {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No travel data for mode={} slug={}", query.mode, query.slug),
|
||||
));
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
|
||||
|
|
@ -43,9 +63,10 @@ pub async fn get_journey(
|
|||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to load travel data: {e}"),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let row = travel_data.get(&query.postcode);
|
||||
let row = travel_data.get(&postcode);
|
||||
let journey = row
|
||||
.and_then(|r| r.journey.as_ref())
|
||||
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());
|
||||
|
|
|
|||
|
|
@ -9,6 +9,31 @@ use tracing::warn;
|
|||
|
||||
use crate::state::SharedState;
|
||||
|
||||
/// PocketBase API paths the frontend is allowed to reach via /pb/*.
|
||||
/// Everything else (admins API, settings, logs, backups, collection schema,
|
||||
/// arbitrary collection records like checkout_sessions/invites/short_urls)
|
||||
/// is rejected at the proxy layer as defense-in-depth on top of PB's own
|
||||
/// collection rules.
|
||||
fn is_allowed_pb_path(path: &str) -> bool {
|
||||
// Exact paths
|
||||
if matches!(
|
||||
path,
|
||||
"/api/health" | "/api/oauth2-redirect" | "/api/realtime"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Prefix-allowed paths. The trailing slash is intentional — without it,
|
||||
// `/api/collections/users` (the schema endpoint) would match.
|
||||
const ALLOWED_PREFIXES: &[&str] = &[
|
||||
"/api/collections/users/",
|
||||
"/api/collections/saved_searches/",
|
||||
"/api/files/",
|
||||
];
|
||||
ALLOWED_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| path.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
|
||||
/// responses are passed through to the browser (needed for OAuth flows).
|
||||
/// No overall timeout because SSE (Server-Sent Events) connections used by
|
||||
|
|
@ -31,6 +56,13 @@ pub async fn proxy_to_pocketbase(
|
|||
|
||||
let path = req.uri().path();
|
||||
let target_path = path.strip_prefix("/pb").unwrap_or(path);
|
||||
if !is_allowed_pb_path(target_path) {
|
||||
warn!(path = %target_path, "Rejected PocketBase proxy request to disallowed path");
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
let query = req
|
||||
.uri()
|
||||
.query()
|
||||
|
|
@ -55,20 +87,9 @@ pub async fn proxy_to_pocketbase(
|
|||
}
|
||||
}
|
||||
|
||||
// Forward client IP so PocketBase rate-limits per-user, not per-server.
|
||||
// Prefer existing X-Forwarded-For (from reverse proxy), fall back to X-Real-IP.
|
||||
if let Some(xff) = req.headers().get("x-forwarded-for") {
|
||||
builder = builder.header("X-Forwarded-For", xff.clone());
|
||||
// First IP in the chain is the original client
|
||||
if let Ok(s) = xff.to_str() {
|
||||
if let Some(client_ip) = s.split(',').next().map(str::trim) {
|
||||
builder = builder.header("X-Real-IP", client_ip);
|
||||
}
|
||||
}
|
||||
} else if let Some(real_ip) = req.headers().get("x-real-ip") {
|
||||
builder = builder.header("X-Forwarded-For", real_ip.clone());
|
||||
builder = builder.header("X-Real-IP", real_ip.clone());
|
||||
}
|
||||
// Do not forward client-supplied X-Forwarded-For/X-Real-IP. PocketBase
|
||||
// may use trusted proxy headers for rate limits, so accepting public
|
||||
// values here lets callers choose their own source IP.
|
||||
|
||||
// Forward body
|
||||
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use axum::response::IntoResponse;
|
|||
use metrics::histogram;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::state::{AppState, SharedState};
|
||||
|
||||
/// Fetch a JPEG screenshot from the screenshot service.
|
||||
|
|
@ -48,9 +49,19 @@ pub async fn get_screenshot(
|
|||
let qs = uri.query().unwrap_or_default();
|
||||
let auth = headers.get(header::AUTHORIZATION);
|
||||
let is_og = qs.contains("og=1");
|
||||
let query_string = if is_og {
|
||||
let language = language_from_accept_language(
|
||||
headers
|
||||
.get(header::ACCEPT_LANGUAGE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
query_string_with_language(qs, language)
|
||||
} else {
|
||||
qs.to_string()
|
||||
};
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = fetch_screenshot_bytes(&state, qs, auth).await;
|
||||
let result = fetch_screenshot_bytes(&state, &query_string, auth).await;
|
||||
let kind = if is_og { "og" } else { "export" };
|
||||
histogram!("screenshot_duration_seconds", "kind" => kind).record(t0.elapsed().as_secs_f64());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue