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