improve
This commit is contained in:
parent
f03edb86c1
commit
e9e47fd811
10 changed files with 141 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -622,8 +622,6 @@ const hi: Translations = {
|
|||
title: 'असीमित फ़िल्टर अनलॉक करें',
|
||||
description:
|
||||
'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
||||
descriptionSharedUpgrade:
|
||||
'साझा खोज में और फ़िल्टर जोड़ने के लिए आजीवन पहुँच चाहिए — इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर अनलॉक करें.',
|
||||
titleRegister: 'मुफ्त खाता बनाएं',
|
||||
descriptionRegister:
|
||||
'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -597,8 +597,6 @@ const zh: Translations = {
|
|||
registerAndUpgrade: '注册并升级',
|
||||
alreadyHaveAccount: '已有账户?请登录',
|
||||
continueFree: '暂时继续浏览',
|
||||
descriptionSharedUpgrade:
|
||||
'向分享的搜索添加更多筛选条件需要终身访问权限 — 解锁英格兰每个邮编的无限筛选。',
|
||||
titleRegister: '注册免费账户',
|
||||
descriptionRegister:
|
||||
'免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue