Compare commits
2 commits
e2b85fe819
...
7bc591be2b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc591be2b | |||
| 30d36a33d5 |
22 changed files with 375 additions and 138 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import type { ParseKeys } from 'i18next';
|
||||||
import type { ExportState } from './components/map/MapPage';
|
import type { ExportState } from './components/map/MapPage';
|
||||||
import {
|
import {
|
||||||
getSeoContentPage,
|
getSeoContentPage,
|
||||||
|
|
@ -277,6 +278,9 @@ export default function App() {
|
||||||
const { startCheckout: startPostAuthCheckout } = useLicense();
|
const { startCheckout: startPostAuthCheckout } = useLicense();
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
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<ParseKeys | null>(null);
|
||||||
const [postAuthIntent, setPostAuthIntent] = useState<PostAuthIntent | null>(null);
|
const [postAuthIntent, setPostAuthIntent] = useState<PostAuthIntent | null>(null);
|
||||||
const postAuthCheckoutReturnPathRef = useRef<string | null>(null);
|
const postAuthCheckoutReturnPathRef = useRef<string | null>(null);
|
||||||
const authCompletedRef = useRef(false);
|
const authCompletedRef = useRef(false);
|
||||||
|
|
@ -294,13 +298,15 @@ export default function App() {
|
||||||
(
|
(
|
||||||
tab: 'login' | 'register',
|
tab: 'login' | 'register',
|
||||||
intent: PostAuthIntent | null = null,
|
intent: PostAuthIntent | null = null,
|
||||||
checkoutReturnPath?: string
|
checkoutReturnPath?: string,
|
||||||
|
valuePropKey?: ParseKeys | null
|
||||||
) => {
|
) => {
|
||||||
authCompletedRef.current = false;
|
authCompletedRef.current = false;
|
||||||
postAuthCheckoutReturnPathRef.current =
|
postAuthCheckoutReturnPathRef.current =
|
||||||
intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null;
|
intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null;
|
||||||
setPostAuthIntent(intent);
|
setPostAuthIntent(intent);
|
||||||
setAuthModalTab(tab);
|
setAuthModalTab(tab);
|
||||||
|
setAuthModalValuePropKey(valuePropKey ?? null);
|
||||||
setShowAuthModal(true);
|
setShowAuthModal(true);
|
||||||
clearError();
|
clearError();
|
||||||
},
|
},
|
||||||
|
|
@ -648,7 +654,7 @@ export default function App() {
|
||||||
onPageChange={navigateTo}
|
onPageChange={navigateTo}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
exportState={activePage === 'dashboard' && user ? exportState : null}
|
exportState={activePage === 'dashboard' ? exportState : null}
|
||||||
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
|
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
|
||||||
dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady}
|
dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady}
|
||||||
onSaveSearch={
|
onSaveSearch={
|
||||||
|
|
@ -665,6 +671,9 @@ export default function App() {
|
||||||
user={user}
|
user={user}
|
||||||
onLoginClick={() => openAuthModal('login')}
|
onLoginClick={() => openAuthModal('login')}
|
||||||
onRegisterClick={() => openAuthModal('register')}
|
onRegisterClick={() => openAuthModal('register')}
|
||||||
|
onAccountRequired={() =>
|
||||||
|
openAuthModal('register', null, undefined, 'auth.dashboardActionsValueProp')
|
||||||
|
}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
@ -753,7 +762,6 @@ export default function App() {
|
||||||
user={user}
|
user={user}
|
||||||
onLoginClick={() => openAuthModal('login')}
|
onLoginClick={() => openAuthModal('login')}
|
||||||
onRegisterClick={() => openAuthModal('register')}
|
onRegisterClick={() => openAuthModal('register')}
|
||||||
onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)}
|
|
||||||
onCheckoutRegisterClick={(returnPath) =>
|
onCheckoutRegisterClick={(returnPath) =>
|
||||||
openAuthModal('register', 'checkout', returnPath)
|
openAuthModal('register', 'checkout', returnPath)
|
||||||
}
|
}
|
||||||
|
|
@ -782,6 +790,7 @@ export default function App() {
|
||||||
error={authError}
|
error={authError}
|
||||||
onClearError={clearError}
|
onClearError={clearError}
|
||||||
initialTab={authModalTab}
|
initialTab={authModalTab}
|
||||||
|
valuePropKey={authModalValuePropKey ?? undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSaveModal && (
|
{showSaveModal && (
|
||||||
|
|
|
||||||
|
|
@ -871,6 +871,7 @@ export default memo(function Filters({
|
||||||
|
|
||||||
<AddFilterPanel
|
<AddFilterPanel
|
||||||
collapsed={addFilterCollapsed}
|
collapsed={addFilterCollapsed}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
isLicensed={isLicensed}
|
isLicensed={isLicensed}
|
||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
allFeatures={[
|
allFeatures={[
|
||||||
|
|
@ -905,6 +906,7 @@ export default memo(function Filters({
|
||||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||||
onUpgradeClick={onUpgradeClick}
|
onUpgradeClick={onUpgradeClick}
|
||||||
|
onRegisterClick={onLoginRequired}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showPhilosophy && (
|
{showPhilosophy && (
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,6 @@ export default function MapPage({
|
||||||
user,
|
user,
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
onRegisterClick,
|
onRegisterClick,
|
||||||
onCheckoutLoginClick,
|
|
||||||
onCheckoutRegisterClick,
|
onCheckoutRegisterClick,
|
||||||
deferTutorial = false,
|
deferTutorial = false,
|
||||||
onSaveSearch,
|
onSaveSearch,
|
||||||
|
|
@ -348,6 +347,7 @@ export default function MapPage({
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeEntries,
|
activeEntries,
|
||||||
|
effectiveFilterCap,
|
||||||
fetchAiFilters,
|
fetchAiFilters,
|
||||||
filters,
|
filters,
|
||||||
filtersUnlimited,
|
filtersUnlimited,
|
||||||
|
|
@ -358,16 +358,28 @@ export default function MapPage({
|
||||||
);
|
);
|
||||||
|
|
||||||
// Travel-time entries share the non-paying user's filter cap, so block adding one
|
// 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(
|
const handleAddTravelTimeEntry = useCallback(
|
||||||
(mode: TransportMode) => {
|
(mode: TransportMode) => {
|
||||||
if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) {
|
if (
|
||||||
|
!filtersUnlimited &&
|
||||||
|
(lockFilterAdds || Object.keys(filters).length + entries.length >= effectiveFilterCap)
|
||||||
|
) {
|
||||||
handleFilterLimitReached();
|
handleFilterLimitReached();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleAddEntry(mode);
|
handleAddEntry(mode);
|
||||||
},
|
},
|
||||||
[filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached]
|
[
|
||||||
|
filtersUnlimited,
|
||||||
|
lockFilterAdds,
|
||||||
|
effectiveFilterCap,
|
||||||
|
filters,
|
||||||
|
entries,
|
||||||
|
handleAddEntry,
|
||||||
|
handleFilterLimitReached,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearAll = useCallback(() => {
|
const handleClearAll = useCallback(() => {
|
||||||
|
|
@ -691,13 +703,14 @@ export default function MapPage({
|
||||||
}, [onDashboardReadyChange]);
|
}, [onDashboardReadyChange]);
|
||||||
|
|
||||||
// Clear the filter-cap upsell once it's no longer relevant: the user became
|
// 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
|
// licensed/admin, or dropped back under the cap by removing filters. On a shared
|
||||||
// stale flag from resurfacing the modal spuriously.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (filtersUnlimited || Object.keys(filters).length < DEMO_MAX_FILTERS) {
|
if (filtersUnlimited || (!lockFilterAdds && Object.keys(filters).length < effectiveFilterCap)) {
|
||||||
setFilterLimitHit(false);
|
setFilterLimitHit(false);
|
||||||
}
|
}
|
||||||
}, [filtersUnlimited, filters]);
|
}, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters]);
|
||||||
|
|
||||||
const handleUpgradeClick = useCallback(() => {
|
const handleUpgradeClick = useCallback(() => {
|
||||||
onNavigateTo('pricing');
|
onNavigateTo('pricing');
|
||||||
|
|
@ -1021,17 +1034,20 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
// The upgrade modal is shown when a non-paying user hits the filter cap.
|
// The upgrade modal is shown when a non-paying user hits the filter cap (or tries
|
||||||
// Dismissing it just closes the modal (the filters they have stay applied).
|
// 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 showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
|
||||||
const upgradeModal = showFilterUpgradeModal ? (
|
const upgradeModal = showFilterUpgradeModal ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<UpgradeModal
|
<UpgradeModal
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={isLoggedIn}
|
||||||
onLoginClick={() =>
|
reason={upgradeReason}
|
||||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
onLoginClick={onLoginClick}
|
||||||
}
|
onRegisterClick={onRegisterClick}
|
||||||
onRegisterClick={() =>
|
onRegisterAndUpgrade={() =>
|
||||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||||
}
|
}
|
||||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
|
|
||||||
interface AddFilterPanelProps {
|
interface AddFilterPanelProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
isLoggedIn: boolean;
|
||||||
isLicensed: boolean;
|
isLicensed: boolean;
|
||||||
availableFeatures: FeatureMeta[];
|
availableFeatures: FeatureMeta[];
|
||||||
allFeatures: FeatureMeta[];
|
allFeatures: FeatureMeta[];
|
||||||
|
|
@ -54,10 +55,13 @@ interface AddFilterPanelProps {
|
||||||
onClearOpenInfoFeature?: () => void;
|
onClearOpenInfoFeature?: () => void;
|
||||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||||
onUpgradeClick?: () => void;
|
onUpgradeClick?: () => void;
|
||||||
|
/** Anonymous-only: open the register prompt from the create-account nag. */
|
||||||
|
onRegisterClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddFilterPanel({
|
export function AddFilterPanel({
|
||||||
collapsed,
|
collapsed,
|
||||||
|
isLoggedIn,
|
||||||
isLicensed,
|
isLicensed,
|
||||||
availableFeatures,
|
availableFeatures,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
|
@ -79,6 +83,7 @@ export function AddFilterPanel({
|
||||||
onClearOpenInfoFeature,
|
onClearOpenInfoFeature,
|
||||||
onAddTravelTimeEntry,
|
onAddTravelTimeEntry,
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
|
onRegisterClick,
|
||||||
}: AddFilterPanelProps) {
|
}: AddFilterPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
||||||
|
|
@ -182,16 +187,16 @@ export function AddFilterPanel({
|
||||||
{!isLicensed && (
|
{!isLicensed && (
|
||||||
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
|
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
|
||||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||||
{t('filters.upgradePrompt')}
|
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||||
{t('filters.oneTimeLifetime')}
|
{isLoggedIn ? t('filters.oneTimeLifetime') : t('filters.registerSubPrompt')}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onUpgradeClick}
|
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
||||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||||
>
|
>
|
||||||
{t('filters.upgradeToFullMap')}
|
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
||||||
</button>
|
</button>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 120 1600 230"
|
viewBox="0 120 1600 230"
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export interface MapPageProps {
|
||||||
user?: { id: string; subscription: string; isAdmin?: boolean; canSeeListings?: boolean } | null;
|
user?: { id: string; subscription: string; isAdmin?: boolean; canSeeListings?: boolean } | null;
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
onCheckoutLoginClick?: (returnPath?: string) => void;
|
|
||||||
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
||||||
deferTutorial?: boolean;
|
deferTutorial?: boolean;
|
||||||
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
|
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useCallback, useEffect, useId } from 'react';
|
import { useState, useCallback, useEffect, useId } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import type { ParseKeys } from 'i18next';
|
||||||
import { CloseIcon } from './icons/CloseIcon';
|
import { CloseIcon } from './icons/CloseIcon';
|
||||||
import { GoogleIcon } from './icons/GoogleIcon';
|
import { GoogleIcon } from './icons/GoogleIcon';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
|
@ -18,6 +19,7 @@ export default function AuthModal({
|
||||||
error,
|
error,
|
||||||
onClearError,
|
onClearError,
|
||||||
initialTab = 'login',
|
initialTab = 'login',
|
||||||
|
valuePropKey,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAuthenticated?: () => void;
|
onAuthenticated?: () => void;
|
||||||
|
|
@ -29,6 +31,9 @@ export default function AuthModal({
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onClearError: () => void;
|
onClearError: () => void;
|
||||||
initialTab?: 'login' | 'register';
|
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 { t } = useTranslation();
|
||||||
const [view, setView] = useState<View>(initialTab);
|
const [view, setView] = useState<View>(initialTab);
|
||||||
|
|
@ -166,7 +171,7 @@ export default function AuthModal({
|
||||||
{/* Value prop */}
|
{/* Value prop */}
|
||||||
{view !== 'forgot' && (
|
{view !== 'forgot' && (
|
||||||
<p className="text-xs text-warm-500 dark:text-warm-400 text-center">
|
<p className="text-xs text-warm-500 dark:text-warm-400 text-center">
|
||||||
{t('auth.valueProp')}
|
{t(valuePropKey ?? 'auth.valueProp')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export default function Header({
|
||||||
user,
|
user,
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
onRegisterClick,
|
onRegisterClick,
|
||||||
|
onAccountRequired,
|
||||||
onLogout,
|
onLogout,
|
||||||
isMobile,
|
isMobile,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -110,6 +111,8 @@ export default function Header({
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
|
/** Logged-out click on a dashboard action (save/share/export) → register prompt. */
|
||||||
|
onAccountRequired: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -122,7 +125,10 @@ export default function Header({
|
||||||
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
|
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
|
||||||
);
|
);
|
||||||
const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth);
|
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(() => {
|
useEffect(() => {
|
||||||
const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY);
|
const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY);
|
||||||
|
|
@ -281,12 +287,13 @@ export default function Header({
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
{/* Desktop-only dashboard actions */}
|
{/* Desktop-only dashboard actions — shown to everyone; a logged-out click
|
||||||
{!useSidebarNav && activePage === 'dashboard' && user && (
|
opens the register prompt instead of acting. */}
|
||||||
|
{!useSidebarNav && activePage === 'dashboard' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={user ? handleShare : onAccountRequired}
|
||||||
disabled={sharing || dashboardActionsBlocked}
|
disabled={!!user && (sharing || dashboardActionsBlocked)}
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sharing ? (
|
{sharing ? (
|
||||||
|
|
@ -308,8 +315,8 @@ export default function Header({
|
||||||
</button>
|
</button>
|
||||||
{exportState && (
|
{exportState && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setExportMenuOpen(true)}
|
onClick={user ? () => setExportMenuOpen(true) : onAccountRequired}
|
||||||
disabled={exportState.exporting || dashboardActionsBlocked}
|
disabled={!!user && (exportState.exporting || dashboardActionsBlocked)}
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title={t('header.exportToExcel')}
|
title={t('header.exportToExcel')}
|
||||||
>
|
>
|
||||||
|
|
@ -317,10 +324,10 @@ export default function Header({
|
||||||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSaveSearch && !editingSearch && (
|
{!editingSearch && (
|
||||||
<button
|
<button
|
||||||
onClick={onSaveSearch}
|
onClick={user ? () => onSaveSearch?.() : onAccountRequired}
|
||||||
disabled={savingSearch || dashboardActionsBlocked}
|
disabled={!!user && (savingSearch || dashboardActionsBlocked)}
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{savingSearch ? (
|
{savingSearch ? (
|
||||||
|
|
@ -431,6 +438,7 @@ export default function Header({
|
||||||
user={user}
|
user={user}
|
||||||
onLoginClick={onLoginClick}
|
onLoginClick={onLoginClick}
|
||||||
onRegisterClick={onRegisterClick}
|
onRegisterClick={onRegisterClick}
|
||||||
|
onAccountRequired={onAccountRequired}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onClose={() => setMenuOpen(false)}
|
onClose={() => setMenuOpen(false)}
|
||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ interface MobileMenuProps {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
|
/** Logged-out click on a dashboard action (save/share/export) → register prompt. */
|
||||||
|
onAccountRequired: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
|
@ -49,6 +51,7 @@ export default function MobileMenu({
|
||||||
user,
|
user,
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
onRegisterClick,
|
onRegisterClick,
|
||||||
|
onAccountRequired,
|
||||||
onLogout,
|
onLogout,
|
||||||
onClose,
|
onClose,
|
||||||
onShare,
|
onShare,
|
||||||
|
|
@ -103,15 +106,17 @@ export default function MobileMenu({
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
const dashboardActions = activePage === 'dashboard' && user && (
|
// Shown to everyone on the dashboard; a logged-out tap opens the register prompt.
|
||||||
|
const dashboardActions = activePage === 'dashboard' && (
|
||||||
<div className="px-2 py-2 border-b border-navy-700">
|
<div className="px-2 py-2 border-b border-navy-700">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onShare();
|
if (user) onShare();
|
||||||
|
else onAccountRequired();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
disabled={sharing || dashboardActionsDisabled}
|
disabled={!!user && (sharing || dashboardActionsDisabled)}
|
||||||
className={dashboardActionClass}
|
className={dashboardActionClass}
|
||||||
>
|
>
|
||||||
{sharing ? (
|
{sharing ? (
|
||||||
|
|
@ -127,22 +132,24 @@ export default function MobileMenu({
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose();
|
onClose();
|
||||||
onOpenExportMenu();
|
if (user) onOpenExportMenu();
|
||||||
|
else onAccountRequired();
|
||||||
}}
|
}}
|
||||||
className={dashboardActionClass}
|
className={dashboardActionClass}
|
||||||
disabled={exportState.exporting || dashboardActionsDisabled}
|
disabled={!!user && (exportState.exporting || dashboardActionsDisabled)}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="w-4 h-4" />
|
<DownloadIcon className="w-4 h-4" />
|
||||||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSaveSearch && (
|
{(onSaveSearch || !user) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSaveSearch();
|
if (user) onSaveSearch?.();
|
||||||
|
else onAccountRequired();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
disabled={savingSearch || dashboardActionsDisabled}
|
disabled={!!user && (savingSearch || dashboardActionsDisabled)}
|
||||||
className={dashboardActionClass}
|
className={dashboardActionClass}
|
||||||
>
|
>
|
||||||
{savingSearch ? (
|
{savingSearch ? (
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,27 @@ import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
|
|
||||||
interface UpgradeModalProps {
|
interface UpgradeModalProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
/** Why the modal opened: a plain filter-cap hit, or an attempt to add filters on
|
||||||
|
* a shared link. Only changes the wording. */
|
||||||
|
reason?: 'filters' | 'shared';
|
||||||
|
/** Anonymous only: log into an existing account. */
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
|
/** Anonymous only: create a free account (the preferred, no-cost next step). */
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
|
/** Anonymous only: register and go straight to lifetime checkout. */
|
||||||
|
onRegisterAndUpgrade: () => void;
|
||||||
|
/** Logged-in only: start lifetime checkout for unlimited filters. */
|
||||||
onStartCheckout: () => Promise<void>;
|
onStartCheckout: () => Promise<void>;
|
||||||
/** Dismiss the modal and keep exploring with the free filter allowance. */
|
/** Dismiss the modal and keep exploring with the current filter allowance. */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpgradeModal({
|
export default function UpgradeModal({
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
reason = 'filters',
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
onRegisterClick,
|
onRegisterClick,
|
||||||
|
onRegisterAndUpgrade,
|
||||||
onStartCheckout,
|
onStartCheckout,
|
||||||
onClose,
|
onClose,
|
||||||
}: UpgradeModalProps) {
|
}: UpgradeModalProps) {
|
||||||
|
|
@ -52,6 +62,18 @@ export default function UpgradeModal({
|
||||||
: `\u00A3${pricePence / 100}`;
|
: `\u00A3${pricePence / 100}`;
|
||||||
const isFree = pricePence === 0;
|
const isFree = pricePence === 0;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const title = isLoggedIn ? t('upgrade.title') : t('upgrade.titleRegister');
|
||||||
|
const description = isLoggedIn
|
||||||
|
? reason === 'shared'
|
||||||
|
? t('upgrade.descriptionSharedUpgrade')
|
||||||
|
: t('upgrade.description')
|
||||||
|
: reason === 'shared'
|
||||||
|
? t('upgrade.descriptionSharedRegister')
|
||||||
|
: t('upgrade.descriptionRegister');
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
const handleUpgrade = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -90,13 +112,16 @@ export default function UpgradeModal({
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||||
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
|
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
|
||||||
{t('upgrade.title')}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p>
|
<p className="text-warm-300 text-sm">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
// Registered free user: pay to unlock unlimited filters.
|
||||||
|
<>
|
||||||
<div className="flex items-baseline justify-center gap-1 mb-4">
|
<div className="flex items-baseline justify-center gap-1 mb-4">
|
||||||
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||||
{priceLabel}
|
{priceLabel}
|
||||||
|
|
@ -110,8 +135,6 @@ export default function UpgradeModal({
|
||||||
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
||||||
{isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')}
|
{isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<button
|
<button
|
||||||
onClick={handleUpgrade}
|
onClick={handleUpgrade}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|
@ -124,21 +147,35 @@ export default function UpgradeModal({
|
||||||
? t('upgrade.claimFreeAccess')
|
? t('upgrade.claimFreeAccess')
|
||||||
: t('upgrade.upgradeFor', { price: priceLabel })}
|
: t('upgrade.upgradeFor', { price: priceLabel })}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
// Anonymous: create a free account first (more filters + save/share/export),
|
||||||
|
// with the paid lifetime tier offered below as the route to unlimited.
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onRegisterClick}
|
onClick={onRegisterClick}
|
||||||
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
|
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
|
||||||
>
|
>
|
||||||
{t('upgrade.registerAndUpgrade')}
|
{t('upgrade.registerFree')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onLoginClick}
|
onClick={onLoginClick}
|
||||||
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
className="w-full px-4 py-2 mt-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||||
>
|
>
|
||||||
{t('upgrade.alreadyHaveAccount')}
|
{t('upgrade.alreadyHaveAccount')}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="mt-5 pt-4 border-t border-warm-200 dark:border-warm-700 text-center">
|
||||||
|
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||||
|
{t('upgrade.lifetimeUpsell')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onRegisterAndUpgrade}
|
||||||
|
className="text-sm font-semibold text-[#c8780f] hover:text-[#a86409] dark:text-[#f09a22] dark:hover:text-[#df8614]"
|
||||||
|
>
|
||||||
|
{t('upgrade.goLifetime', { price: priceLabel })}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,43 @@ describe('useFilters', () => {
|
||||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
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)', () => {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -611,6 +611,8 @@ const de: Translations = {
|
||||||
createAccount: 'Konto erstellen',
|
createAccount: 'Konto erstellen',
|
||||||
resetPassword: 'Passwort zurücksetzen',
|
resetPassword: 'Passwort zurücksetzen',
|
||||||
valueProp: 'Speichere Suchen, merke Immobilien und erstelle eine Shortlist passender Gebiete.',
|
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',
|
continueWithGoogle: 'Weiter mit Google',
|
||||||
email: 'E-Mail',
|
email: 'E-Mail',
|
||||||
emailPlaceholder: 'name@beispiel.de',
|
emailPlaceholder: 'name@beispiel.de',
|
||||||
|
|
@ -634,7 +636,17 @@ const de: Translations = {
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Unbegrenzte Filter freischalten',
|
title: 'Unbegrenzte Filter freischalten',
|
||||||
description:
|
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',
|
free: 'Kostenlos',
|
||||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
||||||
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
||||||
|
|
@ -643,7 +655,7 @@ const de: Translations = {
|
||||||
upgradeFor: 'Upgrade für {{price}}',
|
upgradeFor: 'Upgrade für {{price}}',
|
||||||
registerAndUpgrade: 'Registrieren & upgraden',
|
registerAndUpgrade: 'Registrieren & upgraden',
|
||||||
alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
|
alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
|
||||||
continueFree: 'Mit 3 Filtern fortfahren',
|
continueFree: 'Erstmal weiter erkunden',
|
||||||
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -682,7 +694,12 @@ const de: Translations = {
|
||||||
addFiltersHint:
|
addFiltersHint:
|
||||||
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die zu deinen Kriterien passen',
|
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die zu deinen Kriterien passen',
|
||||||
upgradePrompt:
|
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.',
|
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
||||||
upgradeToFullMap: 'Auf die vollständige Karte upgraden',
|
upgradeToFullMap: 'Auf die vollständige Karte upgraden',
|
||||||
chooseFilters:
|
chooseFilters:
|
||||||
|
|
|
||||||
|
|
@ -599,6 +599,8 @@ const en = {
|
||||||
createAccount: 'Create account',
|
createAccount: 'Create account',
|
||||||
resetPassword: 'Reset password',
|
resetPassword: 'Reset password',
|
||||||
valueProp: 'Save searches, bookmark properties, and build a shortlist of areas that fit.',
|
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',
|
continueWithGoogle: 'Continue with Google',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
|
|
@ -620,9 +622,21 @@ const en = {
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
|
// Registered (logged-in, non-paying) users — the lifetime upsell.
|
||||||
title: 'Unlock unlimited filters',
|
title: 'Unlock unlimited filters',
|
||||||
description:
|
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',
|
free: 'Free',
|
||||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
freeForEarly: 'Free for early adopters. No credit card required.',
|
||||||
oneTimePayment: 'One-time payment. Lifetime access.',
|
oneTimePayment: 'One-time payment. Lifetime access.',
|
||||||
|
|
@ -631,7 +645,7 @@ const en = {
|
||||||
upgradeFor: 'Upgrade for {{price}}',
|
upgradeFor: 'Upgrade for {{price}}',
|
||||||
registerAndUpgrade: 'Register & Upgrade',
|
registerAndUpgrade: 'Register & Upgrade',
|
||||||
alreadyHaveAccount: 'Already have an account? Log in',
|
alreadyHaveAccount: 'Already have an account? Log in',
|
||||||
continueFree: 'Continue with 3 filters',
|
continueFree: 'Keep exploring for now',
|
||||||
checkoutFailed: 'Checkout failed',
|
checkoutFailed: 'Checkout failed',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -668,10 +682,13 @@ const en = {
|
||||||
addFilter: 'Add filters',
|
addFilter: 'Add filters',
|
||||||
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
||||||
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
||||||
upgradePrompt:
|
upgradePrompt: 'Go lifetime to stack unlimited filters across every postcode in England.',
|
||||||
'Find matching postcodes using crime, schools, noise, broadband, prices, and 40+ combinable filters across England.',
|
|
||||||
oneTimeLifetime: 'One-time payment, lifetime access.',
|
oneTimeLifetime: 'One-time payment, lifetime access.',
|
||||||
upgradeToFullMap: 'Upgrade to full map',
|
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.',
|
chooseFilters: 'Click Add to filter. The small buttons show data details or colour the map.',
|
||||||
searchFeatures: 'Search features...',
|
searchFeatures: 'Search features...',
|
||||||
noMatchingFeatures: 'No matching features',
|
noMatchingFeatures: 'No matching features',
|
||||||
|
|
|
||||||
|
|
@ -624,6 +624,8 @@ const fr: Translations = {
|
||||||
resetPassword: 'Réinitialiser le mot de passe',
|
resetPassword: 'Réinitialiser le mot de passe',
|
||||||
valueProp:
|
valueProp:
|
||||||
'Enregistrez vos recherches, ajoutez des biens en favoris et créez une sélection de secteurs adaptés à vos critères.',
|
'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',
|
continueWithGoogle: 'Continuer avec Google',
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
emailPlaceholder: 'vous@exemple.com',
|
emailPlaceholder: 'vous@exemple.com',
|
||||||
|
|
@ -647,7 +649,17 @@ const fr: Translations = {
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Débloquez les filtres illimités',
|
title: 'Débloquez les filtres illimités',
|
||||||
description:
|
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',
|
free: 'Gratuit',
|
||||||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
||||||
oneTimePayment: 'Paiement unique. Accès à vie.',
|
oneTimePayment: 'Paiement unique. Accès à vie.',
|
||||||
|
|
@ -656,7 +668,7 @@ const fr: Translations = {
|
||||||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||||
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
||||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
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é',
|
checkoutFailed: 'Le paiement a échoué',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -696,9 +708,13 @@ const fr: Translations = {
|
||||||
addFiltersHint:
|
addFiltersHint:
|
||||||
'Ajoutez des filtres ci-dessous pour limiter la carte aux secteurs adaptés à vos critères',
|
'Ajoutez des filtres ci-dessous pour limiter la carte aux secteurs adaptés à vos critères',
|
||||||
upgradePrompt:
|
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.',
|
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
||||||
upgradeToFullMap: 'Passer à la carte complète',
|
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:
|
chooseFilters:
|
||||||
'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les détails des données ou colorent la carte.',
|
'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les détails des données ou colorent la carte.',
|
||||||
searchFeatures: 'Rechercher des critères...',
|
searchFeatures: 'Rechercher des critères...',
|
||||||
|
|
|
||||||
|
|
@ -597,6 +597,8 @@ const hi: Translations = {
|
||||||
resetPassword: 'पासवर्ड रीसेट करें',
|
resetPassword: 'पासवर्ड रीसेट करें',
|
||||||
valueProp:
|
valueProp:
|
||||||
'खोजें सहेजें, संपत्तियों को बुकमार्क करें और अपनी जरूरतों से मेल खाने वाले क्षेत्रों की शॉर्टलिस्ट बनाएं.',
|
'खोजें सहेजें, संपत्तियों को बुकमार्क करें और अपनी जरूरतों से मेल खाने वाले क्षेत्रों की शॉर्टलिस्ट बनाएं.',
|
||||||
|
dashboardActionsValueProp:
|
||||||
|
'अपनी खोजें सहेजने, साझा करने और export करने के लिए मुफ्त खाता बनाएं — मुफ्त, कार्ड की जरूरत नहीं.',
|
||||||
continueWithGoogle: 'Google से जारी रखें',
|
continueWithGoogle: 'Google से जारी रखें',
|
||||||
email: 'ईमेल',
|
email: 'ईमेल',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
|
|
@ -619,7 +621,17 @@ const hi: Translations = {
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'असीमित फ़िल्टर अनलॉक करें',
|
title: 'असीमित फ़िल्टर अनलॉक करें',
|
||||||
description:
|
description:
|
||||||
'मुफ्त खाते एक बार में 3 फ़िल्टर तक जोड़ सकते हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
'आपने अपने मुफ्त खाते के सभी 5 फ़िल्टर इस्तेमाल कर लिए हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
||||||
|
descriptionSharedUpgrade:
|
||||||
|
'साझा खोज में और फ़िल्टर जोड़ने के लिए आजीवन पहुँच चाहिए — इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर अनलॉक करें.',
|
||||||
|
titleRegister: 'मुफ्त खाता बनाएं',
|
||||||
|
descriptionRegister:
|
||||||
|
'मुफ्त खाते में 5 फ़िल्टर के साथ-साथ सहेजें, साझा करें और export करें — कार्ड की जरूरत नहीं. पूरे इंग्लैंड में असीमित फ़िल्टर चाहिए? आजीवन पहुँच लें.',
|
||||||
|
descriptionSharedRegister:
|
||||||
|
'यह एक साझा खोज है. अपनी खुद की 5 फ़िल्टर तक की खोज बनाने और सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं — या असीमित के लिए आजीवन पहुँच लें.',
|
||||||
|
registerFree: 'मुफ्त खाता बनाएं',
|
||||||
|
lifetimeUpsell: 'असीमित फ़िल्टर चाहिए?',
|
||||||
|
goLifetime: 'आजीवन पहुँच पाएं — {{price}}',
|
||||||
free: 'मुफ्त',
|
free: 'मुफ्त',
|
||||||
freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.',
|
freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.',
|
||||||
oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.',
|
oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.',
|
||||||
|
|
@ -628,7 +640,7 @@ const hi: Translations = {
|
||||||
upgradeFor: '{{price}} में अपग्रेड करें',
|
upgradeFor: '{{price}} में अपग्रेड करें',
|
||||||
registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें',
|
registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें',
|
||||||
alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें',
|
alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें',
|
||||||
continueFree: '3 फ़िल्टर के साथ जारी रखें',
|
continueFree: 'अभी के लिए खोज जारी रखें',
|
||||||
checkoutFailed: 'चेकआउट विफल रहा',
|
checkoutFailed: 'चेकआउट विफल रहा',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -662,10 +674,13 @@ const hi: Translations = {
|
||||||
addFilter: 'फ़िल्टर जोड़ें',
|
addFilter: 'फ़िल्टर जोड़ें',
|
||||||
findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है',
|
findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है',
|
||||||
addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें',
|
addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें',
|
||||||
upgradePrompt:
|
upgradePrompt: 'इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच लें.',
|
||||||
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 40+ संयोजित फ़िल्टर से मेल खाने वाले पोस्टकोड खोजें.',
|
|
||||||
oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.',
|
oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.',
|
||||||
upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें',
|
upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें',
|
||||||
|
registerPrompt:
|
||||||
|
'5 फ़िल्टर तक जोड़ने और अपनी खोजें सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं.',
|
||||||
|
registerSubPrompt: 'मुफ्त — कार्ड की जरूरत नहीं. आजीवन पहुँच के साथ असीमित फ़िल्टर.',
|
||||||
|
registerCta: 'मुफ्त खाता बनाएं',
|
||||||
chooseFilters:
|
chooseFilters:
|
||||||
'फ़िल्टर लगाने के लिए जोड़ें पर क्लिक करें. छोटे बटन डेटा विवरण दिखाते हैं या मानचित्र को रंगते हैं.',
|
'फ़िल्टर लगाने के लिए जोड़ें पर क्लिक करें. छोटे बटन डेटा विवरण दिखाते हैं या मानचित्र को रंगते हैं.',
|
||||||
searchFeatures: 'सुविधाएं खोजें...',
|
searchFeatures: 'सुविधाएं खोजें...',
|
||||||
|
|
|
||||||
|
|
@ -615,6 +615,8 @@ const hu: Translations = {
|
||||||
resetPassword: 'Jelszó visszaállítása',
|
resetPassword: 'Jelszó visszaállítása',
|
||||||
valueProp:
|
valueProp:
|
||||||
'Mentsd el a kereséseidet, jelöld meg az ingatlanokat, és állíts össze egy listát a megfelelő területekből.',
|
'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',
|
continueWithGoogle: 'Folytatás Google-lel',
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
emailPlaceholder: 'te@pelda.hu',
|
emailPlaceholder: 'te@pelda.hu',
|
||||||
|
|
@ -638,7 +640,17 @@ const hu: Translations = {
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Oldd fel a korlátlan szűrőket',
|
title: 'Oldd fel a korlátlan szűrőket',
|
||||||
description:
|
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',
|
free: 'Ingyenes',
|
||||||
freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.',
|
freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.',
|
||||||
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.',
|
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',
|
upgradeFor: 'Teljes hozzáférés {{price}} áron',
|
||||||
registerAndUpgrade: 'Regisztráció és teljes hozzáférés',
|
registerAndUpgrade: 'Regisztráció és teljes hozzáférés',
|
||||||
alreadyHaveAccount: 'Már van fiókod? Jelentkezz be',
|
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',
|
checkoutFailed: 'A fizetés sikertelen',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -685,9 +697,14 @@ const hu: Translations = {
|
||||||
findingPerfectPostcode: 'Tökéletes irányítószám keresése',
|
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',
|
addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően',
|
||||||
upgradePrompt:
|
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.',
|
oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.',
|
||||||
upgradeToFullMap: 'Teljes térkép megnyitása',
|
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:
|
chooseFilters:
|
||||||
'Kattints a Hozzáadásra a szűréshez. A kis gombok adatokat mutatnak vagy színezik a térképet.',
|
'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...',
|
searchFeatures: 'Szűrők keresése...',
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,7 @@ const zh: Translations = {
|
||||||
createAccount: '注册账户',
|
createAccount: '注册账户',
|
||||||
resetPassword: '重置密码',
|
resetPassword: '重置密码',
|
||||||
valueProp: '保存搜索、收藏房产,并整理出符合您需求的候选区域。',
|
valueProp: '保存搜索、收藏房产,并整理出符合您需求的候选区域。',
|
||||||
|
dashboardActionsValueProp: '注册免费账户,保存、分享并导出您的搜索 — 免费,无需信用卡。',
|
||||||
continueWithGoogle: '使用 Google 账号继续',
|
continueWithGoogle: '使用 Google 账号继续',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
|
|
@ -586,7 +587,7 @@ const zh: Translations = {
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: '解锁无限筛选',
|
title: '解锁无限筛选',
|
||||||
description:
|
description:
|
||||||
'免费账户每次最多可同时组合 3 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。',
|
'您的免费账户已使用全部 5 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。',
|
||||||
free: '免费',
|
free: '免费',
|
||||||
freeForEarly: '早期用户免费。无需信用卡。',
|
freeForEarly: '早期用户免费。无需信用卡。',
|
||||||
oneTimePayment: '一次性付款。终身访问。',
|
oneTimePayment: '一次性付款。终身访问。',
|
||||||
|
|
@ -595,7 +596,17 @@ const zh: Translations = {
|
||||||
upgradeFor: '以 {{price}} 升级',
|
upgradeFor: '以 {{price}} 升级',
|
||||||
registerAndUpgrade: '注册并升级',
|
registerAndUpgrade: '注册并升级',
|
||||||
alreadyHaveAccount: '已有账户?请登录',
|
alreadyHaveAccount: '已有账户?请登录',
|
||||||
continueFree: '继续使用 3 个筛选条件',
|
continueFree: '暂时继续浏览',
|
||||||
|
descriptionSharedUpgrade:
|
||||||
|
'向分享的搜索添加更多筛选条件需要终身访问权限 — 解锁英格兰每个邮编的无限筛选。',
|
||||||
|
titleRegister: '注册免费账户',
|
||||||
|
descriptionRegister:
|
||||||
|
'免费账户解锁最多 5 个筛选条件,并支持保存、分享和导出 — 无需信用卡。想要在整个英格兰使用无限筛选?选择终身访问。',
|
||||||
|
descriptionSharedRegister:
|
||||||
|
'这是一个分享的搜索。注册免费账户,可使用最多 5 个筛选条件,并保存、分享和导出 — 或选择终身访问以解锁无限筛选。',
|
||||||
|
registerFree: '注册免费账户',
|
||||||
|
lifetimeUpsell: '想要无限筛选?',
|
||||||
|
goLifetime: '获取终身访问权限 — {{price}}',
|
||||||
checkoutFailed: '结账失败',
|
checkoutFailed: '结账失败',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -632,8 +643,10 @@ const zh: Translations = {
|
||||||
addFilter: '添加筛选条件',
|
addFilter: '添加筛选条件',
|
||||||
findingPerfectPostcode: '正在寻找理想邮编',
|
findingPerfectPostcode: '正在寻找理想邮编',
|
||||||
addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域',
|
addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域',
|
||||||
upgradePrompt:
|
upgradePrompt: '选择终身访问,在英格兰每个邮编上叠加无限筛选条件。',
|
||||||
'用治安、学校、噪音、宽带、价格等 40 多项联动筛选条件,在整个英格兰找到匹配的邮编。',
|
registerPrompt: '注册免费账户,可组合最多 5 个筛选条件,并保存、分享和导出您的搜索。',
|
||||||
|
registerSubPrompt: '免费 — 无需信用卡。终身访问可解锁无限筛选。',
|
||||||
|
registerCta: '注册免费账户',
|
||||||
oneTimeLifetime: '一次性付款,终身访问。',
|
oneTimeLifetime: '一次性付款,终身访问。',
|
||||||
upgradeToFullMap: '升级到完整地图',
|
upgradeToFullMap: '升级到完整地图',
|
||||||
chooseFilters: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',
|
chooseFilters: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta } from '../types';
|
||||||
import { parseUrlState, stateToParams } from './url-state';
|
import { parseUrlState, stateToParams } from './url-state';
|
||||||
|
import { DEFAULT_OVERLAY_IDS } from './overlays';
|
||||||
import { INITIAL_VIEW_STATE } from './consts';
|
import { INITIAL_VIEW_STATE } from './consts';
|
||||||
import { createSchoolFilterKey } from './school-filter';
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||||
|
|
@ -199,10 +200,10 @@ describe('url-state', () => {
|
||||||
expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots']));
|
expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables crime + trees overlays by default on a fresh visit', () => {
|
it('enables the default overlays on a fresh visit', () => {
|
||||||
const state = parseUrlState();
|
const state = parseUrlState();
|
||||||
|
|
||||||
expect(state.overlays).toEqual(new Set(['crime-hotspots', 'trees-outside-woodlands']));
|
expect(state.overlays).toEqual(new Set(DEFAULT_OVERLAY_IDS));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips an explicit "all overlays off" via the __none sentinel', () => {
|
it('round-trips an explicit "all overlays off" via the __none sentinel', () => {
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,10 @@ impl CrimeRecords {
|
||||||
ParquetReader::new(file)
|
ParquetReader::new(file)
|
||||||
.get_metadata()
|
.get_metadata()
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!("Failed to read crime-records parquet metadata at {}", path.display())
|
format!(
|
||||||
|
"Failed to read crime-records parquet metadata at {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
})?
|
})?
|
||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
@ -193,7 +196,15 @@ impl CrimeRecords {
|
||||||
// the column builders' write order.
|
// the column builders' write order.
|
||||||
let mut global_row: u32 = 0;
|
let mut global_row: u32 = 0;
|
||||||
|
|
||||||
let columns: Vec<String> = ["postcode", "month_index", "crime_type", "location", "outcome", "lat", "lon"]
|
let columns: Vec<String> = [
|
||||||
|
"postcode",
|
||||||
|
"month_index",
|
||||||
|
"crime_type",
|
||||||
|
"location",
|
||||||
|
"outcome",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -337,7 +348,10 @@ impl CrimeRecords {
|
||||||
if let Some(prev) = cur_pc.take() {
|
if let Some(prev) = cur_pc.take() {
|
||||||
by_postcode.insert(prev, (cur_start, global_row - cur_start));
|
by_postcode.insert(prev, (cur_start, global_row - cur_start));
|
||||||
}
|
}
|
||||||
debug_assert_eq!(global_row as usize, n, "streamed fewer rows than the parquet declares");
|
debug_assert_eq!(
|
||||||
|
global_row as usize, n,
|
||||||
|
"streamed fewer rows than the parquet declares"
|
||||||
|
);
|
||||||
|
|
||||||
let records = Self {
|
let records = Self {
|
||||||
month: month.finish()?,
|
month: month.finish()?,
|
||||||
|
|
@ -518,7 +532,10 @@ mod tests {
|
||||||
rss_after,
|
rss_after,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(!recs.by_postcode.is_empty(), "expected at least one postcode");
|
assert!(
|
||||||
|
!recs.by_postcode.is_empty(),
|
||||||
|
"expected at least one postcode"
|
||||||
|
);
|
||||||
assert!(total > 0, "expected at least one record");
|
assert!(total > 0, "expected at least one record");
|
||||||
// The old `.collect()` decoded all rows' string columns at once (tens of
|
// The old `.collect()` decoded all rows' string columns at once (tens of
|
||||||
// GB). Streaming must keep the peak growth far below that; a generous 20GiB
|
// GB). Streaming must keep the peak growth far below that; a generous 20GiB
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,7 @@ impl PostcodePopulation {
|
||||||
bail!("population parquet at {} produced no rows", path.display());
|
bail!("population parquet at {} produced no rows", path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(postcodes = by_postcode.len(), "Postcode population loaded");
|
||||||
postcodes = by_postcode.len(),
|
|
||||||
"Postcode population loaded"
|
|
||||||
);
|
|
||||||
Ok(Self { by_postcode })
|
Ok(Self { by_postcode })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,10 @@ pub struct SpillVecBuilder<T: SpillElem> {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Builder<T: SpillElem> {
|
enum Builder<T: SpillElem> {
|
||||||
Owned { values: Vec<T>, len: usize },
|
Owned {
|
||||||
|
values: Vec<T>,
|
||||||
|
len: usize,
|
||||||
|
},
|
||||||
Mapped {
|
Mapped {
|
||||||
map: MmapMut,
|
map: MmapMut,
|
||||||
len: usize,
|
len: usize,
|
||||||
|
|
@ -462,7 +465,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builder_streams_identically_owned_and_mapped() {
|
fn builder_streams_identically_owned_and_mapped() {
|
||||||
let values: Vec<u32> = (0..50_000u32).map(|n| n.wrapping_mul(2_246_822_519)).collect();
|
let values: Vec<u32> = (0..50_000u32)
|
||||||
|
.map(|n| n.wrapping_mul(2_246_822_519))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Owned path (no spill dir): pushes accumulate into a heap Vec.
|
// Owned path (no spill dir): pushes accumulate into a heap Vec.
|
||||||
let mut owned = SpillVecBuilder::<u32>::with_len(values.len(), None, "u32_owned").unwrap();
|
let mut owned = SpillVecBuilder::<u32>::with_len(values.len(), None, "u32_owned").unwrap();
|
||||||
|
|
@ -497,7 +502,8 @@ mod tests {
|
||||||
|
|
||||||
let dir = TempDir::new("builder-spur");
|
let dir = TempDir::new("builder-spur");
|
||||||
let mut builder =
|
let mut builder =
|
||||||
SpillVecBuilder::<lasso::Spur>::with_len(keys.len(), Some(dir.path()), "spurs").unwrap();
|
SpillVecBuilder::<lasso::Spur>::with_len(keys.len(), Some(dir.path()), "spurs")
|
||||||
|
.unwrap();
|
||||||
for &k in &keys {
|
for &k in &keys {
|
||||||
builder.push(k);
|
builder.push(k);
|
||||||
}
|
}
|
||||||
|
|
@ -536,7 +542,8 @@ mod tests {
|
||||||
#[should_panic(expected = "overflow")]
|
#[should_panic(expected = "overflow")]
|
||||||
fn builder_overfill_panics() {
|
fn builder_overfill_panics() {
|
||||||
let dir = TempDir::new("builder-overfill");
|
let dir = TempDir::new("builder-overfill");
|
||||||
let mut builder = SpillVecBuilder::<u32>::with_len(2, Some(dir.path()), "overfill").unwrap();
|
let mut builder =
|
||||||
|
SpillVecBuilder::<u32>::with_len(2, Some(dir.path()), "overfill").unwrap();
|
||||||
builder.push(1);
|
builder.push(1);
|
||||||
builder.push(2);
|
builder.push(2);
|
||||||
builder.push(3); // one past the declared length
|
builder.push(3); // one past the declared length
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,11 @@ pub async fn get_crime_records(
|
||||||
.postcode_to_idx
|
.postcode_to_idx
|
||||||
.get(&normalized)
|
.get(&normalized)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
(StatusCode::NOT_FOUND, format!("Postcode not found: {normalized}")).into_response()
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Postcode not found: {normalized}"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
})?;
|
})?;
|
||||||
Selection::Postcode(normalized)
|
Selection::Postcode(normalized)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -120,12 +124,9 @@ pub async fn get_crime_records(
|
||||||
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
||||||
let mut seen: FxHashSet<&str> = FxHashSet::default();
|
let mut seen: FxHashSet<&str> = FxHashSet::default();
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out: Vec<String> = Vec::new();
|
||||||
state.grid.for_each_in_bounds(
|
state
|
||||||
min_lat,
|
.grid
|
||||||
min_lon,
|
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
|
||||||
max_lat,
|
|
||||||
max_lon,
|
|
||||||
|row_idx| {
|
|
||||||
let row = row_idx as usize;
|
let row = row_idx as usize;
|
||||||
if cell_for_row_cached(
|
if cell_for_row_cached(
|
||||||
row,
|
row,
|
||||||
|
|
@ -140,8 +141,7 @@ pub async fn get_crime_records(
|
||||||
out.push(pc.to_string());
|
out.push(pc.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -177,6 +177,10 @@ pub async fn get_crime_records(
|
||||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
|
||||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
|
||||||
|
|
||||||
info!(total = result.total, returned = result.records.len(), "GET /api/crime-records");
|
info!(
|
||||||
|
total = result.total,
|
||||||
|
returned = result.records.len(),
|
||||||
|
"GET /api/crime-records"
|
||||||
|
);
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
||||||
use crate::utils::{postcode_outcode, postcode_sector};
|
use crate::utils::{postcode_outcode, postcode_sector};
|
||||||
|
|
||||||
use super::hexagon_stats::{
|
use super::hexagon_stats::{
|
||||||
CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats,
|
CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats,
|
||||||
HistogramStats, NumericFeatureStats, PricePoint,
|
NumericFeatureStats, PricePoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
||||||
|
|
@ -398,7 +398,6 @@ pub fn compute_crime_by_year(
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Latest year present anywhere in the by-year crime dataset. The client
|
/// Latest year present anywhere in the by-year crime dataset. The client
|
||||||
/// compares each selection's last charted year against this to caption
|
/// compares each selection's last charted year against this to caption
|
||||||
/// force-level publication gaps (e.g. Greater Manchester ends mid-2019) as
|
/// force-level publication gaps (e.g. Greater Manchester ends mid-2019) as
|
||||||
|
|
@ -615,18 +614,12 @@ mod tests {
|
||||||
assert_eq!(sector.as_deref(), Some("E14 2"));
|
assert_eq!(sector.as_deref(), Some("E14 2"));
|
||||||
assert_eq!(out.len(), 2);
|
assert_eq!(out.len(), 2);
|
||||||
|
|
||||||
let burglary = out
|
let burglary = out.iter().find(|c| c.name == "Burglary (/yr, 7y)").unwrap();
|
||||||
.iter()
|
|
||||||
.find(|c| c.name == "Burglary (/yr, 7y)")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(burglary.national, Some(8.0));
|
assert_eq!(burglary.national, Some(8.0));
|
||||||
assert_eq!(burglary.outcode, Some(10.0));
|
assert_eq!(burglary.outcode, Some(10.0));
|
||||||
assert_eq!(burglary.sector, Some(5.0));
|
assert_eq!(burglary.sector, Some(5.0));
|
||||||
|
|
||||||
let robbery = out
|
let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap();
|
||||||
.iter()
|
|
||||||
.find(|c| c.name == "Robbery (/yr, 7y)")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(robbery.national, Some(6.0));
|
assert_eq!(robbery.national, Some(6.0));
|
||||||
// The outcode value was NaN — dropped to None; the sector value is finite.
|
// The outcode value was NaN — dropped to None; the sector value is finite.
|
||||||
assert_eq!(robbery.outcode, None);
|
assert_eq!(robbery.outcode, None);
|
||||||
|
|
@ -637,9 +630,7 @@ mod tests {
|
||||||
fn area_crime_averages_respect_fields_filter() {
|
fn area_crime_averages_respect_fields_filter() {
|
||||||
let avgs = sample_averages();
|
let avgs = sample_averages();
|
||||||
// Area averages are keyed by the full crime-feature name.
|
// Area averages are keyed by the full crime-feature name.
|
||||||
let fields: HashSet<String> = ["Burglary (/yr, 7y)".to_string()]
|
let fields: HashSet<String> = ["Burglary (/yr, 7y)".to_string()].into_iter().collect();
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
let (_, _, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, true, &fields);
|
let (_, _, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, true, &fields);
|
||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
assert_eq!(out[0].name, "Burglary (/yr, 7y)");
|
assert_eq!(out[0].name, "Burglary (/yr, 7y)");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue