lgtm 2
This commit is contained in:
parent
a8de0a614d
commit
3fa95819e3
30 changed files with 907 additions and 205 deletions
|
|
@ -22,6 +22,13 @@ export interface SearchedLocation {
|
||||||
focusAddress?: string;
|
focusAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PostcodeLookupResponse {
|
||||||
|
postcode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
geometry: PostcodeGeometry;
|
||||||
|
}
|
||||||
|
|
||||||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||||
city: 10,
|
city: 10,
|
||||||
borough: 12,
|
borough: 12,
|
||||||
|
|
@ -48,11 +55,15 @@ export default function LocationSearch({
|
||||||
onLocationSearched,
|
onLocationSearched,
|
||||||
onCurrentLocationFound,
|
onCurrentLocationFound,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
|
className = '',
|
||||||
|
inputClassName,
|
||||||
}: {
|
}: {
|
||||||
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
||||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||||
onMouseEnter?: () => void;
|
onMouseEnter?: () => void;
|
||||||
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const search = useLocationSearch();
|
const search = useLocationSearch();
|
||||||
|
|
@ -86,10 +97,37 @@ export default function LocationSearch({
|
||||||
async (result: SearchResult) => {
|
async (result: SearchResult) => {
|
||||||
if (result.type === 'place') {
|
if (result.type === 'place') {
|
||||||
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
search.close();
|
||||||
onFlyTo(result.lat, result.lon, zoom);
|
onFlyTo(result.lat, result.lon, zoom);
|
||||||
onLocationSearched?.(null);
|
try {
|
||||||
search.clear();
|
const params = new URLSearchParams({
|
||||||
if (isMobile) setExpanded(false);
|
lat: String(result.lat),
|
||||||
|
lng: String(result.lon),
|
||||||
|
log: 'false',
|
||||||
|
});
|
||||||
|
const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders());
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(t('locationSearch.lookupFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json: PostcodeLookupResponse = await res.json();
|
||||||
|
onLocationSearched?.({
|
||||||
|
postcode: json.postcode,
|
||||||
|
geometry: json.geometry,
|
||||||
|
latitude: json.latitude,
|
||||||
|
longitude: json.longitude,
|
||||||
|
markerLatitude: result.lat,
|
||||||
|
markerLongitude: result.lon,
|
||||||
|
});
|
||||||
|
search.clear();
|
||||||
|
if (isMobile) setExpanded(false);
|
||||||
|
} catch {
|
||||||
|
setError(t('locationSearch.lookupFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,12 +144,7 @@ export default function LocationSearch({
|
||||||
setError(t('locationSearch.postcodeNotFound'));
|
setError(t('locationSearch.postcodeNotFound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json: {
|
const json: PostcodeLookupResponse = await res.json();
|
||||||
postcode: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
geometry: PostcodeGeometry;
|
|
||||||
} = await res.json();
|
|
||||||
onFlyTo(result.lat, result.lon, 17);
|
onFlyTo(result.lat, result.lon, 17);
|
||||||
onLocationSearched?.({
|
onLocationSearched?.({
|
||||||
postcode: json.postcode,
|
postcode: json.postcode,
|
||||||
|
|
@ -143,12 +176,7 @@ export default function LocationSearch({
|
||||||
setError(t('locationSearch.postcodeNotFound'));
|
setError(t('locationSearch.postcodeNotFound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json: {
|
const json: PostcodeLookupResponse = await res.json();
|
||||||
postcode: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
geometry: PostcodeGeometry;
|
|
||||||
} = await res.json();
|
|
||||||
onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
|
onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
|
||||||
onLocationSearched?.({
|
onLocationSearched?.({
|
||||||
postcode: json.postcode,
|
postcode: json.postcode,
|
||||||
|
|
@ -237,7 +265,7 @@ export default function LocationSearch({
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
data-tutorial="search"
|
data-tutorial="search"
|
||||||
className="flex flex-col pointer-events-auto"
|
className={`flex flex-col pointer-events-auto ${className}`}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
>
|
>
|
||||||
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
||||||
|
|
@ -248,7 +276,10 @@ export default function LocationSearch({
|
||||||
loading={loading}
|
loading={loading}
|
||||||
placeholder={t('locationSearch.placeholder')}
|
placeholder={t('locationSearch.placeholder')}
|
||||||
size="sm"
|
size="sm"
|
||||||
inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
inputClassName={
|
||||||
|
inputClassName ??
|
||||||
|
'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'
|
||||||
|
}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
onInputChange={() => setError(null)}
|
onInputChange={() => setError(null)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ export default function MapLegend({
|
||||||
totalCount,
|
totalCount,
|
||||||
onResetScale,
|
onResetScale,
|
||||||
resetScaleDisabled = false,
|
resetScaleDisabled = false,
|
||||||
|
className = '',
|
||||||
}: {
|
}: {
|
||||||
featureLabel: string;
|
featureLabel: string;
|
||||||
range: [number, number];
|
range: [number, number];
|
||||||
|
|
@ -126,6 +127,7 @@ export default function MapLegend({
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
onResetScale?: () => void;
|
onResetScale?: () => void;
|
||||||
resetScaleDisabled?: boolean;
|
resetScaleDisabled?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isEnum = enumValues && enumValues.length > 0;
|
const isEnum = enumValues && enumValues.length > 0;
|
||||||
|
|
@ -199,7 +201,9 @@ export default function MapLegend({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto">
|
<div
|
||||||
|
className={`bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto ${className}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-semibold text-sm dark:text-white min-w-0 truncate">
|
<span className="font-semibold text-sm dark:text-white min-w-0 truncate">
|
||||||
{featureLabel}
|
{featureLabel}
|
||||||
|
|
|
||||||
|
|
@ -260,18 +260,10 @@ export default function MapPage({
|
||||||
const license = useLicense();
|
const license = useLicense();
|
||||||
|
|
||||||
const handleTravelTimeSetDestination = useCallback(
|
const handleTravelTimeSetDestination = useCallback(
|
||||||
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
(index: number, slug: string, label: string, _lat: number, _lon: number) => {
|
||||||
handleSetDestination(index, slug, label);
|
handleSetDestination(index, slug, label);
|
||||||
if (slug) {
|
|
||||||
mapFlyToRef.current?.(
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
|
||||||
getMobileMapFlyToOptions()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[getMobileMapFlyToOptions, handleSetDestination, mapData.currentView?.zoom]
|
[handleSetDestination]
|
||||||
);
|
);
|
||||||
|
|
||||||
const journeyDest = useJourneyDestination(entries);
|
const journeyDest = useJourneyDestination(entries);
|
||||||
|
|
@ -463,7 +455,11 @@ export default function MapPage({
|
||||||
mapData.resolution,
|
mapData.resolution,
|
||||||
areaStats
|
areaStats
|
||||||
);
|
);
|
||||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
const tutorial = useTutorial(
|
||||||
|
initialLoading,
|
||||||
|
isMobile,
|
||||||
|
deferTutorial || mapData.licenseRequired
|
||||||
|
);
|
||||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||||
const densityLabel = t('mapLegend.historicalMatches');
|
const densityLabel = t('mapLegend.historicalMatches');
|
||||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||||
|
|
@ -480,10 +476,13 @@ export default function MapPage({
|
||||||
onExportStateChange,
|
onExportStateChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shareAndSaveView = isMobile
|
||||||
|
? (mapData.currentVisibleView ?? mapData.currentView)
|
||||||
|
: mapData.currentView;
|
||||||
const dashboardParams = useMemo(
|
const dashboardParams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
stateToParams(
|
stateToParams(
|
||||||
mapData.currentView,
|
shareAndSaveView,
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
selectedPOICategories,
|
selectedPOICategories,
|
||||||
|
|
@ -495,12 +494,18 @@ export default function MapPage({
|
||||||
entries,
|
entries,
|
||||||
features,
|
features,
|
||||||
filters,
|
filters,
|
||||||
mapData.currentView,
|
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
selectedPOICategories,
|
selectedPOICategories,
|
||||||
shareCode,
|
shareCode,
|
||||||
|
shareAndSaveView,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
const handleSaveSearch = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
await onSaveSearch?.(name, dashboardParams);
|
||||||
|
},
|
||||||
|
[dashboardParams, onSaveSearch]
|
||||||
|
);
|
||||||
const checkoutReturnPath = useMemo(
|
const checkoutReturnPath = useMemo(
|
||||||
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
||||||
[dashboardParams]
|
[dashboardParams]
|
||||||
|
|
@ -614,7 +619,7 @@ export default function MapPage({
|
||||||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||||||
filterImpacts={filterCounts.impacts}
|
filterImpacts={filterCounts.impacts}
|
||||||
onClearAll={handleClearAll}
|
onClearAll={handleClearAll}
|
||||||
onSaveSearch={onSaveSearch}
|
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
|
||||||
savingSearch={savingSearch}
|
savingSearch={savingSearch}
|
||||||
destinationDropdownPortal={options?.destinationDropdownPortal}
|
destinationDropdownPortal={options?.destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function TravelTimeCard({
|
||||||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-2 md:gap-0.5">
|
||||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
|
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
getPoiFilterMeta,
|
getPoiFilterMeta,
|
||||||
getPoiFilterName,
|
getPoiFilterName,
|
||||||
replacePoiFilterKeySelection,
|
replacePoiFilterKeySelection,
|
||||||
|
usesFixedPoiDistanceScale,
|
||||||
} from '../../../lib/poi-distance-filter';
|
} from '../../../lib/poi-distance-filter';
|
||||||
import { PoiTypeDropdown } from './PoiTypeDropdown';
|
import { PoiTypeDropdown } from './PoiTypeDropdown';
|
||||||
import { SliderLabels } from './SliderLabels';
|
import { SliderLabels } from './SliderLabels';
|
||||||
|
|
@ -69,38 +70,45 @@ export function PoiDistanceFilterCard({
|
||||||
const isActive = activeFeature === poiFeature.name;
|
const isActive = activeFeature === poiFeature.name;
|
||||||
const isPinned = pinnedFeature === poiFeature.name;
|
const isPinned = pinnedFeature === poiFeature.name;
|
||||||
const hist = selectedFeature.histogram;
|
const hist = selectedFeature.histogram;
|
||||||
|
const fixedDistanceScale = usesFixedPoiDistanceScale(selectedFeature);
|
||||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||||
const dataMax = hist?.max ?? selectedFeature.max ?? 5;
|
const dataMax = hist?.max ?? selectedFeature.max ?? 5;
|
||||||
const displayValue =
|
const sliderMin = selectedFeature.min ?? dataMin;
|
||||||
|
const sliderMax = selectedFeature.max ?? dataMax;
|
||||||
|
const rawDisplayValue =
|
||||||
isActive && dragValue
|
isActive && dragValue
|
||||||
? dragValue
|
? dragValue
|
||||||
: (filters[poiFeature.name] as [number, number]) || [dataMin, dataMax];
|
: (filters[poiFeature.name] as [number, number]) ||
|
||||||
const scale = percentileScale;
|
(fixedDistanceScale ? [sliderMin, sliderMax] : [dataMin, dataMax]);
|
||||||
const clampMin = displayValue[0] <= dataMin;
|
const displayValue = fixedDistanceScale
|
||||||
const clampMax = displayValue[1] >= dataMax;
|
? clampPoiFilterRange(rawDisplayValue, selectedFeature)
|
||||||
const isAtMin = displayValue[0] === dataMin;
|
: rawDisplayValue;
|
||||||
const isAtMax = displayValue[1] === dataMax;
|
const scale = fixedDistanceScale ? undefined : percentileScale;
|
||||||
|
const clampMin = fixedDistanceScale ? displayValue[0] <= sliderMin : displayValue[0] <= dataMin;
|
||||||
|
const clampMax = fixedDistanceScale ? displayValue[1] >= sliderMax : displayValue[1] >= dataMax;
|
||||||
|
const isAtMin = fixedDistanceScale ? false : displayValue[0] === dataMin;
|
||||||
|
const isAtMax = fixedDistanceScale ? false : displayValue[1] === dataMax;
|
||||||
const sliderValue: [number, number] = scale
|
const sliderValue: [number, number] = scale
|
||||||
? [
|
? [
|
||||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||||
]
|
]
|
||||||
: [
|
: [clampMin ? sliderMin : displayValue[0], clampMax ? sliderMax : displayValue[1]];
|
||||||
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
|
|
||||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
|
||||||
];
|
|
||||||
|
|
||||||
const replacePoiFeature = (nextFeatureName: string) => {
|
const replacePoiFeature = (nextFeatureName: string) => {
|
||||||
const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName);
|
const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName);
|
||||||
if (nextName === poiFeature.name) return;
|
if (nextName === poiFeature.name) return;
|
||||||
|
|
||||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||||
|
const nextFixedDistanceScale = usesFixedPoiDistanceScale(nextFeature);
|
||||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||||
|
const nextSliderMin = nextFeature?.min ?? nextDataMin;
|
||||||
|
const nextSliderMax = nextFeature?.max ?? nextDataMax;
|
||||||
const nextRange = clampPoiFilterRange(
|
const nextRange = clampPoiFilterRange(
|
||||||
[
|
[
|
||||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
clampMin ? (nextFixedDistanceScale ? nextSliderMin : nextDataMin) : displayValue[0],
|
||||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
clampMax ? (nextFixedDistanceScale ? nextSliderMax : nextDataMax) : displayValue[1],
|
||||||
],
|
],
|
||||||
nextFeature
|
nextFeature
|
||||||
);
|
);
|
||||||
|
|
@ -156,14 +164,9 @@ export function PoiDistanceFilterCard({
|
||||||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Slider
|
<Slider
|
||||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
min={scale ? 0 : sliderMin}
|
||||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
max={scale ? 100 : sliderMax}
|
||||||
step={
|
step={scale ? 1 : (selectedFeature.step ?? (sliderMax - sliderMin) / 100)}
|
||||||
scale
|
|
||||||
? 1
|
|
||||||
: (selectedFeature.step ??
|
|
||||||
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
|
|
||||||
}
|
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
onValueChange={
|
onValueChange={
|
||||||
scale
|
scale
|
||||||
|
|
@ -177,16 +180,16 @@ export function PoiDistanceFilterCard({
|
||||||
}
|
}
|
||||||
: ([min, max]) =>
|
: ([min, max]) =>
|
||||||
onDragChange([
|
onDragChange([
|
||||||
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
|
min <= sliderMin ? sliderMin : min,
|
||||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
max >= sliderMax ? sliderMax : max,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
onPointerDown={() => onDragStart(poiFeature.name)}
|
onPointerDown={() => onDragStart(poiFeature.name)}
|
||||||
onPointerUp={() => onDragEnd()}
|
onPointerUp={() => onDragEnd()}
|
||||||
/>
|
/>
|
||||||
<SliderLabels
|
<SliderLabels
|
||||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
min={scale ? 0 : sliderMin}
|
||||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
max={scale ? 100 : sliderMax}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
displayValues={displayValue}
|
displayValues={displayValue}
|
||||||
isAtMin={isAtMin}
|
isAtMin={isAtMin}
|
||||||
|
|
|
||||||
|
|
@ -151,13 +151,6 @@ export function DesktopMapPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tutorial="map" className="flex-1 relative">
|
<div data-tutorial="map" className="flex-1 relative">
|
||||||
{tutorial.run && (
|
|
||||||
<div
|
|
||||||
data-tutorial="map-anchor"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-teal-500 shadow-lg ring-4 ring-teal-500/30 dark:border-navy-950"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Suspense fallback={<MapFallback />}>
|
<Suspense fallback={<MapFallback />}>
|
||||||
<Map
|
<Map
|
||||||
data={mapData.data}
|
data={mapData.data}
|
||||||
|
|
@ -186,6 +179,7 @@ export function DesktopMapPage({
|
||||||
onCurrentLocationFound={onCurrentLocationFound}
|
onCurrentLocationFound={onCurrentLocationFound}
|
||||||
currentLocation={currentLocation}
|
currentLocation={currentLocation}
|
||||||
bounds={mapData.bounds}
|
bounds={mapData.bounds}
|
||||||
|
hideTopCardsWhenNarrow
|
||||||
travelTimeEntries={travelTimeEntries}
|
travelTimeEntries={travelTimeEntries}
|
||||||
densityLabel={densityLabel}
|
densityLabel={densityLabel}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export interface MapPageProps {
|
||||||
onCheckoutLoginClick?: (returnPath?: string) => void;
|
onCheckoutLoginClick?: (returnPath?: string) => void;
|
||||||
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
||||||
deferTutorial?: boolean;
|
deferTutorial?: boolean;
|
||||||
onSaveSearch?: (name: string) => Promise<void>;
|
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
|
||||||
savingSearch?: boolean;
|
savingSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { AuthUser } from '../../hooks/useAuth';
|
import type { AuthUser } from '../../hooks/useAuth';
|
||||||
import { shortenUrl, prewarmScreenshot } from '../../lib/api';
|
import { shortenUrl, prewarmScreenshot, paramsWithLanguage } from '../../lib/api';
|
||||||
import { copyToClipboard } from '../../lib/clipboard';
|
import { copyToClipboard } from '../../lib/clipboard';
|
||||||
import { DownloadIcon } from './icons/DownloadIcon';
|
import { DownloadIcon } from './icons/DownloadIcon';
|
||||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||||
|
|
@ -95,7 +95,7 @@ export default function Header({
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
@ -140,17 +140,21 @@ export default function Header({
|
||||||
doCopy(window.location.href);
|
doCopy(window.location.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prewarmScreenshot(params);
|
prewarmScreenshot(params, i18n.language);
|
||||||
setSharing(true);
|
setSharing(true);
|
||||||
try {
|
try {
|
||||||
const shortUrl = await shortenUrl(params);
|
const shortUrl = await shortenUrl(params, i18n.language);
|
||||||
doCopy(shortUrl);
|
doCopy(shortUrl);
|
||||||
} catch {
|
} catch {
|
||||||
doCopy(window.location.href);
|
doCopy(
|
||||||
|
activePage === 'dashboard'
|
||||||
|
? `${window.location.origin}/dashboard?${paramsWithLanguage(params, i18n.language)}`
|
||||||
|
: window.location.href
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSharing(false);
|
setSharing(false);
|
||||||
}
|
}
|
||||||
}, [activePage, dashboardParams, doCopy]);
|
}, [activePage, dashboardParams, doCopy, i18n.language]);
|
||||||
|
|
||||||
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
|
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default function LicenseSuccessModal({
|
||||||
: t('licenseSuccess.description');
|
: t('licenseSuccess.description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||||
{isSuccess && (
|
{isSuccess && (
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
{particles.map((p) => (
|
{particles.map((p) => (
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
getPoiDistanceFeatureName,
|
getPoiDistanceFeatureName,
|
||||||
getPoiDistanceFilterKeyId,
|
getPoiDistanceFilterKeyId,
|
||||||
normalizePoiDistanceFilters,
|
normalizePoiDistanceFilters,
|
||||||
|
usesFixedPoiDistanceScale,
|
||||||
type PoiFilterName,
|
type PoiFilterName,
|
||||||
} from '../lib/poi-distance-filter';
|
} from '../lib/poi-distance-filter';
|
||||||
|
|
||||||
|
|
@ -256,6 +257,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
? features.find((feature) => feature.name === defaultPoiFeatureName)
|
? features.find((feature) => feature.name === defaultPoiFeatureName)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!defaultPoiFeatureName) return prev;
|
if (!defaultPoiFeatureName) return prev;
|
||||||
|
const fixedDistanceScale = usesFixedPoiDistanceScale(defaultPoiFeature);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -264,8 +266,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
defaultPoiFeatureName,
|
defaultPoiFeatureName,
|
||||||
poiDistanceFilterIdRef.current++
|
poiDistanceFilterIdRef.current++
|
||||||
)]: [
|
)]: [
|
||||||
defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0,
|
fixedDistanceScale
|
||||||
defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5,
|
? (defaultPoiFeature?.min ?? 0)
|
||||||
|
: (defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0),
|
||||||
|
fixedDistanceScale
|
||||||
|
? (defaultPoiFeature?.max ?? 5)
|
||||||
|
: (defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
frontend/src/lib/active-filter-scroll.ts
Normal file
12
frontend/src/lib/active-filter-scroll.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export function findActiveFilterElement(root: ParentNode | null, filterName: string) {
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
const cards = root.querySelectorAll<HTMLElement>('[data-filter-name]');
|
||||||
|
for (let i = cards.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (cards[i].dataset.filterName === filterName) {
|
||||||
|
return cards[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta } from '../types';
|
||||||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
import { apiUrl, assertOk, buildFilterString, isAbortError, paramsWithLanguage } from './api';
|
||||||
import { createSchoolFilterKey } from './school-filter';
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||||
|
|
@ -38,6 +38,11 @@ describe('api utilities', () => {
|
||||||
expect(isAbortError(regular)).toBe(false);
|
expect(isAbortError(regular)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds supported language parameters without overriding explicit languages', () => {
|
||||||
|
expect(paramsWithLanguage('lat=51.5&lon=-0.1', 'fr-FR')).toBe('lat=51.5&lon=-0.1&lang=fr');
|
||||||
|
expect(paramsWithLanguage('lat=51.5&lang=de', 'fr')).toBe('lat=51.5&lang=de');
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes numeric, absolute, and enum filters for backend routes', () => {
|
it('serializes numeric, absolute, and enum filters for backend routes', () => {
|
||||||
const features: FeatureMeta[] = [
|
const features: FeatureMeta[] = [
|
||||||
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
|
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { getElectionVoteShareFeatureName } from './election-filter';
|
||||||
import { getEthnicityFeatureName } from './ethnicity-filter';
|
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||||
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||||
|
|
||||||
|
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
|
||||||
|
|
||||||
export function logNonAbortError(label: string, error: unknown): void {
|
export function logNonAbortError(label: string, error: unknown): void {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -42,6 +44,36 @@ export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||||
return query ? `${path}?${query}` : path;
|
return query ? `${path}?${query}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSupportedLanguage(value: string | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (SCREENSHOT_LANGUAGES.has(lower)) return lower;
|
||||||
|
|
||||||
|
const prefix = lower.split('-')[0];
|
||||||
|
if (SCREENSHOT_LANGUAGES.has(prefix)) return prefix;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserLanguage(): string | null {
|
||||||
|
if (typeof navigator === 'undefined') return null;
|
||||||
|
const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||||
|
for (const language of languages) {
|
||||||
|
const supported = toSupportedLanguage(language);
|
||||||
|
if (supported) return supported;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paramsWithLanguage(params: string, language?: string): string {
|
||||||
|
const qs = new URLSearchParams(params.replace(/^\?/, ''));
|
||||||
|
if (!qs.has('lang')) {
|
||||||
|
const supported = toSupportedLanguage(language) ?? browserLanguage();
|
||||||
|
if (supported) qs.set('lang', supported);
|
||||||
|
}
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWithRetry<T>(
|
export async function fetchWithRetry<T>(
|
||||||
url: string,
|
url: string,
|
||||||
onSuccess: (data: T) => void,
|
onSuccess: (data: T) => void,
|
||||||
|
|
@ -65,17 +97,19 @@ export async function fetchWithRetry<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
||||||
export function prewarmScreenshot(params: string): void {
|
export function prewarmScreenshot(params: string, language?: string): void {
|
||||||
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
const qs = new URLSearchParams(paramsWithLanguage(params, language));
|
||||||
|
qs.set('og', '1');
|
||||||
|
fetch(apiUrl('screenshot', qs), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shortenUrl(params: string): Promise<string> {
|
export async function shortenUrl(params: string, language?: string): Promise<string> {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
apiUrl('shorten'),
|
apiUrl('shorten'),
|
||||||
authHeaders({
|
authHeaders({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ params }),
|
body: JSON.stringify({ params: paramsWithLanguage(params, language) }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ describe('external property search URLs', () => {
|
||||||
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
|
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses Rightmove this-area-only radius when an exact postcode identifier is provided', () => {
|
it('uses Rightmove quarter-mile radius when an exact postcode identifier is provided', () => {
|
||||||
const urls = buildPropertySearchUrls({
|
const urls = buildPropertySearchUrls({
|
||||||
location: {
|
location: {
|
||||||
lat: 51.501,
|
lat: 51.501,
|
||||||
|
|
@ -93,7 +93,28 @@ describe('external property search URLs', () => {
|
||||||
|
|
||||||
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
|
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
|
||||||
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^837246');
|
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^837246');
|
||||||
|
expect(rightmove.searchParams.get('radius')).toBe('0.25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Rightmove outcode-only radius when an outcode identifier is provided', () => {
|
||||||
|
const urls = buildPropertySearchUrls({
|
||||||
|
location: {
|
||||||
|
lat: 51.501,
|
||||||
|
lon: -0.141,
|
||||||
|
resolution: 8,
|
||||||
|
postcode: 'SW1A 1AA',
|
||||||
|
isPostcode: false,
|
||||||
|
},
|
||||||
|
rightmoveLocationId: 'OUTCODE^2506',
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightmove = new URL(urls!.rightmove!);
|
||||||
|
|
||||||
|
expect(rightmove.searchParams.get('locationIdentifier')).toBe('OUTCODE^2506');
|
||||||
expect(rightmove.searchParams.get('radius')).toBe('0.0');
|
expect(rightmove.searchParams.get('radius')).toBe('0.0');
|
||||||
|
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.5');
|
||||||
|
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds a same-origin Rightmove redirect for exact postcode clicks', () => {
|
it('builds a same-origin Rightmove redirect for exact postcode clicks', () => {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
|
||||||
12: 1,
|
12: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const POSTCODE_RADIUS_MILES = 0.25;
|
||||||
|
|
||||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||||
|
const RIGHTMOVE_OUTCODE_RADIUS_MILES = '0.0';
|
||||||
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||||
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
||||||
|
|
||||||
|
|
@ -111,7 +114,7 @@ export function buildPropertySearchUrls({
|
||||||
const { postcode, resolution, isPostcode } = location;
|
const { postcode, resolution, isPostcode } = location;
|
||||||
if (!postcode) return null;
|
if (!postcode) return null;
|
||||||
|
|
||||||
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
const radiusMiles = isPostcode ? POSTCODE_RADIUS_MILES : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||||
|
|
||||||
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
|
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
|
||||||
const minPrice =
|
const minPrice =
|
||||||
|
|
@ -150,8 +153,8 @@ export function buildPropertySearchUrls({
|
||||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||||
rmParams.set(
|
rmParams.set(
|
||||||
'radius',
|
'radius',
|
||||||
isPostcode && rightmoveLocationId.startsWith('POSTCODE^')
|
rightmoveLocationId.startsWith('OUTCODE^')
|
||||||
? '0.0'
|
? RIGHTMOVE_OUTCODE_RADIUS_MILES
|
||||||
: String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))
|
: String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))
|
||||||
);
|
);
|
||||||
if (minPrice !== undefined)
|
if (minPrice !== undefined)
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,17 @@ import {
|
||||||
POI_COUNT_2KM_FILTER_NAME,
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
POI_DISTANCE_FILTER_NAME,
|
POI_DISTANCE_FILTER_NAME,
|
||||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||||
|
clampPoiFilterRange,
|
||||||
getPoiFilterFeatureOptions,
|
getPoiFilterFeatureOptions,
|
||||||
getPoiFilterName,
|
getPoiFilterName,
|
||||||
} from './poi-distance-filter';
|
} from './poi-distance-filter';
|
||||||
|
|
||||||
const numeric = (name: string): FeatureMeta => ({
|
const numeric = (name: string, overrides: Partial<FeatureMeta> = {}): FeatureMeta => ({
|
||||||
name,
|
name,
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 5,
|
max: 5,
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('poi-distance-filter', () => {
|
describe('poi-distance-filter', () => {
|
||||||
|
|
@ -57,4 +59,13 @@ describe('poi-distance-filter', () => {
|
||||||
);
|
);
|
||||||
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clamps fixed amenity distance scales to the 0-5km slider bounds', () => {
|
||||||
|
const feature = numeric('Distance to nearest amenity (Cafe) (km)', {
|
||||||
|
absolute: true,
|
||||||
|
histogram: { min: 0.2, max: 12, p1: 0.3, p99: 9, counts: [1, 2, 3] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clampPoiFilterRange([-1, 8], feature)).toEqual([0, 5]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,10 @@ export function isPoiDistanceFeatureName(name: string): boolean {
|
||||||
return isDynamicPoiDistanceFeatureName(name);
|
return isDynamicPoiDistanceFeatureName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usesFixedPoiDistanceScale(feature?: FeatureMeta): boolean {
|
||||||
|
return Boolean(feature?.absolute && isPoiDistanceFeatureName(feature.name));
|
||||||
|
}
|
||||||
|
|
||||||
export function isPoiFilterFeatureName(name: string): boolean {
|
export function isPoiFilterFeatureName(name: string): boolean {
|
||||||
return getPoiMetric(name) != null;
|
return getPoiMetric(name) != null;
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +267,7 @@ export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterN
|
||||||
detail: config.detail,
|
detail: config.detail,
|
||||||
source: sourceFeature?.source ?? 'osm-pois',
|
source: sourceFeature?.source ?? 'osm-pois',
|
||||||
suffix: config.suffix,
|
suffix: config.suffix,
|
||||||
|
absolute: sourceFeature?.absolute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,8 +300,12 @@ export function clampPoiFilterRange(
|
||||||
value: [number, number],
|
value: [number, number],
|
||||||
feature?: FeatureMeta
|
feature?: FeatureMeta
|
||||||
): [number, number] {
|
): [number, number] {
|
||||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
const min = usesFixedPoiDistanceScale(feature)
|
||||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
? (feature?.min ?? 0)
|
||||||
|
: (feature?.histogram?.min ?? feature?.min ?? 0);
|
||||||
|
const max = usesFixedPoiDistanceScale(feature)
|
||||||
|
? (feature?.max ?? 5)
|
||||||
|
: (feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]));
|
||||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
isPoiDistanceFilterName,
|
isPoiDistanceFilterName,
|
||||||
type PoiFilterName,
|
type PoiFilterName,
|
||||||
} from './poi-distance-filter';
|
} from './poi-distance-filter';
|
||||||
|
import { dedupeTravelTimeEntries } from './travel-params';
|
||||||
|
|
||||||
const POI_NONE_PARAM = '__none';
|
const POI_NONE_PARAM = '__none';
|
||||||
|
|
||||||
|
|
@ -280,7 +281,7 @@ export function parseUrlState(): UrlState {
|
||||||
entries.push({ mode, slug, label, timeRange, useBest });
|
entries.push({ mode, slug, label, timeRange, useBest });
|
||||||
}
|
}
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
result.travelTime = { entries };
|
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,7 +380,7 @@ export function stateToParams(
|
||||||
|
|
||||||
// Travel time: repeated `tt` params
|
// Travel time: repeated `tt` params
|
||||||
if (travelTimeEntries) {
|
if (travelTimeEntries) {
|
||||||
for (const entry of travelTimeEntries) {
|
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
|
||||||
if (!entry.slug) continue;
|
if (!entry.slug) continue;
|
||||||
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||||
if (entry.useBest) val += ':b';
|
if (entry.useBest) val += ':b';
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ export interface PricePoint {
|
||||||
export interface FilterExclusion {
|
export interface FilterExclusion {
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||||
direction: 'lower_min' | 'raise_max' | 'allow_value';
|
direction: 'lower_min' | 'raise_max' | 'allow_value' | 'missing_value';
|
||||||
value?: number;
|
value?: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { chromium, type Browser, type BrowserContext, type Page, type Route } from 'playwright';
|
import {
|
||||||
|
chromium,
|
||||||
|
type Browser,
|
||||||
|
type BrowserContext,
|
||||||
|
type Page,
|
||||||
|
type Request,
|
||||||
|
type Route,
|
||||||
|
} from 'playwright';
|
||||||
import { existsSync, readdirSync } from 'fs';
|
import { existsSync, readdirSync } from 'fs';
|
||||||
import { NetworkCache } from './network-cache.js';
|
import { NetworkCache } from './network-cache.js';
|
||||||
|
|
||||||
|
|
@ -234,13 +241,13 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
|
||||||
const page = await acquirePage();
|
const page = await acquirePage();
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
|
|
||||||
let postLoadNavigations = 0;
|
let postLoadDocumentNavigations = 0;
|
||||||
let navigationLoopDetected = false;
|
let navigationLoopDetected = false;
|
||||||
const mainFrame = page.mainFrame();
|
const mainFrame = page.mainFrame();
|
||||||
const onNavigated = (frame: ReturnType<typeof page.mainFrame>) => {
|
const onNavigationRequest = (request: Request) => {
|
||||||
if (frame !== mainFrame) return;
|
if (request.frame() !== mainFrame || !request.isNavigationRequest()) return;
|
||||||
postLoadNavigations += 1;
|
postLoadDocumentNavigations += 1;
|
||||||
if (postLoadNavigations > MAX_POST_LOAD_NAVIGATIONS) {
|
if (postLoadDocumentNavigations > MAX_POST_LOAD_NAVIGATIONS) {
|
||||||
navigationLoopDetected = true;
|
navigationLoopDetected = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -265,10 +272,11 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
|
||||||
if (response) {
|
if (response) {
|
||||||
console.log(` Navigate: ${(t1 - t0).toFixed(0)}ms (status ${response.status()})`);
|
console.log(` Navigate: ${(t1 - t0).toFixed(0)}ms (status ${response.status()})`);
|
||||||
}
|
}
|
||||||
// Start counting only AFTER the initial navigation completes — the
|
// Start counting only AFTER the initial navigation completes. Count
|
||||||
// initial `goto` itself fires framenavigated, so we'd double-count.
|
// document navigation requests rather than `framenavigated`, because
|
||||||
postLoadNavigations = 0;
|
// SPA history/query updates fire frame navigation events in dev mode.
|
||||||
page.on('framenavigated', onNavigated);
|
postLoadDocumentNavigations = 0;
|
||||||
|
page.on('request', onNavigationRequest);
|
||||||
|
|
||||||
// Wait for the frontend to signal that data is loaded and layers created
|
// Wait for the frontend to signal that data is loaded and layers created
|
||||||
try {
|
try {
|
||||||
|
|
@ -281,7 +289,7 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
|
||||||
|
|
||||||
if (navigationLoopDetected) {
|
if (navigationLoopDetected) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Navigation loop detected (${postLoadNavigations} post-load navigations)`,
|
`Navigation loop detected (${postLoadDocumentNavigations} post-load document navigations)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const t2 = performance.now();
|
const t2 = performance.now();
|
||||||
|
|
@ -302,7 +310,7 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
|
||||||
|
|
||||||
return Buffer.from(screenshot);
|
return Buffer.from(screenshot);
|
||||||
} finally {
|
} finally {
|
||||||
page.off('framenavigated', onNavigated);
|
page.off('request', onNavigationRequest);
|
||||||
// Remove page-level auth route before returning page to pool
|
// Remove page-level auth route before returning page to pool
|
||||||
// so the next screenshot doesn't inherit stale credentials
|
// so the next screenshot doesn't inherit stale credentials
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,13 @@ async fn validate_token(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
||||||
|
if bypass_auth_for_path(req.uri().path()) {
|
||||||
|
let (mut parts, body) = req.into_parts();
|
||||||
|
parts.extensions.insert(OptionalUser(None));
|
||||||
|
let req = Request::from_parts(parts, body);
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
|
|
||||||
let state = req
|
let state = req
|
||||||
.extensions()
|
.extensions()
|
||||||
.get::<Arc<crate::state::AppState>>()
|
.get::<Arc<crate::state::AppState>>()
|
||||||
|
|
@ -150,3 +157,19 @@ pub async fn auth_middleware(req: Request, next: Next) -> Response {
|
||||||
|
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bypass_auth_for_path(path: &str) -> bool {
|
||||||
|
path == "/api/stripe-webhook"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bypass_auth_only_for_stripe_webhook() {
|
||||||
|
assert!(bypass_auth_for_path("/api/stripe-webhook"));
|
||||||
|
assert!(!bypass_auth_for_path("/api/checkout"));
|
||||||
|
assert!(!bypass_auth_for_path("/api/stripe-webhook/extra"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,32 @@ pub enum CheckoutCompletion {
|
||||||
Rejected(String),
|
Rejected(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum PaymentReversalOutcome {
|
||||||
|
Applied {
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
AlreadyHandled {
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
IgnoredPartialRefund {
|
||||||
|
user_id: String,
|
||||||
|
refunded_amount_pence: u64,
|
||||||
|
paid_amount_pence: u64,
|
||||||
|
},
|
||||||
|
NoMatchingCheckout,
|
||||||
|
NotReversible {
|
||||||
|
user_id: String,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum PaymentReinstatementOutcome {
|
||||||
|
Applied { user_id: String },
|
||||||
|
AlreadyHandled { user_id: String },
|
||||||
|
Ignored { user_id: String, reason: String },
|
||||||
|
NoMatchingCheckout,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct VerifiedCheckout {
|
pub struct VerifiedCheckout {
|
||||||
pub reservation_id: String,
|
pub reservation_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
|
@ -54,6 +80,9 @@ struct PendingCheckout {
|
||||||
currency: String,
|
currency: String,
|
||||||
referral_invite_id: String,
|
referral_invite_id: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
payment_intent_id: String,
|
||||||
|
paid_amount_pence: u64,
|
||||||
|
reversal_reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn now_unix_secs() -> u64 {
|
pub fn now_unix_secs() -> u64 {
|
||||||
|
|
@ -424,6 +453,14 @@ async fn complete_verified_checkout_locked(
|
||||||
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
|
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() {
|
if !checkout.referral_invite_id.is_empty() {
|
||||||
mark_referral_invite_used(
|
mark_referral_invite_used(
|
||||||
state,
|
state,
|
||||||
|
|
@ -433,14 +470,6 @@ async fn complete_verified_checkout_locked(
|
||||||
)
|
)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -466,6 +495,135 @@ pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()
|
||||||
set_user_subscription(state, user_id, "licensed").await
|
set_user_subscription(state, user_id, "licensed").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn reverse_license_for_payment_intent(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
reason: &str,
|
||||||
|
refunded_amount_pence: Option<u64>,
|
||||||
|
) -> anyhow::Result<PaymentReversalOutcome> {
|
||||||
|
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||||
|
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||||
|
}
|
||||||
|
if !is_safe_reversal_reason(reason) {
|
||||||
|
return Err(anyhow!("invalid Stripe reversal reason"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||||
|
let checkout = match find_checkout_by_payment_intent_or_checkout_session(
|
||||||
|
state,
|
||||||
|
payment_intent_id,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(checkout) => checkout,
|
||||||
|
None => return Ok(PaymentReversalOutcome::NoMatchingCheckout),
|
||||||
|
};
|
||||||
|
|
||||||
|
let paid_amount_pence = checkout
|
||||||
|
.paid_amount_pence
|
||||||
|
.max(checkout.expected_total_pence);
|
||||||
|
if let Some(refunded_amount_pence) = refunded_amount_pence {
|
||||||
|
if refunded_amount_pence < paid_amount_pence {
|
||||||
|
return Ok(PaymentReversalOutcome::IgnoredPartialRefund {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
refunded_amount_pence,
|
||||||
|
paid_amount_pence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkout.status == "reversed" {
|
||||||
|
return Ok(PaymentReversalOutcome::AlreadyHandled {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(checkout.status.as_str(), "pending" | "expired" | "failed") {
|
||||||
|
mark_checkout_reversed(state, &checkout.id, reason, payment_intent_id).await?;
|
||||||
|
return Ok(PaymentReversalOutcome::Applied {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkout.status != "completed" {
|
||||||
|
return Ok(PaymentReversalOutcome::NotReversible {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
status: checkout.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_other_license = has_other_completed_checkout_for_user(
|
||||||
|
state,
|
||||||
|
&checkout.user_id,
|
||||||
|
&checkout.id,
|
||||||
|
payment_intent_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !has_other_license {
|
||||||
|
revoke_license(state, &checkout.user_id).await?;
|
||||||
|
}
|
||||||
|
mark_checkout_reversed(state, &checkout.id, reason, payment_intent_id).await?;
|
||||||
|
|
||||||
|
Ok(PaymentReversalOutcome::Applied {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reinstate_license_for_payment_intent(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> anyhow::Result<PaymentReinstatementOutcome> {
|
||||||
|
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||||
|
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||||
|
}
|
||||||
|
if !is_safe_reversal_reason(reason) {
|
||||||
|
return Err(anyhow!("invalid Stripe reinstatement reason"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||||
|
let checkout = match find_checkout_by_payment_intent_or_checkout_session(
|
||||||
|
state,
|
||||||
|
payment_intent_id,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(checkout) => checkout,
|
||||||
|
None => return Ok(PaymentReinstatementOutcome::NoMatchingCheckout),
|
||||||
|
};
|
||||||
|
|
||||||
|
if checkout.status == "completed" {
|
||||||
|
return Ok(PaymentReinstatementOutcome::AlreadyHandled {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkout.status != "reversed" {
|
||||||
|
return Ok(PaymentReinstatementOutcome::Ignored {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
reason: format!("checkout status is {}", checkout.status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !checkout.reversal_reason.starts_with("charge.dispute.") {
|
||||||
|
return Ok(PaymentReinstatementOutcome::Ignored {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
reason: format!("checkout was reversed by {}", checkout.reversal_reason),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
grant_license(state, &checkout.user_id).await?;
|
||||||
|
mark_checkout_reinstated(state, &checkout.id, reason).await?;
|
||||||
|
|
||||||
|
Ok(PaymentReinstatementOutcome::Applied {
|
||||||
|
user_id: checkout.user_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||||
|
set_user_subscription(state, user_id, "free").await
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_user_subscription(
|
async fn set_user_subscription(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|
@ -510,20 +668,34 @@ pub async fn mark_referral_invite_used(
|
||||||
let token = get_superuser_token(state).await?;
|
let token = get_superuser_token(state).await?;
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
|
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
|
||||||
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
|
||||||
if existing_used_by == user_id {
|
// A verified Stripe payment must not lose entitlement just because local
|
||||||
return Ok(());
|
// invite reservation bookkeeping expired or moved before webhook delivery.
|
||||||
}
|
match referral_invite_completion_action(&invite, user_id, reservation_id) {
|
||||||
if !existing_used_by.is_empty() {
|
ReferralInviteCompletionAction::AlreadyRecorded => return Ok(()),
|
||||||
return Err(anyhow!("referral invite already used by another account"));
|
ReferralInviteCompletionAction::AlreadyUsedByAnother => {
|
||||||
}
|
warn!(
|
||||||
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
|
invite_id,
|
||||||
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
user_id,
|
||||||
if !reserved_by_id.is_empty() && reserved_by_id != user_id {
|
existing_used_by = invite["used_by_id"].as_str().unwrap_or_default(),
|
||||||
return Err(anyhow!("referral invite reserved by another account"));
|
"Referral invite was already used by another account; preserving verified checkout entitlement"
|
||||||
}
|
);
|
||||||
if !reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id {
|
return Ok(());
|
||||||
return Err(anyhow!("referral invite reserved by another checkout"));
|
}
|
||||||
|
ReferralInviteCompletionAction::Record {
|
||||||
|
reservation_reassigned,
|
||||||
|
} => {
|
||||||
|
if reservation_reassigned {
|
||||||
|
warn!(
|
||||||
|
invite_id,
|
||||||
|
user_id,
|
||||||
|
reservation_id,
|
||||||
|
reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default(),
|
||||||
|
reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default(),
|
||||||
|
"Referral invite reservation moved before webhook completion; verified checkout will consume it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||||
|
|
@ -546,6 +718,36 @@ pub async fn mark_referral_invite_used(
|
||||||
.context("PocketBase invite usage update failed")
|
.context("PocketBase invite usage update failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ReferralInviteCompletionAction {
|
||||||
|
AlreadyRecorded,
|
||||||
|
AlreadyUsedByAnother,
|
||||||
|
Record { reservation_reassigned: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn referral_invite_completion_action(
|
||||||
|
invite: &Value,
|
||||||
|
user_id: &str,
|
||||||
|
reservation_id: &str,
|
||||||
|
) -> ReferralInviteCompletionAction {
|
||||||
|
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
||||||
|
if existing_used_by == user_id {
|
||||||
|
return ReferralInviteCompletionAction::AlreadyRecorded;
|
||||||
|
}
|
||||||
|
if !existing_used_by.is_empty() {
|
||||||
|
return ReferralInviteCompletionAction::AlreadyUsedByAnother;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
|
||||||
|
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
||||||
|
let reservation_reassigned = (!reserved_by_id.is_empty() && reserved_by_id != user_id)
|
||||||
|
|| (!reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id);
|
||||||
|
|
||||||
|
ReferralInviteCompletionAction::Record {
|
||||||
|
reservation_reassigned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_invite_record(
|
async fn fetch_invite_record(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
pb_url: &str,
|
pb_url: &str,
|
||||||
|
|
@ -1038,6 +1240,56 @@ async fn mark_checkout_status(
|
||||||
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_checkout_reversed(
|
||||||
|
state: &AppState,
|
||||||
|
reservation_id: &str,
|
||||||
|
reason: &str,
|
||||||
|
payment_intent_id: &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": "reversed",
|
||||||
|
"reversal_reason": reason,
|
||||||
|
"stripe_payment_intent_id": payment_intent_id,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success(resp)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_checkout_reinstated(
|
||||||
|
state: &AppState,
|
||||||
|
reservation_id: &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": "completed",
|
||||||
|
"reversal_reason": "",
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success(resp).await.with_context(|| {
|
||||||
|
format!("PocketBase checkout reinstatement update failed for {reservation_id}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_checkout_by_stripe_session(
|
async fn find_checkout_by_stripe_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
stripe_session_id: &str,
|
stripe_session_id: &str,
|
||||||
|
|
@ -1067,6 +1319,164 @@ async fn find_checkout_by_stripe_session(
|
||||||
item.map(parse_pending_checkout).transpose()
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_checkout_by_payment_intent_or_checkout_session(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||||
|
if let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? {
|
||||||
|
return Ok(Some(checkout));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(session_id) =
|
||||||
|
fetch_stripe_checkout_session_id_for_payment_intent(state, payment_intent_id).await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut checkout) = find_checkout_by_stripe_session(state, &session_id).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if checkout.payment_intent_id.is_empty() {
|
||||||
|
attach_payment_intent_to_checkout(state, &checkout.id, payment_intent_id).await?;
|
||||||
|
checkout.payment_intent_id = payment_intent_id.to_string();
|
||||||
|
} else if checkout.payment_intent_id != payment_intent_id {
|
||||||
|
mark_checkout_status(state, &checkout.id, "invalid").await?;
|
||||||
|
return Err(anyhow!(
|
||||||
|
"checkout reservation payment intent changed before reversal"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(checkout))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_stripe_checkout_session_id_for_payment_intent(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
let url = format!(
|
||||||
|
"https://api.stripe.com/v1/checkout/sessions?payment_intent={}&limit=1",
|
||||||
|
urlencoding::encode(payment_intent_id)
|
||||||
|
);
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&state.stripe_secret_key, None::<&str>)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Stripe checkout session lookup failed")?;
|
||||||
|
|
||||||
|
ensure_success_ref(&resp)
|
||||||
|
.await
|
||||||
|
.context("Stripe checkout session lookup returned error")?;
|
||||||
|
|
||||||
|
let body: Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse Stripe checkout session lookup")?;
|
||||||
|
Ok(body["data"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|items| items.first())
|
||||||
|
.and_then(|item| item["id"].as_str())
|
||||||
|
.filter(|id| is_safe_stripe_session_id(id))
|
||||||
|
.map(str::to_string))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach_payment_intent_to_checkout(
|
||||||
|
state: &AppState,
|
||||||
|
reservation_id: &str,
|
||||||
|
payment_intent_id: &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!({
|
||||||
|
"stripe_payment_intent_id": payment_intent_id,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success(resp)
|
||||||
|
.await
|
||||||
|
.context("PocketBase checkout payment intent attach failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn has_other_completed_checkout_for_user(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
reservation_id: &str,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
if !is_safe_pocketbase_id(user_id) || !is_safe_pocketbase_id(reservation_id) {
|
||||||
|
return Err(anyhow!("invalid PocketBase id"));
|
||||||
|
}
|
||||||
|
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||||
|
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = get_superuser_token(state).await?;
|
||||||
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
let filter = format!("user=\"{user_id}\" && status=\"completed\"");
|
||||||
|
let url = format!(
|
||||||
|
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=50",
|
||||||
|
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 Some(items) = body["items"].as_array() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(items.iter().any(|item| {
|
||||||
|
let other_id = item["id"].as_str().unwrap_or_default();
|
||||||
|
let other_payment_intent = item["stripe_payment_intent_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default();
|
||||||
|
other_id != reservation_id && other_payment_intent != payment_intent_id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||||
Ok(PendingCheckout {
|
Ok(PendingCheckout {
|
||||||
id: item["id"]
|
id: item["id"]
|
||||||
|
|
@ -1098,6 +1508,15 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
status: item["status"].as_str().unwrap_or_default().to_string(),
|
status: item["status"].as_str().unwrap_or_default().to_string(),
|
||||||
|
payment_intent_id: item["stripe_payment_intent_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
paid_amount_pence: number_field(&item, "paid_amount_pence").unwrap_or(0),
|
||||||
|
reversal_reason: item["reversal_reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1129,6 +1548,14 @@ fn is_safe_pocketbase_id(id: &str) -> bool {
|
||||||
!id.is_empty() && id.len() <= 32 && id.bytes().all(|b| b.is_ascii_alphanumeric())
|
!id.is_empty() && id.len() <= 32 && id.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_safe_reversal_reason(reason: &str) -> bool {
|
||||||
|
!reason.is_empty()
|
||||||
|
&& reason.len() <= 128
|
||||||
|
&& reason
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_success(resp: reqwest::Response) -> anyhow::Result<()> {
|
async fn ensure_success(resp: reqwest::Response) -> anyhow::Result<()> {
|
||||||
if resp.status().is_success() {
|
if resp.status().is_success() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -1182,4 +1609,51 @@ mod tests {
|
||||||
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
|
assert_eq!(expected_total_for_checkout(1, Some("coupon_30")), 1);
|
||||||
assert_eq!(expected_total_for_checkout(999, None), 999);
|
assert_eq!(expected_total_for_checkout(999, None), 999);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referral_invite_completion_records_available_invite() {
|
||||||
|
let invite = serde_json::json!({
|
||||||
|
"used_by_id": "",
|
||||||
|
"reserved_by_id": "",
|
||||||
|
"reserved_checkout_id": "",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
referral_invite_completion_action(&invite, "user123", "checkout123"),
|
||||||
|
ReferralInviteCompletionAction::Record {
|
||||||
|
reservation_reassigned: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referral_invite_completion_records_reassigned_reservation() {
|
||||||
|
let invite = serde_json::json!({
|
||||||
|
"used_by_id": "",
|
||||||
|
"reserved_by_id": "otheruser",
|
||||||
|
"reserved_checkout_id": "othercheckout",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
referral_invite_completion_action(&invite, "user123", "checkout123"),
|
||||||
|
ReferralInviteCompletionAction::Record {
|
||||||
|
reservation_reassigned: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referral_invite_completion_detects_existing_usage() {
|
||||||
|
let used_by_same_user = serde_json::json!({ "used_by_id": "user123" });
|
||||||
|
let used_by_another_user = serde_json::json!({ "used_by_id": "otheruser" });
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
referral_invite_completion_action(&used_by_same_user, "user123", "checkout123"),
|
||||||
|
ReferralInviteCompletionAction::AlreadyRecorded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
referral_invite_completion_action(&used_by_another_user, "user123", "checkout123"),
|
||||||
|
ReferralInviteCompletionAction::AlreadyUsedByAnother
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,8 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
|
||||||
|
|
||||||
pub const GRID_CELL_SIZE: f32 = 0.01;
|
pub const GRID_CELL_SIZE: f32 = 0.01;
|
||||||
pub const MAX_POIS_PER_REQUEST: usize = 10000;
|
pub const MAX_POIS_PER_REQUEST: usize = 10000;
|
||||||
pub const MAX_CELLS_PER_REQUEST: usize = 50000;
|
|
||||||
pub const MAX_ROWS_PER_BOUNDS_QUERY: usize = 2_000_000;
|
|
||||||
pub const MAX_ROWS_PER_EXPORT: usize = 250_000;
|
|
||||||
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
||||||
pub const MAX_PROPERTIES_LIMIT: usize = 500;
|
|
||||||
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
||||||
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
||||||
|
|
||||||
|
|
@ -30,3 +27,10 @@ pub const SERVICE_CALL_TIMEOUT: u64 = 120;
|
||||||
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
|
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
|
||||||
/// Users without a license can only query data within these bounds.
|
/// Users without a license can only query data within these bounds.
|
||||||
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
|
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
|
||||||
|
|
||||||
|
pub const SHARE_CACHE_TTL_SECS: u64 = 300;
|
||||||
|
pub const SHARE_CACHE_MAX_ENTRIES: usize = 1024;
|
||||||
|
pub const MIN_SHARE_ZOOM: f64 = 11.0;
|
||||||
|
pub const MAX_SHARE_ZOOM: f64 = 20.0;
|
||||||
|
pub const MAX_SHARE_LAT_SPAN: f64 = 1.2;
|
||||||
|
pub const MAX_SHARE_LON_SPAN: f64 = 2.0;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ mod checkout_sessions;
|
||||||
mod consts;
|
mod consts;
|
||||||
mod data;
|
mod data;
|
||||||
mod features;
|
mod features;
|
||||||
|
mod language;
|
||||||
mod licensing;
|
mod licensing;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod og_middleware;
|
mod og_middleware;
|
||||||
|
|
@ -496,7 +497,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let shared = Arc::new(SharedState::new(app_state));
|
let shared = Arc::new(SharedState::new(app_state));
|
||||||
|
|
||||||
// Start background PocketBase metrics poller (users, saved searches/properties counts)
|
// Start background PocketBase metrics poller (users, saved searches counts)
|
||||||
pocketbase::start_metrics_poller(shared.clone());
|
pocketbase::start_metrics_poller(shared.clone());
|
||||||
|
|
||||||
let initial_state = shared.load_state();
|
let initial_state = shared.load_state();
|
||||||
|
|
|
||||||
|
|
@ -1085,40 +1085,6 @@ pub async fn ensure_collections(
|
||||||
ensure_notes_field(client, base_url, &token, "saved_searches").await?;
|
ensure_notes_field(client, base_url, &token, "saved_searches").await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !existing.iter().any(|n| n == "saved_properties") {
|
|
||||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
|
||||||
let user_only = Some("user = @request.auth.id".to_string());
|
|
||||||
create_collection(
|
|
||||||
client,
|
|
||||||
base_url,
|
|
||||||
&token,
|
|
||||||
CreateCollection {
|
|
||||||
name: "saved_properties".to_string(),
|
|
||||||
r#type: "base".to_string(),
|
|
||||||
fields: vec![
|
|
||||||
Field::relation("user", &users_id),
|
|
||||||
Field::text("address", true),
|
|
||||||
Field::text("postcode", true),
|
|
||||||
Field::text("data", false),
|
|
||||||
Field::text("notes", false),
|
|
||||||
Field::autodate("created", true, false),
|
|
||||||
Field::autodate("updated", true, true),
|
|
||||||
],
|
|
||||||
list_rule: user_only.clone(),
|
|
||||||
view_rule: user_only.clone(),
|
|
||||||
create_rule: user_only.clone(),
|
|
||||||
update_rule: user_only.clone(),
|
|
||||||
delete_rule: user_only,
|
|
||||||
indexes: Vec::new(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
|
|
||||||
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
|
|
||||||
ensure_notes_field(client, base_url, &token, "saved_properties").await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !existing.iter().any(|n| n == "invites") {
|
if !existing.iter().any(|n| n == "invites") {
|
||||||
create_collection(
|
create_collection(
|
||||||
client,
|
client,
|
||||||
|
|
@ -1460,7 +1426,6 @@ async fn poll_pocketbase_counts(state: &AppState) {
|
||||||
for (collection, metric_name) in [
|
for (collection, metric_name) in [
|
||||||
("users", "pocketbase_users_total"),
|
("users", "pocketbase_users_total"),
|
||||||
("saved_searches", "pocketbase_saved_searches_total"),
|
("saved_searches", "pocketbase_saved_searches_total"),
|
||||||
("saved_properties", "pocketbase_saved_properties_total"),
|
|
||||||
] {
|
] {
|
||||||
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
|
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
|
||||||
gauge!(metric_name).set(total as f64);
|
gauge!(metric_name).set(total as f64);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use crate::state::SharedState;
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CheckoutRequest {
|
pub struct CheckoutRequest {
|
||||||
referral_code: Option<String>,
|
referral_code: Option<String>,
|
||||||
|
return_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -21,6 +22,27 @@ struct CheckoutResponse {
|
||||||
url: String,
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_return_path(path: Option<&str>) -> &str {
|
||||||
|
let Some(path) = path else {
|
||||||
|
return "/pricing";
|
||||||
|
};
|
||||||
|
let path = path.split('#').next().unwrap_or(path);
|
||||||
|
if path.is_empty()
|
||||||
|
|| path.len() > 2048
|
||||||
|
|| !path.starts_with('/')
|
||||||
|
|| path.starts_with("//")
|
||||||
|
|| path.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return "/pricing";
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_query_param(path: &str, key: &str, value: &str) -> String {
|
||||||
|
let separator = if path.contains('?') { '&' } else { '?' };
|
||||||
|
format!("{path}{separator}{key}={value}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a reserved Stripe Checkout session for the lifetime license.
|
/// Create a reserved Stripe Checkout session for the lifetime license.
|
||||||
/// Requires authentication. Referral discounts are issued via invite redemption.
|
/// Requires authentication. Referral discounts are issued via invite redemption.
|
||||||
pub async fn post_checkout(
|
pub async fn post_checkout(
|
||||||
|
|
@ -34,9 +56,13 @@ pub async fn post_checkout(
|
||||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let public_url = &state.public_url;
|
let public_url = state.public_url.trim_end_matches('/');
|
||||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
let return_path = sanitize_return_path(req.return_path.as_deref());
|
||||||
let cancel_url = format!("{public_url}/pricing");
|
let success_url = format!(
|
||||||
|
"{public_url}{}",
|
||||||
|
append_query_param(return_path, "license_success", "1")
|
||||||
|
);
|
||||||
|
let cancel_url = format!("{public_url}{return_path}");
|
||||||
|
|
||||||
if req.referral_code.is_some() {
|
if req.referral_code.is_some() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -46,6 +72,10 @@ pub async fn post_checkout(
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.is_admin || user.subscription == "licensed" {
|
||||||
|
return (StatusCode::CONFLICT, "This account already has full access").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
match start_license_checkout(&state, &user, &success_url, &cancel_url, None, None).await {
|
match start_license_checkout(&state, &user, &success_url, &cancel_url, None, None).await {
|
||||||
Ok(CheckoutStart::Free) => {
|
Ok(CheckoutStart::Free) => {
|
||||||
info!(user_id = %user.id, "Granted free early-bird license");
|
info!(user_id = %user.id, "Granted free early-bird license");
|
||||||
|
|
@ -58,3 +88,38 @@ pub async fn post_checkout(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_return_path_accepts_local_paths_and_strips_fragments() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_return_path(Some("/map?postcode=SW1A#details")),
|
||||||
|
"/map?postcode=SW1A"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_return_path_rejects_external_or_control_paths() {
|
||||||
|
assert_eq!(sanitize_return_path(Some("//evil.test/path")), "/pricing");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_return_path(Some("https://evil.test/path")),
|
||||||
|
"/pricing"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_return_path(Some("/map\nbad")), "/pricing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_query_param_preserves_existing_query_separator() {
|
||||||
|
assert_eq!(
|
||||||
|
append_query_param("/map?postcode=SW1A", "license_success", "1"),
|
||||||
|
"/map?postcode=SW1A&license_success=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
append_query_param("/pricing", "license_success", "1"),
|
||||||
|
"/pricing?license_success=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ use tracing::{info, warn};
|
||||||
use crate::auth::OptionalUser;
|
use crate::auth::OptionalUser;
|
||||||
use crate::consts::POSTCODE_SEARCH_OFFSET;
|
use crate::consts::POSTCODE_SEARCH_OFFSET;
|
||||||
use crate::licensing::{check_license_point, resolve_share_code};
|
use crate::licensing::{check_license_point, resolve_share_code};
|
||||||
use crate::parsing::{
|
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
|
||||||
parse_field_set, parse_filters_with_poi, row_passes_filters, row_passes_poi_filters,
|
|
||||||
};
|
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
use crate::utils::normalize_postcode;
|
use crate::utils::normalize_postcode;
|
||||||
|
|
||||||
use super::hexagon_stats::HexagonStatsResponse;
|
use super::hexagon_stats::{
|
||||||
|
parse_area_stats_field_set, top_filter_exclusions, HexagonStatsResponse,
|
||||||
|
};
|
||||||
use super::stats;
|
use super::stats;
|
||||||
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
||||||
|
|
||||||
|
|
@ -24,8 +24,9 @@ use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_tra
|
||||||
pub struct PostcodeStatsParams {
|
pub struct PostcodeStatsParams {
|
||||||
pub postcode: String,
|
pub postcode: String,
|
||||||
pub filters: Option<String>,
|
pub filters: Option<String>,
|
||||||
/// Comma-separated feature names to include in stats response.
|
/// `;;`-separated feature names to include in stats response.
|
||||||
/// Only listed features are computed; if absent or empty, no features are returned.
|
/// Only listed features are computed. If absent, area stats default to
|
||||||
|
/// displayable groups; if empty, no feature stats are returned.
|
||||||
pub fields: Option<String>,
|
pub fields: Option<String>,
|
||||||
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
|
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
|
||||||
/// Optional min:max applies as a filter (exclude properties outside range).
|
/// Optional min:max applies as a filter (exclude properties outside range).
|
||||||
|
|
@ -80,7 +81,7 @@ pub async fn get_postcode_stats(
|
||||||
let filters_str = params.filters;
|
let filters_str = params.filters;
|
||||||
let has_poi_filters = !parsed_poi_filters.is_empty();
|
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||||
|
|
||||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref());
|
||||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||||
|
|
||||||
|
|
@ -100,26 +101,26 @@ pub async fn get_postcode_stats(
|
||||||
let min_lon = centroid_lon as f64 - offset;
|
let min_lon = centroid_lon as f64 - offset;
|
||||||
let max_lon = centroid_lon as f64 + offset;
|
let max_lon = centroid_lon as f64 + offset;
|
||||||
|
|
||||||
|
let mut area_rows: Vec<usize> = Vec::new();
|
||||||
let mut matching_rows: Vec<usize> = Vec::new();
|
let mut matching_rows: Vec<usize> = Vec::new();
|
||||||
state
|
state
|
||||||
.grid
|
.grid
|
||||||
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
|
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
|
||||||
let row = row_idx as usize;
|
let row = row_idx as usize;
|
||||||
let row_postcode = state.data.postcode(row);
|
let row_postcode = state.data.postcode(row);
|
||||||
if row_postcode == postcode_str
|
if row_postcode != postcode_str {
|
||||||
&& row_passes_filters(
|
return;
|
||||||
row,
|
}
|
||||||
&parsed_filters,
|
|
||||||
&parsed_enum_filters,
|
area_rows.push(row);
|
||||||
feature_data,
|
if row_passes_filters(
|
||||||
num_features,
|
row,
|
||||||
)
|
&parsed_filters,
|
||||||
&& (!has_poi_filters
|
&parsed_enum_filters,
|
||||||
|| row_passes_poi_filters(
|
feature_data,
|
||||||
row,
|
num_features,
|
||||||
&parsed_poi_filters,
|
) && (!has_poi_filters
|
||||||
&state.data.poi_metrics,
|
|| row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics))
|
||||||
))
|
|
||||||
{
|
{
|
||||||
if has_travel
|
if has_travel
|
||||||
&& !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data)
|
&& !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data)
|
||||||
|
|
@ -131,6 +132,19 @@ pub async fn get_postcode_stats(
|
||||||
});
|
});
|
||||||
|
|
||||||
let total_count = matching_rows.len();
|
let total_count = matching_rows.len();
|
||||||
|
let filter_exclusions = if total_count == 0 {
|
||||||
|
top_filter_exclusions(
|
||||||
|
&area_rows,
|
||||||
|
&parsed_filters,
|
||||||
|
&parsed_enum_filters,
|
||||||
|
&parsed_poi_filters,
|
||||||
|
&travel_entries,
|
||||||
|
&travel_data,
|
||||||
|
&state.data,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
let price_history =
|
let price_history =
|
||||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||||
|
|
@ -168,6 +182,7 @@ pub async fn get_postcode_stats(
|
||||||
enum_features: enum_features_out,
|
enum_features: enum_features_out,
|
||||||
price_history,
|
price_history,
|
||||||
central_postcode: None,
|
central_postcode: None,
|
||||||
|
filter_exclusions,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ pub struct PostcodesResponse {
|
||||||
pub struct NearestPostcodeParams {
|
pub struct NearestPostcodeParams {
|
||||||
lat: f64,
|
lat: f64,
|
||||||
lng: f64,
|
lng: f64,
|
||||||
|
log: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -468,15 +469,17 @@ pub async fn get_nearest_postcode(
|
||||||
let postcode = &postcode_data.postcodes[idx];
|
let postcode = &postcode_data.postcodes[idx];
|
||||||
|
|
||||||
// Log location for authenticated users (best-effort, non-blocking)
|
// Log location for authenticated users (best-effort, non-blocking)
|
||||||
if let Some(ref pb_user) = user.0 {
|
if params.log.unwrap_or(true) {
|
||||||
let state = state.clone();
|
if let Some(ref pb_user) = user.0 {
|
||||||
let user_id = pb_user.id.clone();
|
let state = state.clone();
|
||||||
let lat_f64 = params.lat;
|
let user_id = pb_user.id.clone();
|
||||||
let lng_f64 = params.lng;
|
let lat_f64 = params.lat;
|
||||||
let pc = postcode.clone();
|
let lng_f64 = params.lng;
|
||||||
tokio::spawn(async move {
|
let pc = postcode.clone();
|
||||||
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
|
tokio::spawn(async move {
|
||||||
});
|
log_user_location(&state, &user_id, lat_f64, lng_f64, &pc).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(postcode = %postcode, "GET /api/nearest-postcode");
|
info!(postcode = %postcode, "GET /api/nearest-postcode");
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::auth::OptionalUser;
|
use crate::auth::OptionalUser;
|
||||||
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
|
use crate::consts::DEFAULT_PROPERTIES_LIMIT;
|
||||||
use crate::data::RenovationEvent;
|
use crate::data::RenovationEvent;
|
||||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||||
use crate::parsing::{
|
use crate::parsing::{
|
||||||
|
|
@ -273,10 +273,7 @@ pub async fn get_hexagon_properties(
|
||||||
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
|
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
|
||||||
|
|
||||||
let total = matching_rows.len();
|
let total = matching_rows.len();
|
||||||
let limit = params
|
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
|
||||||
.limit
|
|
||||||
.unwrap_or(DEFAULT_PROPERTIES_LIMIT)
|
|
||||||
.min(MAX_PROPERTIES_LIMIT);
|
|
||||||
let offset = params.offset.unwrap_or(0);
|
let offset = params.offset.unwrap_or(0);
|
||||||
let truncated = total > offset + limit;
|
let truncated = total > offset + limit;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use crate::utils::normalize_postcode;
|
||||||
const RIGHTMOVE_TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
const RIGHTMOVE_TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||||
const RIGHTMOVE_HOST: &str = "www.rightmove.co.uk";
|
const RIGHTMOVE_HOST: &str = "www.rightmove.co.uk";
|
||||||
const RIGHTMOVE_FIND_PATH: &str = "/property-for-sale/find.html";
|
const RIGHTMOVE_FIND_PATH: &str = "/property-for-sale/find.html";
|
||||||
|
const RIGHTMOVE_POSTCODE_RADIUS_MILES: &str = "0.25";
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct RightmoveRedirectParams {
|
pub struct RightmoveRedirectParams {
|
||||||
|
|
@ -151,7 +152,10 @@ fn apply_exact_postcode_location(url: &mut Url, postcode: &str, location_identif
|
||||||
"locationIdentifier".to_string(),
|
"locationIdentifier".to_string(),
|
||||||
location_identifier.to_string(),
|
location_identifier.to_string(),
|
||||||
));
|
));
|
||||||
pairs.push(("radius".to_string(), "0.0".to_string()));
|
pairs.push((
|
||||||
|
"radius".to_string(),
|
||||||
|
RIGHTMOVE_POSTCODE_RADIUS_MILES.to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
let mut query = url.query_pairs_mut();
|
let mut query = url.query_pairs_mut();
|
||||||
query.clear();
|
query.clear();
|
||||||
|
|
@ -200,7 +204,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rewrites_rightmove_url_to_exact_postcode_location() {
|
fn rewrites_rightmove_url_to_exact_postcode_location() {
|
||||||
let mut url = Url::parse(
|
let mut url = Url::parse(
|
||||||
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.25&minPrice=100000",
|
"https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&useLocationIdentifier=true&locationIdentifier=OUTCODE%5E2506&radius=0.0&minPrice=100000",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -210,7 +214,7 @@ mod tests {
|
||||||
assert_eq!(pairs.get("searchLocation").unwrap(), "SW1A 1AA");
|
assert_eq!(pairs.get("searchLocation").unwrap(), "SW1A 1AA");
|
||||||
assert_eq!(pairs.get("useLocationIdentifier").unwrap(), "true");
|
assert_eq!(pairs.get("useLocationIdentifier").unwrap(), "true");
|
||||||
assert_eq!(pairs.get("locationIdentifier").unwrap(), "POSTCODE^837246");
|
assert_eq!(pairs.get("locationIdentifier").unwrap(), "POSTCODE^837246");
|
||||||
assert_eq!(pairs.get("radius").unwrap(), "0.0");
|
assert_eq!(pairs.get("radius").unwrap(), "0.25");
|
||||||
assert_eq!(pairs.get("minPrice").unwrap(), "100000");
|
assert_eq!(pairs.get("minPrice").unwrap(), "100000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue