improve
This commit is contained in:
parent
8688b7475e
commit
e8345cbdc1
40 changed files with 1980 additions and 904 deletions
|
|
@ -81,6 +81,15 @@ function isProtectedPage(page: Page): boolean {
|
|||
return page === 'account' || page === 'saved';
|
||||
}
|
||||
|
||||
function isSharedDashboardUrl(): boolean {
|
||||
const share = new URLSearchParams(window.location.search).get('share');
|
||||
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
|
||||
}
|
||||
|
||||
function isAuthRequiredRoute(page: Page): boolean {
|
||||
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
|
||||
}
|
||||
|
||||
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
|
||||
const normalizedHash = normalizeHash(hash);
|
||||
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
|
||||
|
|
@ -235,6 +244,7 @@ export default function App() {
|
|||
const postAuthCheckoutReturnPathRef = useRef<string | null>(null);
|
||||
const authCompletedRef = useRef(false);
|
||||
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
|
||||
const [dashboardReady, setDashboardReady] = useState(false);
|
||||
|
||||
// Keep a ref to the latest refreshAuth so the mount-only startup effect always
|
||||
// calls the current implementation without re-running when the callback identity changes.
|
||||
|
|
@ -266,7 +276,7 @@ export default function App() {
|
|||
if (!completed) {
|
||||
setPostAuthIntent(null);
|
||||
postAuthCheckoutReturnPathRef.current = null;
|
||||
if (isProtectedPage(activePageRef.current)) {
|
||||
if (isAuthRequiredRoute(activePageRef.current)) {
|
||||
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||
setRouteHash('');
|
||||
setActivePage('home');
|
||||
|
|
@ -517,7 +527,10 @@ export default function App() {
|
|||
}
|
||||
}, [activePage, fetchSearches]);
|
||||
|
||||
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved';
|
||||
const isAuthRequiredPage =
|
||||
activePage === 'account' ||
|
||||
activePage === 'saved' ||
|
||||
(activePage === 'dashboard' && !mapUrlState.share);
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (isAuthRequiredPage && !user) {
|
||||
|
|
@ -530,6 +543,13 @@ export default function App() {
|
|||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePage !== 'dashboard' || !user) {
|
||||
setDashboardReady(false);
|
||||
setExportState(null);
|
||||
}
|
||||
}, [activePage, user]);
|
||||
|
||||
if ((isScreenshotMode || isOgMode) && inviteCode) {
|
||||
return (
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
|
|
@ -584,8 +604,9 @@ export default function App() {
|
|||
onPageChange={navigateTo}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
exportState={activePage === 'dashboard' ? exportState : null}
|
||||
exportState={activePage === 'dashboard' && user ? exportState : null}
|
||||
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
|
||||
dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady}
|
||||
onSaveSearch={
|
||||
activePage === 'dashboard' && user
|
||||
? editingSearch
|
||||
|
|
@ -675,6 +696,7 @@ export default function App() {
|
|||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
onDashboardParamsChange={setDashboardParams}
|
||||
onDashboardReadyChange={setDashboardReady}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
initialPostcode={mapUrlState.postcode}
|
||||
|
|
|
|||
|
|
@ -461,6 +461,24 @@ interface ShareLinkListItem {
|
|||
created: string;
|
||||
}
|
||||
|
||||
function latestPendingInviteUrls(invites: InviteListItem[]): Record<string, string> {
|
||||
const latestByType: Record<string, { url: string; createdMs: number }> = {};
|
||||
|
||||
for (const invite of invites) {
|
||||
if (invite.used || !invite.url) continue;
|
||||
|
||||
const createdMs = Date.parse(invite.created) || 0;
|
||||
const existing = latestByType[invite.invite_type];
|
||||
if (!existing || createdMs > existing.createdMs) {
|
||||
latestByType[invite.invite_type] = { url: invite.url, createdMs };
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(latestByType).map(([type, invite]) => [type, invite.url])
|
||||
);
|
||||
}
|
||||
|
||||
function InviteTable({
|
||||
invites,
|
||||
loading,
|
||||
|
|
@ -673,7 +691,16 @@ function InviteSection({ user }: { user: AuthUser }) {
|
|||
const res = await fetch(apiUrl('invites'), authHeaders());
|
||||
assertOk(res, 'Fetch invites');
|
||||
const data = await res.json();
|
||||
setInviteHistory(data.invites);
|
||||
const invites: InviteListItem[] = Array.isArray(data.invites) ? data.invites : [];
|
||||
setInviteHistory(invites);
|
||||
const pendingInviteUrls = latestPendingInviteUrls(invites);
|
||||
setInviteUrl((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [type, url] of Object.entries(pendingInviteUrls)) {
|
||||
if (!next[type]) next[type] = url;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Silent — non-critical
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
|
|||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key,
|
||||
t: (key: string) => {
|
||||
if (key === 'locationSearch.placeholder') return 'Search places or postcodes...';
|
||||
if (key === 'locationSearch.noResults') return 'No matching places or postcodes';
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -226,6 +229,91 @@ describe('LocationSearch', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('selects the first place suggestion with Enter when none is highlighted', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
if (url.pathname === '/api/places') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
places: [
|
||||
{
|
||||
type: 'place',
|
||||
name: 'London',
|
||||
slug: 'london',
|
||||
place_type: 'city',
|
||||
lat: 51.5074,
|
||||
lon: -0.1278,
|
||||
},
|
||||
],
|
||||
postcodes: [],
|
||||
addresses: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/api/nearest-postcode') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
postcode: 'SW1A 1AA',
|
||||
latitude: 51.501,
|
||||
longitude: -0.141,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
||||
const onFlyTo = vi.fn();
|
||||
const onLocationSearched = vi.fn();
|
||||
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'London' } });
|
||||
|
||||
await screen.findByRole('button', { name: 'London' });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onLocationSearched).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onFlyTo).toHaveBeenCalledWith(51.5074, -0.1278, 10);
|
||||
expect(onLocationSearched).toHaveBeenCalledWith({
|
||||
postcode: 'SW1A 1AA',
|
||||
geometry: postcodeGeometry,
|
||||
latitude: 51.501,
|
||||
longitude: -0.141,
|
||||
zoom: 10,
|
||||
markerLatitude: 51.5074,
|
||||
markerLongitude: -0.1278,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an empty state for invalid place queries', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
jsonResponse({
|
||||
places: [],
|
||||
postcodes: [],
|
||||
addresses: [],
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '!!!!zzzzzz!!!!' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No matching places or postcodes')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps only the three most recent local searches', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
|
|
|||
|
|
@ -333,6 +333,8 @@ export default function LocationSearch({
|
|||
onSelect={selectResult}
|
||||
loading={loading}
|
||||
placeholder={t('locationSearch.placeholder')}
|
||||
ariaLabel={t('locationSearch.searchLabel')}
|
||||
name="location-search"
|
||||
size="sm"
|
||||
inputClassName={
|
||||
inputClassName ??
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default function MapPage({
|
|||
onNavigateTo,
|
||||
onExportStateChange,
|
||||
onDashboardParamsChange,
|
||||
onDashboardReadyChange,
|
||||
screenshotMode,
|
||||
ogMode,
|
||||
isMobile = false,
|
||||
|
|
@ -642,6 +643,23 @@ export default function MapPage({
|
|||
onDashboardParamsChange?.(dashboardParams);
|
||||
}, [dashboardParams, onDashboardParamsChange]);
|
||||
|
||||
const dashboardReady =
|
||||
!initialLoading &&
|
||||
!mapData.loading &&
|
||||
!mapData.licenseRequired &&
|
||||
mapData.bounds != null &&
|
||||
mapData.currentView != null;
|
||||
|
||||
useEffect(() => {
|
||||
onDashboardReadyChange?.(dashboardReady);
|
||||
}, [dashboardReady, onDashboardReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onDashboardReadyChange?.(false);
|
||||
};
|
||||
}, [onDashboardReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
|
@ -830,8 +848,8 @@ export default function MapPage({
|
|||
</button>
|
||||
<button
|
||||
onClick={() => onUpdateEdit?.(dashboardParams)}
|
||||
disabled={savingSearch}
|
||||
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
|
||||
disabled={savingSearch || !dashboardReady}
|
||||
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export default function POIPane({
|
|||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="px-3 py-2">
|
||||
<PillGroup>
|
||||
<PillGroup wrap>
|
||||
{group.categories.map((category) => {
|
||||
const logo = getPoiCategoryLogoUrl(category);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -269,7 +269,10 @@ export function DesktopMapPage({
|
|||
</div>
|
||||
)}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-28 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
<div
|
||||
className="absolute bottom-28 right-4 z-10 flex min-h-0 w-80 max-w-[calc(100%_-_2rem)] flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"
|
||||
style={{ height: 'min(30rem, calc(100vh - 10rem))' }}
|
||||
>
|
||||
{poiPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,10 @@ export function MobileMapPage({
|
|||
upgradeModal,
|
||||
editingBar,
|
||||
}: MobileMapPageProps) {
|
||||
const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil(
|
||||
bottomScreenInset
|
||||
)}px - 7rem))`;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<LoadingOverlay show={initialLoading} />
|
||||
|
|
@ -219,7 +223,13 @@ export function MobileMapPage({
|
|||
)}
|
||||
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
<div
|
||||
className="absolute top-24 right-3 left-3 z-20 flex min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"
|
||||
style={{
|
||||
height: `min(22rem, ${floatingPaneAvailableHeight})`,
|
||||
maxHeight: floatingPaneAvailableHeight,
|
||||
}}
|
||||
>
|
||||
{poiPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface MapPageProps {
|
|||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
onDashboardParamsChange?: (params: string) => void;
|
||||
onDashboardReadyChange?: (ready: boolean) => void;
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
|
|
@ -36,6 +36,9 @@ export default function AuthModal({
|
|||
const [password, setPassword] = useState('');
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const dialogRef = useModalA11y();
|
||||
const fieldId = useId();
|
||||
const emailInputId = `${fieldId}-email`;
|
||||
const passwordInputId = `${fieldId}-password`;
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('Auth Modal Open', { tab: initialTab });
|
||||
|
|
@ -194,14 +197,20 @@ export default function AuthModal({
|
|||
{/* Email form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
<label
|
||||
htmlFor={emailInputId}
|
||||
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
||||
>
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
id={emailInputId}
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
/>
|
||||
|
|
@ -209,15 +218,21 @@ export default function AuthModal({
|
|||
|
||||
{view !== 'forgot' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
<label
|
||||
htmlFor={passwordInputId}
|
||||
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
||||
>
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id={passwordInputId}
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={
|
||||
view === 'register'
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export default function Header({
|
|||
onToggleTheme,
|
||||
exportState,
|
||||
dashboardParams,
|
||||
dashboardActionsDisabled = false,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
editingSearch,
|
||||
|
|
@ -96,6 +97,7 @@ export default function Header({
|
|||
onToggleTheme: () => void;
|
||||
exportState: HeaderExportState | null;
|
||||
dashboardParams: string;
|
||||
dashboardActionsDisabled?: boolean;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
editingSearch: EditingSearchState | null;
|
||||
|
|
@ -116,6 +118,7 @@ export default function Header({
|
|||
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
|
||||
);
|
||||
const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth);
|
||||
const dashboardActionsBlocked = activePage === 'dashboard' && (!user || dashboardActionsDisabled);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY);
|
||||
|
|
@ -139,6 +142,10 @@ export default function Header({
|
|||
if (!useSidebarNav) setMenuOpen(false);
|
||||
}, [useSidebarNav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboardActionsBlocked) setExportMenuOpen(false);
|
||||
}, [dashboardActionsBlocked]);
|
||||
|
||||
const doCopy = useCallback((text: string) => {
|
||||
copyToClipboard(text, () => {
|
||||
setCopied(true);
|
||||
|
|
@ -147,6 +154,7 @@ export default function Header({
|
|||
}, []);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
if (dashboardActionsBlocked) return;
|
||||
const params =
|
||||
activePage === 'dashboard' ? dashboardParams : window.location.search.replace(/^\?/, '');
|
||||
if (!params) {
|
||||
|
|
@ -167,7 +175,7 @@ export default function Header({
|
|||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
}, [activePage, dashboardParams, doCopy, i18n.language]);
|
||||
}, [activePage, dashboardActionsBlocked, dashboardParams, doCopy, i18n.language]);
|
||||
|
||||
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
|
|
@ -206,8 +214,8 @@ export default function Header({
|
|||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
|
|
@ -216,14 +224,16 @@ export default function Header({
|
|||
</div>
|
||||
)}
|
||||
{/* Left: Logo + nav */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
className="flex cursor-pointer items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
className="flex min-w-0 cursor-pointer items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => navLink('home', e)}
|
||||
>
|
||||
<LogoIcon className="w-5 h-5 text-teal-400" />
|
||||
<span className="text-lg font-semibold text-teal-300">{t('header.appName')}</span>
|
||||
<LogoIcon className="w-5 h-5 shrink-0 text-teal-400" />
|
||||
<span className="max-w-[9rem] truncate whitespace-nowrap text-lg font-semibold text-teal-300 sm:max-w-none">
|
||||
{t('header.appName')}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
|
|
@ -266,14 +276,14 @@ export default function Header({
|
|||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* Desktop-only dashboard actions */}
|
||||
{!useSidebarNav && activePage === 'dashboard' && (
|
||||
{!useSidebarNav && activePage === 'dashboard' && user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={sharing}
|
||||
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:opacity-50"
|
||||
disabled={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 ? (
|
||||
<>
|
||||
|
|
@ -295,8 +305,8 @@ export default function Header({
|
|||
{exportState && (
|
||||
<button
|
||||
onClick={() => setExportMenuOpen(true)}
|
||||
disabled={exportState.exporting}
|
||||
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:opacity-50"
|
||||
disabled={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')}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
|
|
@ -306,8 +316,8 @@ export default function Header({
|
|||
{onSaveSearch && !editingSearch && (
|
||||
<button
|
||||
onClick={onSaveSearch}
|
||||
disabled={savingSearch}
|
||||
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:opacity-50"
|
||||
disabled={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 ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
|
|
@ -363,7 +373,7 @@ export default function Header({
|
|||
{useSidebarNav && !user && (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="cursor-pointer px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
|
||||
className="flex h-8 max-w-[8.5rem] shrink-0 cursor-pointer items-center justify-center truncate whitespace-nowrap rounded bg-teal-600 px-2.5 text-xs font-semibold leading-none transition-colors hover:bg-teal-700 sm:max-w-none sm:px-3 sm:text-sm"
|
||||
>
|
||||
{t('header.createAccount')}
|
||||
</button>
|
||||
|
|
@ -410,6 +420,7 @@ export default function Header({
|
|||
onToggleTheme={onToggleTheme}
|
||||
exportState={exportState}
|
||||
onOpenExportMenu={() => setExportMenuOpen(true)}
|
||||
dashboardActionsDisabled={dashboardActionsBlocked}
|
||||
onSaveSearch={onSaveSearch}
|
||||
savingSearch={savingSearch}
|
||||
isEditingSearch={!!editingSearch}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface MobileMenuProps {
|
|||
onToggleTheme: () => void;
|
||||
exportState: HeaderExportState | null;
|
||||
onOpenExportMenu: () => void;
|
||||
dashboardActionsDisabled?: boolean;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
isEditingSearch: boolean;
|
||||
|
|
@ -41,6 +42,7 @@ export default function MobileMenu({
|
|||
onToggleTheme,
|
||||
exportState,
|
||||
onOpenExportMenu,
|
||||
dashboardActionsDisabled = false,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
isEditingSearch,
|
||||
|
|
@ -101,7 +103,7 @@ export default function MobileMenu({
|
|||
</a>
|
||||
);
|
||||
|
||||
const dashboardActions = activePage === 'dashboard' && (
|
||||
const dashboardActions = activePage === 'dashboard' && user && (
|
||||
<div className="px-2 py-2 border-b border-navy-700">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
|
|
@ -109,7 +111,7 @@ export default function MobileMenu({
|
|||
onShare();
|
||||
onClose();
|
||||
}}
|
||||
disabled={sharing}
|
||||
disabled={sharing || dashboardActionsDisabled}
|
||||
className={dashboardActionClass}
|
||||
>
|
||||
{sharing ? (
|
||||
|
|
@ -127,8 +129,8 @@ export default function MobileMenu({
|
|||
onClose();
|
||||
onOpenExportMenu();
|
||||
}}
|
||||
disabled={exportState.exporting}
|
||||
className={dashboardActionClass}
|
||||
disabled={exportState.exporting || dashboardActionsDisabled}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
|
|
@ -140,7 +142,7 @@ export default function MobileMenu({
|
|||
onSaveSearch();
|
||||
onClose();
|
||||
}}
|
||||
disabled={savingSearch}
|
||||
disabled={savingSearch || dashboardActionsDisabled}
|
||||
className={dashboardActionClass}
|
||||
>
|
||||
{savingSearch ? (
|
||||
|
|
@ -199,7 +201,7 @@ export default function MobileMenu({
|
|||
</button>
|
||||
|
||||
{/* Language selector */}
|
||||
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-3 pb-1 scrollbar-hide">
|
||||
<div className="grid max-w-full grid-cols-3 gap-1 px-3 pb-1">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
|
|
@ -208,7 +210,7 @@ export default function MobileMenu({
|
|||
localStorage.setItem('language', lang.code);
|
||||
void changeAppLanguage(lang.code);
|
||||
}}
|
||||
className={`flex-none min-w-[2.5rem] flex cursor-pointer items-center justify-center gap-1.5 px-2 py-1.5 rounded text-sm ${
|
||||
className={`flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1.5 text-sm ${
|
||||
i18n.language === lang.code
|
||||
? 'bg-navy-700 text-white font-medium'
|
||||
: 'text-warm-400 hover:bg-navy-800 hover:text-white'
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@ import type { ReactNode } from 'react';
|
|||
interface PillGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
export function PillGroup({ children, className = '' }: PillGroupProps) {
|
||||
export function PillGroup({ children, className = '', wrap = false }: PillGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 max-w-full flex-nowrap gap-1.5 overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible ${className}`}
|
||||
className={`flex min-w-0 max-w-full gap-1.5 ${
|
||||
wrap
|
||||
? 'flex-wrap overflow-x-visible'
|
||||
: 'flex-nowrap overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible'
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type React from 'react';
|
||||
import type { SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
|
||||
|
|
@ -13,6 +14,7 @@ interface SearchHook {
|
|||
activeIndex: number;
|
||||
setActiveIndex: (idx: number) => void;
|
||||
open: boolean;
|
||||
searching?: boolean;
|
||||
handleInputChange: (value: string) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
||||
showEmptySearches: () => void;
|
||||
|
|
@ -23,6 +25,8 @@ interface PlaceSearchInputProps {
|
|||
onSelect: (result: SearchResult) => void;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
name?: string;
|
||||
size?: 'sm' | 'xs';
|
||||
inputClassName?: string;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
|
|
@ -35,19 +39,28 @@ export function PlaceSearchInput({
|
|||
onSelect,
|
||||
loading,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
name,
|
||||
size = 'sm',
|
||||
inputClassName,
|
||||
inputRef,
|
||||
onInputChange,
|
||||
portal,
|
||||
}: PlaceSearchInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const sm = size === 'sm';
|
||||
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
|
||||
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false);
|
||||
|
||||
const showDropdown = search.open && search.results.length > 0;
|
||||
const showEmptyResults =
|
||||
search.open &&
|
||||
!search.searching &&
|
||||
search.query.trim().length >= 2 &&
|
||||
search.results.length === 0;
|
||||
const showDropdown = search.open && (search.results.length > 0 || showEmptyResults);
|
||||
const showSpinner = loading || search.searching;
|
||||
|
||||
const dropdown = showDropdown && (
|
||||
<div
|
||||
|
|
@ -64,57 +77,66 @@ export function PlaceSearchInput({
|
|||
: undefined
|
||||
}
|
||||
>
|
||||
{search.results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: result.type === 'address'
|
||||
? `addr-${result.postcode}-${result.address}-${result.lat}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center cursor-pointer ${
|
||||
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
|
||||
} ${
|
||||
idx === search.activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => search.setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(result);
|
||||
}}
|
||||
{showEmptyResults ? (
|
||||
<div
|
||||
className={`text-warm-500 dark:text-warm-400 ${sm ? 'px-3 py-2 text-sm' : 'px-2 py-1.5 text-xs'}`}
|
||||
role="status"
|
||||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
</>
|
||||
) : result.type === 'address' ? (
|
||||
<>
|
||||
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="min-w-0 text-warm-700 dark:text-warm-200">
|
||||
<span className="block truncate">{result.address}</span>
|
||||
<span className="block truncate text-warm-400 dark:text-warm-500">
|
||||
{result.postcode}
|
||||
{t('locationSearch.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
search.results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: result.type === 'address'
|
||||
? `addr-${result.postcode}-${result.address}-${result.lat}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center cursor-pointer ${
|
||||
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
|
||||
} ${
|
||||
idx === search.activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => search.setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(result);
|
||||
}}
|
||||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
</>
|
||||
) : result.type === 'address' ? (
|
||||
<>
|
||||
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="min-w-0 text-warm-700 dark:text-warm-200">
|
||||
<span className="block truncate">{result.address}</span>
|
||||
<span className="block truncate text-warm-400 dark:text-warm-500">
|
||||
{result.postcode}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
{result.name}
|
||||
{result.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
{result.name}
|
||||
{result.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -123,6 +145,7 @@ export function PlaceSearchInput({
|
|||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name={name}
|
||||
value={search.query}
|
||||
onChange={(e) => {
|
||||
search.handleInputChange(e.target.value);
|
||||
|
|
@ -132,11 +155,12 @@ export function PlaceSearchInput({
|
|||
search.showEmptySearches();
|
||||
}}
|
||||
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
||||
aria-label={ariaLabel ?? placeholder}
|
||||
placeholder={placeholder}
|
||||
className={inputClassName}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
{showSpinner && (
|
||||
<div
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,18 +4,27 @@ interface SearchInputProps {
|
|||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
className = '',
|
||||
}: SearchInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputPlaceholder = placeholder ?? t('common.search');
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? t('common.search')}
|
||||
placeholder={inputPlaceholder}
|
||||
aria-label={ariaLabel ?? inputPlaceholder}
|
||||
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const LISTING_CLUSTER_MAX_ZOOM = 24;
|
|||
const LISTING_CLUSTER_POPUP_LIMIT = 30;
|
||||
const LISTING_SPIDERFY_LIMIT = 12;
|
||||
const TILE_SIZE = 512;
|
||||
const PRICE_LABEL_CHARACTER_SET = '£0123456789.kM';
|
||||
|
||||
interface SingleListingPopupInfo {
|
||||
mode: 'single';
|
||||
|
|
@ -472,6 +473,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
outlineWidth: 3,
|
||||
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
|
||||
fontSettings: { sdf: true },
|
||||
characterSet: PRICE_LABEL_CHARACTER_SET,
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 10,
|
||||
sizeMaxPixels: 14,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ export function useLocationSearch(mode?: string) {
|
|||
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latestQueryRef = useRef('');
|
||||
|
|
@ -176,6 +177,7 @@ export function useLocationSearch(mode?: string) {
|
|||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setSearching(false);
|
||||
setResults(recentSearches);
|
||||
lastResultsRef.current = [];
|
||||
setOpen(recentSearches.length > 0);
|
||||
|
|
@ -183,6 +185,7 @@ export function useLocationSearch(mode?: string) {
|
|||
}
|
||||
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setSearching(false);
|
||||
const postcodeResults: SearchResult[] = [
|
||||
{ type: 'postcode', label: normalizePostcode(trimmed) },
|
||||
];
|
||||
|
|
@ -192,6 +195,7 @@ export function useLocationSearch(mode?: string) {
|
|||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setSearching(false);
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
|
|
@ -200,6 +204,7 @@ export function useLocationSearch(mode?: string) {
|
|||
const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed);
|
||||
setResults(locallyFilteredResults);
|
||||
setOpen(locallyFilteredResults.length > 0);
|
||||
setSearching(true);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -211,7 +216,13 @@ export function useLocationSearch(mode?: string) {
|
|||
`/api/places?${params}`,
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
|
||||
setResults([]);
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
places: PlaceResult[];
|
||||
postcodes?: string[];
|
||||
|
|
@ -253,9 +264,17 @@ export function useLocationSearch(mode?: string) {
|
|||
lastResultsRef.current = combinedResults;
|
||||
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
|
||||
setResults(matchingResults);
|
||||
setOpen(matchingResults.length > 0);
|
||||
setOpen(true);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
|
||||
setResults([]);
|
||||
setOpen(true);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
|
|
@ -264,7 +283,7 @@ export function useLocationSearch(mode?: string) {
|
|||
|
||||
const showEmptySearches = useCallback(() => {
|
||||
if (latestQueryRef.current.trim()) {
|
||||
setOpen(results.length > 0);
|
||||
setOpen(results.length > 0 || latestQueryRef.current.trim().length >= 2);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +297,7 @@ export function useLocationSearch(mode?: string) {
|
|||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
latestQueryRef.current = '';
|
||||
setSearching(false);
|
||||
setResults([]);
|
||||
lastResultsRef.current = [];
|
||||
setOpen(false);
|
||||
|
|
@ -308,6 +328,8 @@ export function useLocationSearch(mode?: string) {
|
|||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
onSelect(results[activeIndex]);
|
||||
} else if (results.length > 0) {
|
||||
onSelect(results[0]);
|
||||
} else if (looksLikePostcode(query)) {
|
||||
onSelect({ type: 'postcode', label: normalizePostcode(query) });
|
||||
}
|
||||
|
|
@ -332,6 +354,7 @@ export function useLocationSearch(mode?: string) {
|
|||
activeIndex,
|
||||
setActiveIndex,
|
||||
open,
|
||||
searching,
|
||||
setOpen,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
|
|
|
|||
|
|
@ -916,6 +916,7 @@ const de: Translations = {
|
|||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Orte oder Postcodes suchen...',
|
||||
noResults: 'Keine passenden Orte oder Postcodes',
|
||||
postcodeNotFound: 'Postcode nicht gefunden',
|
||||
lookupFailed: 'Suche fehlgeschlagen',
|
||||
searchLabel: 'Orte oder Postcodes suchen',
|
||||
|
|
|
|||
|
|
@ -892,6 +892,7 @@ const en = {
|
|||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Search places or postcodes...',
|
||||
noResults: 'No matching places or postcodes',
|
||||
postcodeNotFound: 'Postcode not found',
|
||||
lookupFailed: 'Lookup failed',
|
||||
searchLabel: 'Search places or postcodes',
|
||||
|
|
|
|||
|
|
@ -924,6 +924,7 @@ const fr: Translations = {
|
|||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Rechercher des lieux ou codes postaux...',
|
||||
noResults: 'Aucun lieu ni code postal correspondant',
|
||||
postcodeNotFound: 'Code postal introuvable',
|
||||
lookupFailed: 'Échec de la recherche',
|
||||
searchLabel: 'Rechercher des lieux ou codes postaux',
|
||||
|
|
|
|||
|
|
@ -876,6 +876,7 @@ const hi: Translations = {
|
|||
|
||||
locationSearch: {
|
||||
placeholder: 'स्थान या पोस्टकोड खोजें...',
|
||||
noResults: 'कोई मिलती-जुलती जगह या पोस्टकोड नहीं मिला',
|
||||
postcodeNotFound: 'पोस्टकोड नहीं मिला',
|
||||
lookupFailed: 'खोज विफल रही',
|
||||
searchLabel: 'स्थान या पोस्टकोड खोजें',
|
||||
|
|
|
|||
|
|
@ -910,6 +910,7 @@ const hu: Translations = {
|
|||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Helyek vagy irányítószámok keresése...',
|
||||
noResults: 'Nincs egyező hely vagy irányítószám',
|
||||
postcodeNotFound: 'Irányítószám nem található',
|
||||
lookupFailed: 'A keresés sikertelen',
|
||||
searchLabel: 'Helyek vagy irányítószámok keresése',
|
||||
|
|
|
|||
|
|
@ -850,6 +850,7 @@ const zh: Translations = {
|
|||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: '搜索地点或邮编...',
|
||||
noResults: '未找到匹配的地点或邮编',
|
||||
postcodeNotFound: '未找到该邮编',
|
||||
lookupFailed: '查询失败',
|
||||
searchLabel: '搜索地点或邮编',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue