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 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<ParseKeys | null>(null);
|
||||
const [postAuthIntent, setPostAuthIntent] = useState<PostAuthIntent | null>(null);
|
||||
const postAuthCheckoutReturnPathRef = useRef<string | null>(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 && (
|
||||
|
|
|
|||
|
|
@ -871,6 +871,7 @@ export default memo(function Filters({
|
|||
|
||||
<AddFilterPanel
|
||||
collapsed={addFilterCollapsed}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isLicensed={isLicensed}
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={[
|
||||
|
|
@ -905,6 +906,7 @@ export default memo(function Filters({
|
|||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
onRegisterClick={onLoginRequired}
|
||||
/>
|
||||
|
||||
{showPhilosophy && (
|
||||
|
|
|
|||
|
|
@ -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,14 @@ 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 +1034,20 @@ export default function MapPage({
|
|||
</div>
|
||||
) : 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 ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
isLoggedIn={isLoggedIn}
|
||||
reason={upgradeReason}
|
||||
onLoginClick={onLoginClick}
|
||||
onRegisterClick={onRegisterClick}
|
||||
onRegisterAndUpgrade={() =>
|
||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<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">
|
||||
{t('filters.upgradePrompt')}
|
||||
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}
|
||||
</p>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
{t('filters.upgradeToFullMap')}
|
||||
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export interface MapPageProps {
|
|||
user?: { id: string; subscription: string; isAdmin?: boolean; canSeeListings?: boolean } | null;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onCheckoutLoginClick?: (returnPath?: string) => void;
|
||||
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
||||
deferTutorial?: boolean;
|
||||
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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<View>(initialTab);
|
||||
|
|
@ -166,7 +171,7 @@ export default function AuthModal({
|
|||
{/* Value prop */}
|
||||
{view !== 'forgot' && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 text-center">
|
||||
{t('auth.valueProp')}
|
||||
{t(valuePropKey ?? 'auth.valueProp')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* 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' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={sharing || dashboardActionsBlocked}
|
||||
onClick={user ? handleShare : onAccountRequired}
|
||||
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"
|
||||
>
|
||||
{sharing ? (
|
||||
|
|
@ -308,8 +315,8 @@ export default function Header({
|
|||
</button>
|
||||
{exportState && (
|
||||
<button
|
||||
onClick={() => setExportMenuOpen(true)}
|
||||
disabled={exportState.exporting || dashboardActionsBlocked}
|
||||
onClick={user ? () => setExportMenuOpen(true) : onAccountRequired}
|
||||
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"
|
||||
title={t('header.exportToExcel')}
|
||||
>
|
||||
|
|
@ -317,10 +324,10 @@ export default function Header({
|
|||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
)}
|
||||
{onSaveSearch && !editingSearch && (
|
||||
{!editingSearch && (
|
||||
<button
|
||||
onClick={onSaveSearch}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
onClick={user ? () => onSaveSearch?.() : onAccountRequired}
|
||||
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"
|
||||
>
|
||||
{savingSearch ? (
|
||||
|
|
@ -431,6 +438,7 @@ export default function Header({
|
|||
user={user}
|
||||
onLoginClick={onLoginClick}
|
||||
onRegisterClick={onRegisterClick}
|
||||
onAccountRequired={onAccountRequired}
|
||||
onLogout={onLogout}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onShare={handleShare}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ interface MobileMenuProps {
|
|||
user: AuthUser | null;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
/** Logged-out click on a dashboard action (save/share/export) → register prompt. */
|
||||
onAccountRequired: () => void;
|
||||
onLogout: () => void;
|
||||
onClose: () => void;
|
||||
onShare: () => void;
|
||||
|
|
@ -49,6 +51,7 @@ export default function MobileMenu({
|
|||
user,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onAccountRequired,
|
||||
onLogout,
|
||||
onClose,
|
||||
onShare,
|
||||
|
|
@ -103,15 +106,17 @@ export default function MobileMenu({
|
|||
</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="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onShare();
|
||||
if (user) onShare();
|
||||
else onAccountRequired();
|
||||
onClose();
|
||||
}}
|
||||
disabled={sharing || dashboardActionsDisabled}
|
||||
disabled={!!user && (sharing || dashboardActionsDisabled)}
|
||||
className={dashboardActionClass}
|
||||
>
|
||||
{sharing ? (
|
||||
|
|
@ -127,22 +132,24 @@ export default function MobileMenu({
|
|||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenExportMenu();
|
||||
if (user) onOpenExportMenu();
|
||||
else onAccountRequired();
|
||||
}}
|
||||
className={dashboardActionClass}
|
||||
disabled={exportState.exporting || dashboardActionsDisabled}
|
||||
disabled={!!user && (exportState.exporting || dashboardActionsDisabled)}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
)}
|
||||
{onSaveSearch && (
|
||||
{(onSaveSearch || !user) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onSaveSearch();
|
||||
if (user) onSaveSearch?.();
|
||||
else onAccountRequired();
|
||||
onClose();
|
||||
}}
|
||||
disabled={savingSearch || dashboardActionsDisabled}
|
||||
disabled={!!user && (savingSearch || dashboardActionsDisabled)}
|
||||
className={dashboardActionClass}
|
||||
>
|
||||
{savingSearch ? (
|
||||
|
|
|
|||
|
|
@ -7,17 +7,27 @@ import { useModalA11y } from '../../hooks/useModalA11y';
|
|||
|
||||
interface UpgradeModalProps {
|
||||
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;
|
||||
/** Anonymous only: create a free account (the preferred, no-cost next step). */
|
||||
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>;
|
||||
/** Dismiss the modal and keep exploring with the free filter allowance. */
|
||||
/** Dismiss the modal and keep exploring with the current filter allowance. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
isLoggedIn,
|
||||
reason = 'filters',
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onRegisterAndUpgrade,
|
||||
onStartCheckout,
|
||||
onClose,
|
||||
}: UpgradeModalProps) {
|
||||
|
|
@ -52,6 +62,18 @@ export default function UpgradeModal({
|
|||
: `\u00A3${pricePence / 100}`;
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -90,55 +112,70 @@ export default function UpgradeModal({
|
|||
{/* Header */}
|
||||
<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">
|
||||
{t('upgrade.title')}
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p>
|
||||
<p className="text-warm-300 text-sm">{description}</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-6">
|
||||
<div className="flex items-baseline justify-center gap-1 mb-4">
|
||||
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||
{priceLabel}
|
||||
</span>
|
||||
{!isFree && (
|
||||
<span className="text-warm-500 dark:text-warm-400 text-lg">
|
||||
{t('pricingPage.lifetime')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
||||
{isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')}
|
||||
</p>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
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 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{loading
|
||||
? t('upgrade.redirecting')
|
||||
: isFree
|
||||
? t('upgrade.claimFreeAccess')
|
||||
: t('upgrade.upgradeFor', { price: priceLabel })}
|
||||
</button>
|
||||
// Registered free user: pay to unlock unlimited filters.
|
||||
<>
|
||||
<div className="flex items-baseline justify-center gap-1 mb-4">
|
||||
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||
{priceLabel}
|
||||
</span>
|
||||
{!isFree && (
|
||||
<span className="text-warm-500 dark:text-warm-400 text-lg">
|
||||
{t('pricingPage.lifetime')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
||||
{isFree ? t('upgrade.freeForEarly') : t('upgrade.oneTimePayment')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
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 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{loading
|
||||
? t('upgrade.redirecting')
|
||||
: isFree
|
||||
? t('upgrade.claimFreeAccess')
|
||||
: t('upgrade.upgradeFor', { price: priceLabel })}
|
||||
</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
|
||||
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"
|
||||
>
|
||||
{t('upgrade.registerAndUpgrade')}
|
||||
{t('upgrade.registerFree')}
|
||||
</button>
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
||||
|
|
@ -668,10 +682,13 @@ const en = {
|
|||
addFilter: 'Add filters',
|
||||
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.',
|
||||
upgradePrompt: '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',
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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: 'चेकआउट विफल रहा',
|
||||
},
|
||||
|
||||
|
|
@ -662,10 +674,13 @@ const hi: Translations = {
|
|||
addFilter: 'फ़िल्टर जोड़ें',
|
||||
findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है',
|
||||
addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फ़िल्टर जोड़ें',
|
||||
upgradePrompt:
|
||||
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 40+ संयोजित फ़िल्टर से मेल खाने वाले पोस्टकोड खोजें.',
|
||||
upgradePrompt: 'इंग्लैंड के हर पोस्टकोड में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच लें.',
|
||||
oneTimeLifetime: 'एक बार भुगतान, आजीवन पहुँच.',
|
||||
upgradeToFullMap: 'पूरे मानचित्र पर अपग्रेड करें',
|
||||
registerPrompt:
|
||||
'5 फ़िल्टर तक जोड़ने और अपनी खोजें सहेजने, साझा करने व export करने के लिए मुफ्त खाता बनाएं.',
|
||||
registerSubPrompt: 'मुफ्त — कार्ड की जरूरत नहीं. आजीवन पहुँच के साथ असीमित फ़िल्टर.',
|
||||
registerCta: 'मुफ्त खाता बनाएं',
|
||||
chooseFilters:
|
||||
'फ़िल्टर लगाने के लिए जोड़ें पर क्लिक करें. छोटे बटन डेटा विवरण दिखाते हैं या मानचित्र को रंगते हैं.',
|
||||
searchFeatures: 'सुविधाएं खोजें...',
|
||||
|
|
|
|||
|
|
@ -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,14 @@ 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...',
|
||||
|
|
|
|||
|
|
@ -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: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { parseUrlState, stateToParams } from './url-state';
|
||||
import { DEFAULT_OVERLAY_IDS } from './overlays';
|
||||
import { INITIAL_VIEW_STATE } from './consts';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
|
|
@ -199,10 +200,10 @@ describe('url-state', () => {
|
|||
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();
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -158,7 +158,10 @@ impl CrimeRecords {
|
|||
ParquetReader::new(file)
|
||||
.get_metadata()
|
||||
.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()
|
||||
};
|
||||
|
|
@ -193,10 +196,18 @@ impl CrimeRecords {
|
|||
// the column builders' write order.
|
||||
let mut global_row: u32 = 0;
|
||||
|
||||
let columns: Vec<String> = ["postcode", "month_index", "crime_type", "location", "outcome", "lat", "lon"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let columns: Vec<String> = [
|
||||
"postcode",
|
||||
"month_index",
|
||||
"crime_type",
|
||||
"location",
|
||||
"outcome",
|
||||
"lat",
|
||||
"lon",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let mut offset = 0usize;
|
||||
while offset < n {
|
||||
|
|
@ -337,7 +348,10 @@ impl CrimeRecords {
|
|||
if let Some(prev) = cur_pc.take() {
|
||||
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 {
|
||||
month: month.finish()?,
|
||||
|
|
@ -413,7 +427,7 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(robbery.outcome, Some("Court result"));
|
||||
assert_eq!(robbery.location, None); // null location → None
|
||||
// Two records have a null outcome (an AA1 Burglary and the BB2 Drugs).
|
||||
// Two records have a null outcome (an AA1 Burglary and the BB2 Drugs).
|
||||
let null_outcomes = all
|
||||
.iter()
|
||||
.map(|&i| recs.view(i))
|
||||
|
|
@ -518,7 +532,10 @@ mod tests {
|
|||
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");
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -82,10 +82,7 @@ impl PostcodePopulation {
|
|||
bail!("population parquet at {} produced no rows", path.display());
|
||||
}
|
||||
|
||||
info!(
|
||||
postcodes = by_postcode.len(),
|
||||
"Postcode population loaded"
|
||||
);
|
||||
info!(postcodes = by_postcode.len(), "Postcode population loaded");
|
||||
Ok(Self { by_postcode })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ pub struct SpillVecBuilder<T: SpillElem> {
|
|||
}
|
||||
|
||||
enum Builder<T: SpillElem> {
|
||||
Owned { values: Vec<T>, len: usize },
|
||||
Owned {
|
||||
values: Vec<T>,
|
||||
len: usize,
|
||||
},
|
||||
Mapped {
|
||||
map: MmapMut,
|
||||
len: usize,
|
||||
|
|
@ -462,7 +465,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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.
|
||||
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 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 {
|
||||
builder.push(k);
|
||||
}
|
||||
|
|
@ -536,7 +542,8 @@ mod tests {
|
|||
#[should_panic(expected = "overflow")]
|
||||
fn builder_overfill_panics() {
|
||||
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(2);
|
||||
builder.push(3); // one past the declared length
|
||||
|
|
|
|||
|
|
@ -99,7 +99,11 @@ pub async fn get_crime_records(
|
|||
.postcode_to_idx
|
||||
.get(&normalized)
|
||||
.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)
|
||||
} else {
|
||||
|
|
@ -120,12 +124,9 @@ pub async fn get_crime_records(
|
|||
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
|
||||
let mut seen: FxHashSet<&str> = FxHashSet::default();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
state.grid.for_each_in_bounds(
|
||||
min_lat,
|
||||
min_lon,
|
||||
max_lat,
|
||||
max_lon,
|
||||
|row_idx| {
|
||||
state
|
||||
.grid
|
||||
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
|
||||
let row = row_idx as usize;
|
||||
if cell_for_row_cached(
|
||||
row,
|
||||
|
|
@ -140,8 +141,7 @@ pub async fn get_crime_records(
|
|||
out.push(pc.to_string());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
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).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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
|||
use crate::utils::{postcode_outcode, postcode_sector};
|
||||
|
||||
use super::hexagon_stats::{
|
||||
CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats,
|
||||
HistogramStats, NumericFeatureStats, PricePoint,
|
||||
CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats,
|
||||
NumericFeatureStats, PricePoint,
|
||||
};
|
||||
|
||||
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
||||
|
|
@ -398,7 +398,6 @@ pub fn compute_crime_by_year(
|
|||
out
|
||||
}
|
||||
|
||||
|
||||
/// Latest year present anywhere in the by-year crime dataset. The client
|
||||
/// compares each selection's last charted year against this to caption
|
||||
/// 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!(out.len(), 2);
|
||||
|
||||
let burglary = out
|
||||
.iter()
|
||||
.find(|c| c.name == "Burglary (/yr, 7y)")
|
||||
.unwrap();
|
||||
let burglary = out.iter().find(|c| c.name == "Burglary (/yr, 7y)").unwrap();
|
||||
assert_eq!(burglary.national, Some(8.0));
|
||||
assert_eq!(burglary.outcode, Some(10.0));
|
||||
assert_eq!(burglary.sector, Some(5.0));
|
||||
|
||||
let robbery = out
|
||||
.iter()
|
||||
.find(|c| c.name == "Robbery (/yr, 7y)")
|
||||
.unwrap();
|
||||
let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap();
|
||||
assert_eq!(robbery.national, Some(6.0));
|
||||
// The outcode value was NaN — dropped to None; the sector value is finite.
|
||||
assert_eq!(robbery.outcode, None);
|
||||
|
|
@ -637,9 +630,7 @@ mod tests {
|
|||
fn area_crime_averages_respect_fields_filter() {
|
||||
let avgs = sample_averages();
|
||||
// Area averages are keyed by the full crime-feature name.
|
||||
let fields: HashSet<String> = ["Burglary (/yr, 7y)".to_string()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let fields: HashSet<String> = ["Burglary (/yr, 7y)".to_string()].into_iter().collect();
|
||||
let (_, _, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, true, &fields);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].name, "Burglary (/yr, 7y)");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue