improve
All checks were successful
Build and publish Docker image / build-and-push (push) Successful in 2m18s
CI / Check (push) Successful in 14m12s

This commit is contained in:
Andras Schmelczer 2026-06-26 21:08:08 +01:00
parent f03edb86c1
commit e9e47fd811
10 changed files with 141 additions and 24 deletions

View file

@ -7,7 +7,7 @@ import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData'; import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings'; import { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params'; import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters'; import { useFilters, countEffectiveFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection'; import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize'; import { usePaneResize } from '../../hooks/usePaneResize';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
@ -159,17 +159,49 @@ export default function MapPage({
user?.isAdmin === true; user?.isAdmin === true;
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS; const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
const filterLimit = filtersUnlimited ? null : effectiveFilterCap; const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
// On a shared link, non-paying users may view and adjust the shared filters but // On a shared link, anonymous visitors may view and adjust the shared filters but
// not add new ones — so a richer shared search can't become a way around the cap. // not add new ones. Keyed off login state (not paid status) so the "create a free
const lockFilterAdds = !filtersUnlimited && !!shareCode; // account to build your own" upsell isn't a dead-end — once registered, the user can
// Which upsell the modal frames: a shared-link block, or a plain filter-cap hit. // add filters up to their normal cap right there on the shared search. The
const upgradeReason: 'filters' | 'shared' = lockFilterAdds ? 'shared' : 'filters'; // `!filtersUnlimited` term also excludes the non-interactive screenshot/OG render
// (treated as unlimited above), which must show the shared filters in full.
const lockFilterAdds = !isLoggedIn && !filtersUnlimited && !!shareCode;
// A shared/bookmarked link can encode more filters than the viewer's tier allows.
// The initial set is then trimmed to the cap (the server 400s the full request), so
// the viewer would silently see a broader result than was shared. Count the effective
// filters (folded + known to the backend, matching what the server counts) so stale
// or unknown keys don't raise a false alarm; recomputed once features finish loading.
const initialEffectiveFilterCount = useMemo(
() =>
countEffectiveFilters(initialFilters, features) + (initialTravelTime?.entries?.length ?? 0),
[initialFilters, features, initialTravelTime]
);
// Gate on features being loaded: until then countEffectiveFilters can't drop unknown
// keys and would over-count (firing the popup for a set that actually fits the cap).
const sharedSearchTrimmed =
features.length > 0 && !filtersUnlimited && initialEffectiveFilterCount > effectiveFilterCap;
// Which upsell the modal frames: a shared-link context (lock, or a trimmed shared
// search), or a plain filter-cap hit.
const upgradeReason: 'filters' | 'shared' =
lockFilterAdds || sharedSearchTrimmed ? 'shared' : 'filters';
const [filterLimitHit, setFilterLimitHit] = useState(false); const [filterLimitHit, setFilterLimitHit] = useState(false);
const handleFilterLimitReached = useCallback(() => { const handleFilterLimitReached = useCallback(() => {
trackEvent('Upgrade Modal Shown'); trackEvent('Upgrade Modal Shown');
setFilterLimitHit(true); setFilterLimitHit(true);
}, []); }, []);
// Pop the upgrade modal once when the shared/bookmarked search carried more filters
// than the viewer's tier allows — so trimmed filters are surfaced, not silently
// dropped. Fires when the signal first turns true (which may be after features finish
// loading), and only once so dismissing it sticks.
const overCapPopupShownRef = useRef(false);
useEffect(() => {
if (sharedSearchTrimmed && !overCapPopupShownRef.current) {
overCapPopupShownRef.current = true;
handleFilterLimitReached();
}
}, [sharedSearchTrimmed, handleFilterLimitReached]);
// Travel-time destinations count toward the same cap as feature filters (each one // Travel-time destinations count toward the same cap as feature filters (each one
// restricts which properties match). Cap an over-budget initial travel set (from a // restricts which properties match). Cap an over-budget initial travel set (from a
// bookmarked/shared URL), preferring feature filters, so the first data request // bookmarked/shared URL), preferring feature filters, so the first data request

View file

@ -65,11 +65,11 @@ export default function UpgradeModal({
// Registered users see the lifetime upsell directly; anonymous users are nudged to // Registered users see the lifetime upsell directly; anonymous users are nudged to
// create a free account first (more filters + save/share/export), with the paid // create a free account first (more filters + save/share/export), with the paid
// tier offered as the route to unlimited. // tier offered as the route to unlimited.
// The `shared` reason only applies to anonymous visitors (lockFilterAdds is
// anonymous-only), so logged-in users always get the plain lifetime-upsell copy.
const title = isLoggedIn ? t('upgrade.title') : t('upgrade.titleRegister'); const title = isLoggedIn ? t('upgrade.title') : t('upgrade.titleRegister');
const description = isLoggedIn const description = isLoggedIn
? reason === 'shared' ? t('upgrade.description')
? t('upgrade.descriptionSharedUpgrade')
: t('upgrade.description')
: reason === 'shared' : reason === 'shared'
? t('upgrade.descriptionSharedRegister') ? t('upgrade.descriptionSharedRegister')
: t('upgrade.descriptionRegister'); : t('upgrade.descriptionRegister');

View file

@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { useFilters } from './useFilters'; import { useFilters, countEffectiveFilters } from './useFilters';
import type { FeatureMeta } from '../types'; import type { FeatureMeta } from '../types';
vi.mock('../lib/analytics', () => ({ vi.mock('../lib/analytics', () => ({
@ -265,6 +265,45 @@ describe('useFilters', () => {
expect(onFilterLimitReached).toHaveBeenCalledTimes(1); expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
}); });
it('strips new keys from a bulk set (AI filters) when lockAdds is set, keeping existing ones', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
const onFilterLimitReached = vi.fn();
// A non-paying user on a shared link runs an AI query, which goes through the
// bulk handleSetFilters path. lockAdds must apply here too (not just per-add),
// otherwise the AI box becomes a way to build a fresh filter set on a shared link.
const { result } = renderHook(() =>
useFilters({
initialFilters: { a: [0, 100] },
features: sixFeatures,
filterLimit: 5,
lockAdds: true,
onFilterLimitReached,
})
);
// A bulk set that adjusts the existing key 'a' alongside new keys ('b','c') keeps
// only 'a' (its new value), drops the new keys, and reports the block.
act(() => {
result.current.handleSetFilters({ a: [10, 90], b: [0, 50], c: [0, 50] });
});
expect(Object.keys(result.current.filters)).toEqual(['a']);
expect(result.current.filters.a).toEqual([10, 90]);
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
// An all-new bulk set (no existing key survives) is restricted to {} and reported.
act(() => {
result.current.handleSetFilters({ d: [0, 50], e: [0, 50] });
});
expect(Object.keys(result.current.filters)).toHaveLength(0);
expect(onFilterLimitReached).toHaveBeenCalledTimes(2);
});
it('does not cap filters when filterLimit is null (licensed users)', () => { it('does not cap filters when filterLimit is null (licensed users)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({ const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name, name,
@ -311,4 +350,51 @@ describe('useFilters', () => {
expect(result.current.dragValue).toBeNull(); expect(result.current.dragValue).toBeNull();
expect(result.current.filters).toEqual({}); expect(result.current.filters).toEqual({});
}); });
it('drops unknown initial keys before applying the cap, not after', () => {
const knownFeatures: FeatureMeta[] = ['a', 'b', 'c'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
// 3 valid keys + 2 stale/unknown keys, cap 3. The unknowns must be dropped first so
// the cap is spent on the 3 real filters (all kept), not wasted on stale keys.
const { result } = renderHook(() =>
useFilters({
initialFilters: {
a: [0, 100],
stale1: [0, 100],
b: [0, 100],
stale2: [0, 100],
c: [0, 100],
},
features: knownFeatures,
filterLimit: 3,
})
);
expect(Object.keys(result.current.filters).sort()).toEqual(['a', 'b', 'c']);
});
});
describe('countEffectiveFilters', () => {
const features: FeatureMeta[] = ['a', 'b', 'c'].map((name) => ({
name,
type: 'numeric',
min: 0,
max: 100,
}));
it('counts only known features, ignoring stale/unknown keys', () => {
// The over-cap shared-link popup keys off this: stale keys must not inflate the count
// and trigger a false "your search was trimmed" alarm.
expect(countEffectiveFilters({ a: [0, 1], b: [0, 1], gone: [0, 1] }, features)).toBe(2);
expect(countEffectiveFilters({ a: [0, 1], b: [0, 1], c: [0, 1] }, features)).toBe(3);
});
it('counts everything when the feature list is empty (still loading)', () => {
expect(countEffectiveFilters({ a: [0, 1], b: [0, 1] }, [])).toBe(2);
});
}); });

View file

@ -133,6 +133,15 @@ function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): F
return changed ? next : filters; return changed ? next : filters;
} }
/** Count the *effective* filters a raw set resolves to: normalized (variant folding)
* and limited to features the backend knows. Mirrors what the hook actually applies
* and what the server counts, so a caller can tell whether a loaded set exceeds the
* cap without re-deriving the fold/unknown-drop logic (and without counting stale or
* unknown keys that would never reach the server). `features` empty counts as-is. */
export function countEffectiveFilters(filters: FeatureFilters, features: FeatureMeta[]): number {
return Object.keys(dropUnknownFilters(normalizeFilters(filters), features)).length;
}
function getNextNumericKeyId( function getNextNumericKeyId(
filters: FeatureFilters, filters: FeatureFilters,
getId: (name: string) => string | null getId: (name: string) => string | null
@ -162,8 +171,10 @@ export function useFilters({
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the // Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
// user's remaining budget — the cap minus slots already taken by travel-time // user's remaining budget — the cap minus slots already taken by travel-time
// filters — so a non-paying user never starts above the cap, which the server // filters — so a non-paying user never starts above the cap, which the server
// would reject (400), blanking the map on first load. // would reject (400), blanking the map on first load. Unknown keys are dropped
const normalized = normalizeFilters(initialFilters); // first WHEN the feature list is already loaded (in-app navigation); on a cold load
// features arrive after this runs, so the prune effect below removes them then.
const normalized = dropUnknownFilters(normalizeFilters(initialFilters), features);
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null; const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
initialFiltersRef.current = initialFiltersRef.current =
budget != null && Object.keys(normalized).length > budget budget != null && Object.keys(normalized).length > budget

View file

@ -637,8 +637,6 @@ const de: Translations = {
title: 'Unbegrenzte Filter freischalten', title: 'Unbegrenzte Filter freischalten',
description: description:
'Du hast alle 5 Filter deines kostenlosen Kontos genutzt. Erhalte lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode und jede Nachbarschaft in England hinweg zu kombinieren. Einmal zahlen, für immer nutzen.', 'Du hast alle 5 Filter deines kostenlosen Kontos genutzt. Erhalte lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode und jede Nachbarschaft in England hinweg zu kombinieren. Einmal zahlen, für immer nutzen.',
descriptionSharedUpgrade:
'Um einer geteilten Suche weitere Filter hinzuzufügen, ist ein lebenslanger Zugang erforderlich — schalte unbegrenzte Filter für jeden Postcode in England frei.',
titleRegister: 'Kostenloses Konto erstellen', titleRegister: 'Kostenloses Konto erstellen',
descriptionRegister: descriptionRegister:
'Kostenlose Konten schalten bis zu 5 Filter sowie Speichern, Teilen und Exportieren frei — keine Kreditkarte erforderlich. Willst du unbegrenzte Filter für ganz England? Wähle den lebenslangen Zugang.', 'Kostenlose Konten schalten bis zu 5 Filter sowie Speichern, Teilen und Exportieren frei — keine Kreditkarte erforderlich. Willst du unbegrenzte Filter für ganz England? Wähle den lebenslangen Zugang.',

View file

@ -626,8 +626,6 @@ const en = {
title: 'Unlock unlimited filters', title: 'Unlock unlimited filters',
description: description:
"You've used all 5 filters on your free account. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.", "You've used all 5 filters on your free account. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.",
descriptionSharedUpgrade:
'Adding more filters to a shared search needs lifetime access — unlock unlimited filters across every postcode in England.',
// Anonymous users — create a free account first. // Anonymous users — create a free account first.
titleRegister: 'Create a free account', titleRegister: 'Create a free account',
descriptionRegister: descriptionRegister:

View file

@ -650,8 +650,6 @@ const fr: Translations = {
title: 'Débloquez les filtres illimités', title: 'Débloquez les filtres illimités',
description: description:
'Vous avez utilisé les 5 filtres de votre compte gratuit. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur chaque code postal et quartier dAngleterre. Un seul paiement, pour toujours.', 'Vous avez utilisé les 5 filtres de votre compte gratuit. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur chaque code postal et quartier dAngleterre. Un seul paiement, pour toujours.',
descriptionSharedUpgrade:
'Ajouter des filtres supplémentaires à une recherche partagée nécessite un accès à vie — débloquez des filtres illimités sur chaque code postal dAngleterre.',
titleRegister: 'Créer un compte gratuit', titleRegister: 'Créer un compte gratuit',
descriptionRegister: descriptionRegister:
'Les comptes gratuits débloquent jusquà 5 filtres ainsi que la sauvegarde, le partage et lexport — sans carte bancaire. Des filtres illimités dans toute lAngleterre ? Passez à laccès à vie.', 'Les comptes gratuits débloquent jusquà 5 filtres ainsi que la sauvegarde, le partage et lexport — sans carte bancaire. Des filtres illimités dans toute lAngleterre ? Passez à laccès à vie.',

View file

@ -622,8 +622,6 @@ const hi: Translations = {
title: 'असीमित फ़िल्टर अनलॉक करें', title: 'असीमित फ़िल्टर अनलॉक करें',
description: description:
'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.', 'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
descriptionSharedUpgrade:
'साझा खोज में और फ़िल्टर जोड़ने के लिए आजीवन पहुँच चाहिए — इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर अनलॉक करें.',
titleRegister: 'मुफ्त खाता बनाएं', titleRegister: 'मुफ्त खाता बनाएं',
descriptionRegister: descriptionRegister:
'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.', 'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.',

View file

@ -641,8 +641,6 @@ const hu: Translations = {
title: 'Oldd fel a korlátlan szűrőket', title: 'Oldd fel a korlátlan szűrőket',
description: description:
'Felhasználtad az ingyenes fiókod mind az 5 szűrőjét. Szerezz élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán és környékén. Egyetlen fizetés, örökre.', 'Felhasználtad az ingyenes fiókod mind az 5 szűrőjét. Szerezz élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán és környékén. Egyetlen fizetés, örökre.',
descriptionSharedUpgrade:
'Egy megosztott kereséshez több szűrőt hozzáadni élethosszig tartó hozzáférést igényel oldd fel a korlátlan szűrőket Anglia minden irányítószámán.',
titleRegister: 'Ingyenes fiók létrehozása', titleRegister: 'Ingyenes fiók létrehozása',
descriptionRegister: descriptionRegister:
'Az ingyenes fiókok akár 5 szűrőt oldanak fel, ráadásul menthetsz, megoszthatsz és exportálhatsz kártya nélkül. Korlátlan szűrőket szeretnél egész Angliában? Válaszd az élethosszig tartó hozzáférést.', 'Az ingyenes fiókok akár 5 szűrőt oldanak fel, ráadásul menthetsz, megoszthatsz és exportálhatsz kártya nélkül. Korlátlan szűrőket szeretnél egész Angliában? Válaszd az élethosszig tartó hozzáférést.',

View file

@ -597,8 +597,6 @@ const zh: Translations = {
registerAndUpgrade: '注册并升级', registerAndUpgrade: '注册并升级',
alreadyHaveAccount: '已有账户?请登录', alreadyHaveAccount: '已有账户?请登录',
continueFree: '暂时继续浏览', continueFree: '暂时继续浏览',
descriptionSharedUpgrade:
'向分享的搜索添加更多筛选条件需要终身访问权限 — 解锁英格兰每个邮编的无限筛选。',
titleRegister: '注册免费账户', titleRegister: '注册免费账户',
descriptionRegister: descriptionRegister:
'免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。', '免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。',