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 { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters';
import { useFilters, countEffectiveFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
@ -159,17 +159,49 @@ export default function MapPage({
user?.isAdmin === true;
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
// On a shared link, non-paying users 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.
const lockFilterAdds = !filtersUnlimited && !!shareCode;
// Which upsell the modal frames: a shared-link block, or a plain filter-cap hit.
const upgradeReason: 'filters' | 'shared' = lockFilterAdds ? 'shared' : 'filters';
// On a shared link, anonymous visitors may view and adjust the shared filters but
// not add new ones. Keyed off login state (not paid status) so the "create a free
// account to build your own" upsell isn't a dead-end — once registered, the user can
// add filters up to their normal cap right there on the shared search. The
// `!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 handleFilterLimitReached = useCallback(() => {
trackEvent('Upgrade Modal Shown');
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
// restricts which properties match). Cap an over-budget initial travel set (from a
// 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
// create a free account first (more filters + save/share/export), with the paid
// 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 description = isLoggedIn
? reason === 'shared'
? t('upgrade.descriptionSharedUpgrade')
: t('upgrade.description')
? t('upgrade.description')
: reason === 'shared'
? t('upgrade.descriptionSharedRegister')
: t('upgrade.descriptionRegister');

View file

@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useFilters } from './useFilters';
import { useFilters, countEffectiveFilters } from './useFilters';
import type { FeatureMeta } from '../types';
vi.mock('../lib/analytics', () => ({
@ -265,6 +265,45 @@ describe('useFilters', () => {
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)', () => {
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
name,
@ -311,4 +350,51 @@ describe('useFilters', () => {
expect(result.current.dragValue).toBeNull();
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;
}
/** 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(
filters: FeatureFilters,
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
// 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
// would reject (400), blanking the map on first load.
const normalized = normalizeFilters(initialFilters);
// would reject (400), blanking the map on first load. Unknown keys are dropped
// 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;
initialFiltersRef.current =
budget != null && Object.keys(normalized).length > budget

View file

@ -637,8 +637,6 @@ const de: Translations = {
title: 'Unbegrenzte Filter freischalten',
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.',
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',
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.',

View file

@ -626,8 +626,6 @@ const en = {
title: 'Unlock unlimited filters',
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.",
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.
titleRegister: 'Create a free account',
descriptionRegister:

View file

@ -650,8 +650,6 @@ const fr: Translations = {
title: 'Débloquez les filtres illimités',
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.',
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',
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.',

View file

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

View file

@ -641,8 +641,6 @@ const hu: Translations = {
title: 'Oldd fel a korlátlan szűrőket',
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.',
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',
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.',

View file

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