diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index a8bb6e2..62c54c6 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -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 diff --git a/frontend/src/components/ui/UpgradeModal.tsx b/frontend/src/components/ui/UpgradeModal.tsx index 60bd6fb..dedd2ba 100644 --- a/frontend/src/components/ui/UpgradeModal.tsx +++ b/frontend/src/components/ui/UpgradeModal.tsx @@ -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'); diff --git a/frontend/src/hooks/useFilters.test.ts b/frontend/src/hooks/useFilters.test.ts index 4f6648f..61f293a 100644 --- a/frontend/src/hooks/useFilters.test.ts +++ b/frontend/src/hooks/useFilters.test.ts @@ -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); + }); }); diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index 27343b4..0dcd2ee 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -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 diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 07b60fa..5e07443 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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.', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a13b540..f2b5857 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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: diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b8905c2..c89c4a3 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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 d’Angleterre. 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 d’Angleterre.', titleRegister: 'Créer un compte gratuit', descriptionRegister: 'Les comptes gratuits débloquent jusqu’à 5 filtres ainsi que la sauvegarde, le partage et l’export — sans carte bancaire. Des filtres illimités dans toute l’Angleterre ? Passez à l’accès à vie.', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index cbee11d..d798267 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -622,8 +622,6 @@ const hi: Translations = { title: 'असीमित फ़िल्टर अनलॉक करें', description: 'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.', - descriptionSharedUpgrade: - 'साझा खोज में और फ़िल्टर जोड़ने के लिए आजीवन पहुँच चाहिए — इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर अनलॉक करें.', titleRegister: 'मुफ्त खाता बनाएं', descriptionRegister: 'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index ccc5abc..1e91bef 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -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.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 84f1ff3..3cc8189 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -597,8 +597,6 @@ const zh: Translations = { registerAndUpgrade: '注册并升级', alreadyHaveAccount: '已有账户?请登录', continueFree: '暂时继续浏览', - descriptionSharedUpgrade: - '向分享的搜索添加更多筛选条件需要终身访问权限 — 解锁英格兰每个邮编的无限筛选。', titleRegister: '注册免费账户', descriptionRegister: '免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。',