Compare commits

..

2 commits

Author SHA1 Message Date
7bc591be2b fmt
All checks were successful
Build and publish Docker image / build-and-push (push) Successful in 6m31s
CI / Check (push) Successful in 10m26s
2026-06-26 19:57:59 +01:00
30d36a33d5 finish new demo 2026-06-26 19:47:23 +01:00
22 changed files with 375 additions and 138 deletions

View file

@ -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 && (

View file

@ -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 && (

View file

@ -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)}

View file

@ -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"

View file

@ -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>;

View file

@ -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>
)} )}

View file

@ -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}

View file

@ -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 ? (

View file

@ -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 && (

View file

@ -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,

View file

@ -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:

View file

@ -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',

View file

@ -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 dAngleterre. 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 dAngleterre. 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 dAngleterre.',
titleRegister: 'Créer un compte gratuit',
descriptionRegister:
'Les comptes gratuits débloquent jusquà 5 filtres ainsi que la sauvegarde, le partage et lexport — sans carte bancaire. Des filtres illimités dans toute lAngleterre ? Passez à laccès à vie.',
descriptionSharedRegister:
'Il sagit dune recherche partagée. Créez un compte gratuit pour construire la vôtre avec jusquà 5 filtres et enregistrer, partager et exporter — ou passez à laccès à vie pour des filtres illimités.',
registerFree: 'Créer un compte gratuit',
lifetimeUpsell: 'Des filtres illimités ?',
goLifetime: 'Obtenir laccè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: 'Sinscrire et passer à la version complète', registerAndUpgrade: 'Sinscrire 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 linstant',
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 lAngleterre.', 'Passez à laccès à vie pour superposer un nombre illimité de filtres sur chaque code postal dAngleterre.',
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 laccè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...',

View file

@ -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: 'सुविधाएं खोजें...',

View file

@ -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...',

View file

@ -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: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',

View file

@ -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', () => {

View file

@ -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

View file

@ -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 })
} }

View file

@ -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

View file

@ -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))
} }

View file

@ -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)");