diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e03eb6..243ad60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import type { ParseKeys } from 'i18next'; import type { ExportState } from './components/map/MapPage'; import { getSeoContentPage, @@ -277,6 +278,9 @@ export default function App() { const { startCheckout: startPostAuthCheckout } = useLicense(); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); + // Optional context-specific value-prop key for the auth modal (e.g. when a + // logged-out user taps Save/Share/Export). Null → the generic value prop. + const [authModalValuePropKey, setAuthModalValuePropKey] = useState(null); const [postAuthIntent, setPostAuthIntent] = useState(null); const postAuthCheckoutReturnPathRef = useRef(null); const authCompletedRef = useRef(false); @@ -294,13 +298,15 @@ export default function App() { ( tab: 'login' | 'register', intent: PostAuthIntent | null = null, - checkoutReturnPath?: string + checkoutReturnPath?: string, + valuePropKey?: ParseKeys | null ) => { authCompletedRef.current = false; postAuthCheckoutReturnPathRef.current = intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null; setPostAuthIntent(intent); setAuthModalTab(tab); + setAuthModalValuePropKey(valuePropKey ?? null); setShowAuthModal(true); clearError(); }, @@ -648,7 +654,7 @@ export default function App() { onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} - exportState={activePage === 'dashboard' && user ? exportState : null} + exportState={activePage === 'dashboard' ? exportState : null} dashboardParams={activePage === 'dashboard' ? dashboardParams : ''} dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady} onSaveSearch={ @@ -665,6 +671,9 @@ export default function App() { user={user} onLoginClick={() => openAuthModal('login')} onRegisterClick={() => openAuthModal('register')} + onAccountRequired={() => + openAuthModal('register', null, undefined, 'auth.dashboardActionsValueProp') + } onLogout={logout} isMobile={isMobile} /> @@ -753,7 +762,6 @@ export default function App() { user={user} onLoginClick={() => openAuthModal('login')} onRegisterClick={() => openAuthModal('register')} - onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)} onCheckoutRegisterClick={(returnPath) => openAuthModal('register', 'checkout', returnPath) } @@ -782,6 +790,7 @@ export default function App() { error={authError} onClearError={clearError} initialTab={authModalTab} + valuePropKey={authModalValuePropKey ?? undefined} /> )} {showSaveModal && ( diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 28e8f82..7018274 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -871,6 +871,7 @@ export default memo(function Filters({ {showPhilosophy && ( diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 27bf3cb..2a014b3 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -109,7 +109,6 @@ export default function MapPage({ user, onLoginClick, onRegisterClick, - onCheckoutLoginClick, onCheckoutRegisterClick, deferTutorial = false, onSaveSearch, @@ -348,6 +347,7 @@ export default function MapPage({ }, [ activeEntries, + effectiveFilterCap, fetchAiFilters, filters, filtersUnlimited, @@ -358,16 +358,28 @@ export default function MapPage({ ); // Travel-time entries share the non-paying user's filter cap, so block adding one - // when the combined feature + travel count is already at the limit. + // when the combined feature + travel count is already at the limit — and block it + // outright on a shared link, where non-paying users can't add filters at all. const handleAddTravelTimeEntry = useCallback( (mode: TransportMode) => { - if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) { + if ( + !filtersUnlimited && + (lockFilterAdds || Object.keys(filters).length + entries.length >= effectiveFilterCap) + ) { handleFilterLimitReached(); return; } handleAddEntry(mode); }, - [filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached] + [ + filtersUnlimited, + lockFilterAdds, + effectiveFilterCap, + filters, + entries, + handleAddEntry, + handleFilterLimitReached, + ] ); const handleClearAll = useCallback(() => { @@ -691,13 +703,17 @@ export default function MapPage({ }, [onDashboardReadyChange]); // Clear the filter-cap upsell once it's no longer relevant: the user became - // licensed/admin, or dropped back under the cap by removing filters. Prevents a - // stale flag from resurfacing the modal spuriously. + // licensed/admin, or dropped back under the cap by removing filters. On a shared + // link adds are blocked regardless of count, so don't auto-clear there — the user + // dismisses it themselves. Prevents a stale flag from resurfacing spuriously. useEffect(() => { - if (filtersUnlimited || Object.keys(filters).length < DEMO_MAX_FILTERS) { + if ( + filtersUnlimited || + (!lockFilterAdds && Object.keys(filters).length < effectiveFilterCap) + ) { setFilterLimitHit(false); } - }, [filtersUnlimited, filters]); + }, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters]); const handleUpgradeClick = useCallback(() => { onNavigateTo('pricing'); @@ -1021,17 +1037,20 @@ export default function MapPage({ ) : null; - // The upgrade modal is shown when a non-paying user hits the filter cap. - // Dismissing it just closes the modal (the filters they have stay applied). + // The upgrade modal is shown when a non-paying user hits the filter cap (or tries + // to add filters on a shared link). Dismissing it just closes the modal (the + // filters they have stay applied). Anonymous users are nudged to register for free + // first (a higher filter allowance + save/share/export); paying is the way to + // unlimited. Registered users see the lifetime upgrade directly. const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited; const upgradeModal = showFilterUpgradeModal ? ( - onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() - } - onRegisterClick={() => + isLoggedIn={isLoggedIn} + reason={upgradeReason} + onLoginClick={onLoginClick} + onRegisterClick={onRegisterClick} + onRegisterAndUpgrade={() => onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() } onStartCheckout={() => license.startCheckout(checkoutReturnPath)} diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx index d8c73cb..dfebf67 100644 --- a/frontend/src/components/map/filters/AddFilterPanel.tsx +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -33,6 +33,7 @@ import { interface AddFilterPanelProps { collapsed: boolean; + isLoggedIn: boolean; isLicensed: boolean; availableFeatures: FeatureMeta[]; allFeatures: FeatureMeta[]; @@ -54,10 +55,13 @@ interface AddFilterPanelProps { onClearOpenInfoFeature?: () => void; onAddTravelTimeEntry: (mode: TransportMode) => void; onUpgradeClick?: () => void; + /** Anonymous-only: open the register prompt from the create-account nag. */ + onRegisterClick?: () => void; } export function AddFilterPanel({ collapsed, + isLoggedIn, isLicensed, availableFeatures, allFeatures, @@ -79,6 +83,7 @@ export function AddFilterPanel({ onClearOpenInfoFeature, onAddTravelTimeEntry, onUpgradeClick, + onRegisterClick, }: AddFilterPanelProps) { const { t } = useTranslation(); const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); @@ -182,16 +187,16 @@ export function AddFilterPanel({ {!isLicensed && (

- {t('filters.upgradePrompt')} + {isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}

- {t('filters.oneTimeLifetime')} + {isLoggedIn ? t('filters.oneTimeLifetime') : t('filters.registerSubPrompt')}

void; onRegisterClick: () => void; - onCheckoutLoginClick?: (returnPath?: string) => void; onCheckoutRegisterClick?: (returnPath?: string) => void; deferTutorial?: boolean; onSaveSearch?: (name: string, paramsOverride?: string) => Promise; diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index 408f9db..e226a72 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useId } from 'react'; import { Trans, useTranslation } from 'react-i18next'; +import type { ParseKeys } from 'i18next'; import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; import { trackEvent } from '../../lib/analytics'; @@ -18,6 +19,7 @@ export default function AuthModal({ error, onClearError, initialTab = 'login', + valuePropKey, }: { onClose: () => void; onAuthenticated?: () => void; @@ -29,6 +31,9 @@ export default function AuthModal({ error: string | null; onClearError: () => void; initialTab?: 'login' | 'register'; + /** Translation key for the value-prop line, e.g. a context-specific nudge. Falls + * back to the generic `auth.valueProp`. */ + valuePropKey?: ParseKeys; }) { const { t } = useTranslation(); const [view, setView] = useState(initialTab); @@ -166,7 +171,7 @@ export default function AuthModal({ {/* Value prop */} {view !== 'forgot' && (

- {t('auth.valueProp')} + {t(valuePropKey ?? 'auth.valueProp')}

)} diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 51f00d2..0120ad3 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -91,6 +91,7 @@ export default function Header({ user, onLoginClick, onRegisterClick, + onAccountRequired, onLogout, isMobile, }: { @@ -110,6 +111,8 @@ export default function Header({ user: AuthUser | null; onLoginClick: () => void; onRegisterClick: () => void; + /** Logged-out click on a dashboard action (save/share/export) → register prompt. */ + onAccountRequired: () => void; onLogout: () => void; isMobile: boolean; }) { @@ -122,7 +125,10 @@ export default function Header({ () => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches ); const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth); - const dashboardActionsBlocked = activePage === 'dashboard' && (!user || dashboardActionsDisabled); + // Save/Share/Export are shown to everyone on the dashboard; a logged-out click + // opens the register prompt (see onAccountRequired) instead of acting. "Blocked" + // only applies to logged-in users while the dashboard isn't ready yet. + const dashboardActionsBlocked = activePage === 'dashboard' && !!user && dashboardActionsDisabled; useEffect(() => { const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY); @@ -281,12 +287,13 @@ export default function Header({ {/* Right side */}
- {/* Desktop-only dashboard actions */} - {!useSidebarNav && activePage === 'dashboard' && user && ( + {/* Desktop-only dashboard actions — shown to everyone; a logged-out click + opens the register prompt instead of acting. */} + {!useSidebarNav && activePage === 'dashboard' && ( <> {exportState && ( )} - {onSaveSearch && !editingSearch && ( + {!editingSearch && ( )} - {onSaveSearch && ( + {(onSaveSearch || !user) && ( + // Registered free user: pay to unlock unlimited filters. + <> +
+ + {priceLabel} + + {!isFree && ( + + {t('pricingPage.lifetime')} + + )} +
+

+ {isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')} +

+ + ) : ( -
+ // Anonymous: create a free account first (more filters + save/share/export), + // with the paid lifetime tier offered below as the route to unlimited. + <> -
+
+

+ {t('upgrade.lifetimeUpsell')} +

+ +
+ )} {error && ( diff --git a/frontend/src/hooks/useFilters.test.ts b/frontend/src/hooks/useFilters.test.ts index 37cd878..4f6648f 100644 --- a/frontend/src/hooks/useFilters.test.ts +++ b/frontend/src/hooks/useFilters.test.ts @@ -228,6 +228,43 @@ describe('useFilters', () => { expect(Object.keys(result.current.filters)).toHaveLength(2); }); + it('blocks adding new filters when lockAdds is set (shared link), but allows replacing 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: still under the cap (5), but adds are + // locked. They can adjust the shared filter ('a') but not add a new one. + const { result } = renderHook(() => + useFilters({ + initialFilters: { a: [0, 100] }, + features: sixFeatures, + filterLimit: 5, + lockAdds: true, + onFilterLimitReached, + }) + ); + + // Adding a brand-new filter is blocked and reported, even though count < cap. + act(() => { + result.current.handleAddFilter('b'); + }); + expect(Object.keys(result.current.filters)).toHaveLength(1); + expect(result.current.filters.b).toBeUndefined(); + expect(onFilterLimitReached).toHaveBeenCalledTimes(1); + + // Re-applying the existing filter replaces it (no new key) and is allowed. + act(() => { + result.current.handleAddFilter('a'); + }); + expect(Object.keys(result.current.filters)).toHaveLength(1); + expect(onFilterLimitReached).toHaveBeenCalledTimes(1); + }); + it('does not cap filters when filterLimit is null (licensed users)', () => { const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({ name, diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 859d132..07b60fa 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -611,6 +611,8 @@ const de: Translations = { createAccount: 'Konto erstellen', resetPassword: 'Passwort zurücksetzen', valueProp: 'Speichere Suchen, merke Immobilien und erstelle eine Shortlist passender Gebiete.', + dashboardActionsValueProp: + 'Erstelle ein kostenloses Konto, um deine Suchen zu speichern, zu teilen und zu exportieren — kostenlos, keine Kreditkarte erforderlich.', continueWithGoogle: 'Weiter mit Google', email: 'E-Mail', emailPlaceholder: 'name@beispiel.de', @@ -634,7 +636,17 @@ const de: Translations = { upgrade: { title: 'Unbegrenzte Filter freischalten', description: - 'Kostenlose Konten können bis zu 3 Filter gleichzeitig kombinieren. 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', + 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.', + descriptionSharedRegister: + 'Das ist eine geteilte Suche. Erstelle ein kostenloses Konto, um deine eigene mit bis zu 5 Filtern zu erstellen und zu speichern, teilen & exportieren — oder wähle den lebenslangen Zugang für unbegrenzte Filter.', + registerFree: 'Kostenloses Konto erstellen', + lifetimeUpsell: 'Willst du unbegrenzte Filter?', + goLifetime: 'Lebenslangen Zugang erhalten — {{price}}', free: 'Kostenlos', freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.', oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.', @@ -643,7 +655,7 @@ const de: Translations = { upgradeFor: 'Upgrade für {{price}}', registerAndUpgrade: 'Registrieren & upgraden', alreadyHaveAccount: 'Bereits ein Konto? Anmelden', - continueFree: 'Mit 3 Filtern fortfahren', + continueFree: 'Erstmal weiter erkunden', checkoutFailed: 'Bezahlvorgang fehlgeschlagen', }, @@ -682,7 +694,12 @@ const de: Translations = { addFiltersHint: 'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die zu deinen Kriterien passen', upgradePrompt: - 'Finde passende Postcodes mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 40 kombinierbaren Filtern in ganz England.', + 'Wähle den lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode in England hinweg zu kombinieren.', + registerPrompt: + 'Erstelle ein kostenloses Konto, um bis zu 5 Filter zu kombinieren und deine Suchen zu speichern, zu teilen & zu exportieren.', + registerSubPrompt: + 'Kostenlos — keine Kreditkarte erforderlich. Unbegrenzte Filter mit lebenslangem Zugang.', + registerCta: 'Kostenloses Konto erstellen', oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.', upgradeToFullMap: 'Auf die vollständige Karte upgraden', chooseFilters: diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5ad081d..8a27a76 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -599,6 +599,8 @@ const en = { createAccount: 'Create account', resetPassword: 'Reset password', valueProp: 'Save searches, bookmark properties, and build a shortlist of areas that fit.', + dashboardActionsValueProp: + 'Create a free account to save, share and export your searches — free, no card needed.', continueWithGoogle: 'Continue with Google', email: 'Email', emailPlaceholder: 'you@example.com', @@ -620,9 +622,21 @@ const en = { // ── Upgrade Modal ────────────────────────────────── upgrade: { + // Registered (logged-in, non-paying) users — the lifetime upsell. title: 'Unlock unlimited filters', description: - 'Free accounts can combine up to 3 filters at a time. 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. + titleRegister: 'Create a free account', + descriptionRegister: + 'Free accounts unlock up to 5 filters plus save, share and export — no card needed. Want unlimited filters across all of England? Go lifetime.', + descriptionSharedRegister: + 'This is a shared search. Create a free account to build your own with up to 5 filters and save, share & export — or go lifetime for unlimited.', + registerFree: 'Create free account', + lifetimeUpsell: 'Want unlimited filters?', + goLifetime: 'Get lifetime access — {{price}}', free: 'Free', freeForEarly: 'Free for early adopters. No credit card required.', oneTimePayment: 'One-time payment. Lifetime access.', @@ -631,7 +645,7 @@ const en = { upgradeFor: 'Upgrade for {{price}}', registerAndUpgrade: 'Register & Upgrade', alreadyHaveAccount: 'Already have an account? Log in', - continueFree: 'Continue with 3 filters', + continueFree: 'Keep exploring for now', checkoutFailed: 'Checkout failed', }, @@ -669,9 +683,13 @@ const en = { findingPerfectPostcode: 'Finding the Perfect Postcode', addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria', upgradePrompt: - 'Find matching postcodes using crime, schools, noise, broadband, prices, and 40+ combinable filters across England.', + 'Go lifetime to stack unlimited filters across every postcode in England.', oneTimeLifetime: 'One-time payment, lifetime access.', upgradeToFullMap: 'Upgrade to full map', + registerPrompt: + 'Create a free account to combine up to 5 filters and save, share & export your searches.', + registerSubPrompt: 'Free — no card needed. Unlimited filters with lifetime access.', + registerCta: 'Create free account', chooseFilters: 'Click Add to filter. The small buttons show data details or colour the map.', searchFeatures: 'Search features...', noMatchingFeatures: 'No matching features', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 3c94b2d..b8905c2 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -624,6 +624,8 @@ const fr: Translations = { resetPassword: 'Réinitialiser le mot de passe', valueProp: 'Enregistrez vos recherches, ajoutez des biens en favoris et créez une sélection de secteurs adaptés à vos critères.', + dashboardActionsValueProp: + 'Créez un compte gratuit pour enregistrer, partager et exporter vos recherches — gratuit, sans carte bancaire.', continueWithGoogle: 'Continuer avec Google', email: 'E-mail', emailPlaceholder: 'vous@exemple.com', @@ -647,7 +649,17 @@ const fr: Translations = { upgrade: { title: 'Débloquez les filtres illimités', description: - 'Les comptes gratuits peuvent combiner jusqu’à 3 filtres à la fois. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur tous les codes postaux et tous les quartiers 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', + 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.', + descriptionSharedRegister: + 'Il s’agit d’une recherche partagée. Créez un compte gratuit pour construire la vôtre avec jusqu’à 5 filtres et enregistrer, partager et exporter — ou passez à l’accès à vie pour des filtres illimités.', + registerFree: 'Créer un compte gratuit', + lifetimeUpsell: 'Des filtres illimités ?', + goLifetime: 'Obtenir l’accès à vie — {{price}}', free: 'Gratuit', freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.', oneTimePayment: 'Paiement unique. Accès à vie.', @@ -656,7 +668,7 @@ const fr: Translations = { upgradeFor: 'Passer à la version complète pour {{price}}', registerAndUpgrade: 'S’inscrire et passer à la version complète', alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous', - continueFree: 'Continuer avec 3 filtres', + continueFree: 'Continuer à explorer pour l’instant', checkoutFailed: 'Le paiement a échoué', }, @@ -696,9 +708,13 @@ const fr: Translations = { addFiltersHint: 'Ajoutez des filtres ci-dessous pour limiter la carte aux secteurs adaptés à vos critères', upgradePrompt: - 'Trouvez les codes postaux compatibles grâce aux filtres de criminalité, d’écoles, de bruit, de haut débit, de prix et plus de 40 filtres combinables dans toute l’Angleterre.', + 'Passez à l’accès à vie pour superposer un nombre illimité de filtres sur chaque code postal d’Angleterre.', oneTimeLifetime: 'Paiement unique, accès à vie.', upgradeToFullMap: 'Passer à la carte complète', + registerPrompt: + 'Créez un compte gratuit pour combiner jusqu’à 5 filtres et enregistrer, partager et exporter vos recherches.', + registerSubPrompt: 'Gratuit — sans carte bancaire. Filtres illimités avec l’accès à vie.', + registerCta: 'Créer un compte gratuit', chooseFilters: 'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les détails des données ou colorent la carte.', searchFeatures: 'Rechercher des critères...', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index c35b4db..571014a 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -597,6 +597,8 @@ const hi: Translations = { resetPassword: 'पासवर्ड रीसेट करें', valueProp: 'खोजें सहेजें, संपत्तियों को बुकमार्क करें और अपनी जरूरतों से मेल खाने वाले क्षेत्रों की शॉर्टलिस्ट बनाएं.', + dashboardActionsValueProp: + 'अपनी खोजें सहेजने, साझा करने और export करने के लिए मुफ्त खाता बनाएं — मुफ्त, कार्ड की जरूरत नहीं.', continueWithGoogle: 'Google से जारी रखें', email: 'ईमेल', emailPlaceholder: 'you@example.com', @@ -619,7 +621,17 @@ const hi: Translations = { upgrade: { title: 'असीमित फ़िल्टर अनलॉक करें', description: - 'मुफ्त खाते एक बार में 3 फ़िल्टर तक जोड़ सकते हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.', + 'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.', + descriptionSharedUpgrade: + 'साझा खोज में और फ़िल्टर जोड़ने के लिए आजीवन पहुँच चाहिए — इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर अनलॉक करें.', + titleRegister: 'मुफ्त खाता बनाएं', + descriptionRegister: + 'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.', + descriptionSharedRegister: + 'यह एक साझा खोज है. अपनी खुद की 5 फ़िल्टर तक की खोज बनाने और सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं — या असीमित के लिए आजीवन पहुँच लें.', + registerFree: 'मुफ्त खाता बनाएं', + lifetimeUpsell: 'असीमित फ़िल्टर चाहिए?', + goLifetime: 'आजीवन पहुँच पाएं — {{price}}', free: 'मुफ्त', freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.', oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.', @@ -628,7 +640,7 @@ const hi: Translations = { upgradeFor: '{{price}} में अपग्रेड करें', registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें', alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें', - continueFree: '3 फ़िल्टर के साथ जारी रखें', + continueFree: 'अभी के लिए खोज जारी रखें', checkoutFailed: 'चेकआउट विफल रहा', }, @@ -663,9 +675,13 @@ const hi: Translations = { findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है', addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें', upgradePrompt: - 'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 40+ संयोजित फ़िल्टर से मेल खाने वाले पोस्टकोड खोजें.', + 'इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच लें.', oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.', upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें', + registerPrompt: + '5 फ़िल्टर तक जोड़ने और अपनी खोजें सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं.', + registerSubPrompt: 'मुफ्त — कार्ड की जरूरत नहीं. आजीवन पहुँच के साथ असीमित फ़िल्टर.', + registerCta: 'मुफ्त खाता बनाएं', chooseFilters: 'फ़िल्टर लगाने के लिए जोड़ें पर क्लिक करें. छोटे बटन डेटा विवरण दिखाते हैं या मानचित्र को रंगते हैं.', searchFeatures: 'सुविधाएं खोजें...', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index 2b9aa73..6bca033 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -615,6 +615,8 @@ const hu: Translations = { resetPassword: 'Jelszó visszaállítása', valueProp: 'Mentsd el a kereséseidet, jelöld meg az ingatlanokat, és állíts össze egy listát a megfelelő területekből.', + dashboardActionsValueProp: + 'Hozz létre egy ingyenes fiókot a kereséseid mentéséhez, megosztásához és exportálásához – ingyenes, kártya nélkül.', continueWithGoogle: 'Folytatás Google-lel', email: 'E-mail', emailPlaceholder: 'te@pelda.hu', @@ -638,7 +640,17 @@ const hu: Translations = { upgrade: { title: 'Oldd fel a korlátlan szűrőket', description: - 'Az ingyenes fiókok egyszerre legfeljebb 3 szűrőt kombinálhatnak. 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', + 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.', + descriptionSharedRegister: + 'Ez egy megosztott keresés. Hozz létre egy ingyenes fiókot, hogy sajátot készíthess akár 5 szűrővel, és menthess, megoszthass és exportálhass – vagy válassz élethosszig tartó hozzáférést a korlátlan szűrőkért.', + registerFree: 'Ingyenes fiók létrehozása', + lifetimeUpsell: 'Korlátlan szűrőket szeretnél?', + goLifetime: 'Élethosszig tartó hozzáférés – {{price}}', free: 'Ingyenes', freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.', oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.', @@ -647,7 +659,7 @@ const hu: Translations = { upgradeFor: 'Teljes hozzáférés {{price}} áron', registerAndUpgrade: 'Regisztráció és teljes hozzáférés', alreadyHaveAccount: 'Már van fiókod? Jelentkezz be', - continueFree: 'Folytatás 3 szűrővel', + continueFree: 'Egyelőre tovább fedezem fel', checkoutFailed: 'A fizetés sikertelen', }, @@ -685,9 +697,13 @@ const hu: Translations = { findingPerfectPostcode: 'Tökéletes irányítószám keresése', addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően', upgradePrompt: - 'Találj megfelelő irányítószámokat bűnözés, iskolák, zaj, szélessáv, árak és több mint 40 kombinálható szűrő alapján egész Angliában.', + 'Válaszd az élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán.', oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.', upgradeToFullMap: 'Teljes térkép megnyitása', + registerPrompt: + 'Hozz létre egy ingyenes fiókot, hogy akár 5 szűrőt kombinálhass, és menthess, megoszthass és exportálhass kereséseket.', + registerSubPrompt: 'Ingyenes – kártya nélkül. Korlátlan szűrők élethosszig tartó hozzáféréssel.', + registerCta: 'Ingyenes fiók létrehozása', chooseFilters: 'Kattints a Hozzáadásra a szűréshez. A kis gombok adatokat mutatnak vagy színezik a térképet.', searchFeatures: 'Szűrők keresése...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8eaed83..84f1ff3 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -563,6 +563,7 @@ const zh: Translations = { createAccount: '注册账户', resetPassword: '重置密码', valueProp: '保存搜索、收藏房产,并整理出符合您需求的候选区域。', + dashboardActionsValueProp: '注册免费账户,保存、分享并导出您的搜索 — 免费,无需信用卡。', continueWithGoogle: '使用 Google 账号继续', email: '邮箱', emailPlaceholder: 'you@example.com', @@ -586,7 +587,7 @@ const zh: Translations = { upgrade: { title: '解锁无限筛选', description: - '免费账户每次最多可同时组合 3 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。', + '您的免费账户已使用全部 5 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。', free: '免费', freeForEarly: '早期用户免费。无需信用卡。', oneTimePayment: '一次性付款。终身访问。', @@ -595,7 +596,17 @@ const zh: Translations = { upgradeFor: '以 {{price}} 升级', registerAndUpgrade: '注册并升级', alreadyHaveAccount: '已有账户?请登录', - continueFree: '继续使用 3 个筛选条件', + continueFree: '暂时继续浏览', + descriptionSharedUpgrade: + '向分享的搜索添加更多筛选条件需要终身访问权限 — 解锁英格兰每个邮编的无限筛选。', + titleRegister: '注册免费账户', + descriptionRegister: + '免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。', + descriptionSharedRegister: + '这是一个分享的搜索。注册免费账户,可使用最多 5 个筛选条件,并保存、分享和导出 — 或选择终身访问以解锁无限筛选。', + registerFree: '注册免费账户', + lifetimeUpsell: '想要无限筛选?', + goLifetime: '获取终身访问权限 — {{price}}', checkoutFailed: '结账失败', }, @@ -632,8 +643,10 @@ const zh: Translations = { addFilter: '添加筛选条件', findingPerfectPostcode: '正在寻找理想邮编', addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域', - upgradePrompt: - '用治安、学校、噪音、宽带、价格等 40 多项联动筛选条件,在整个英格兰找到匹配的邮编。', + upgradePrompt: '选择终身访问,在英格兰每个邮编上叠加无限筛选条件。', + registerPrompt: '注册免费账户,可组合最多 5 个筛选条件,并保存、分享和导出您的搜索。', + registerSubPrompt: '免费 — 无需信用卡。终身访问可解锁无限筛选。', + registerCta: '注册免费账户', oneTimeLifetime: '一次性付款,终身访问。', upgradeToFullMap: '升级到完整地图', chooseFilters: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',