From 6d2dd374148e95c9a5f4685d4efa7c696d2ad9ba Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Wed, 27 May 2026 23:19:03 +0200 Subject: [PATCH] feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079) * feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos - Rework the mobile dashboard: cover hero, separate boarding-pass card, trimmed atlas (trips + days only), stacked widgets - New floating bottom tab bar with a centred context-aware + button (new trip / place / journey / entry depending on the page) - Move profile + notifications into a small top strip on the dashboard - Desktop: glassmorphic tiles (light + dark), neutral dark palette, plain-text countdown module, real place photos in the boarding pass * i18n(dashboard): translate new dashboard keys across all locales Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy dialog, aria labels, countdown) that were left as English placeholders, plus the new startsIn/aria keys, for all 19 languages. * feat(oidc): send PKCE (S256) in the OIDC login flow The OIDC client now generates a code_verifier per login, sends the S256 code_challenge on the authorize request and the code_verifier on the token exchange. Works whether the provider has PKCE optional or required (fixes login against providers that require PKCE, e.g. Pocket ID). --- .../src/components/Layout/BottomNav.test.tsx | 75 +----- client/src/components/Layout/BottomNav.tsx | 216 +++++++----------- .../components/Layout/MobileTopBar.test.tsx | 80 +++++++ client/src/components/Layout/MobileTopBar.tsx | 128 +++++++++++ client/src/pages/DashboardPage.test.tsx | 180 ++++++--------- client/src/pages/DashboardPage.tsx | 197 ++++++++-------- client/src/pages/JourneyDetailPage.tsx | 12 +- client/src/pages/JourneyPage.tsx | 12 +- client/src/pages/TripPlannerPage.tsx | 11 +- client/src/styles/dashboard.css | 196 ++++++++++++---- server/src/routes/oidc.ts | 6 +- server/src/services/oidcService.ts | 34 ++- server/tests/integration/oidc.test.ts | 14 +- .../tests/unit/services/oidcService.test.ts | 15 +- shared/src/i18n/ar/dashboard.ts | 159 +++++++------ shared/src/i18n/ar/nav.ts | 5 + shared/src/i18n/br/dashboard.ts | 109 +++++---- shared/src/i18n/cs/dashboard.ts | 109 +++++---- shared/src/i18n/de/dashboard.ts | 9 + shared/src/i18n/en/dashboard.ts | 9 + shared/src/i18n/es/dashboard.ts | 107 +++++---- shared/src/i18n/fr/dashboard.ts | 105 +++++---- shared/src/i18n/hu/dashboard.ts | 109 +++++---- shared/src/i18n/id/dashboard.ts | 109 +++++---- shared/src/i18n/it/dashboard.ts | 107 +++++---- shared/src/i18n/ja/dashboard.ts | 81 ++++--- shared/src/i18n/ko/dashboard.ts | 81 ++++--- shared/src/i18n/nl/dashboard.ts | 107 +++++---- shared/src/i18n/pl/dashboard.ts | 109 +++++---- shared/src/i18n/ru/dashboard.ts | 109 +++++---- shared/src/i18n/tr/dashboard.ts | 89 ++++---- shared/src/i18n/uk/dashboard.ts | 81 ++++--- shared/src/i18n/zh-TW/dashboard.ts | 109 +++++---- shared/src/i18n/zh/dashboard.ts | 109 +++++---- 34 files changed, 1692 insertions(+), 1296 deletions(-) create mode 100644 client/src/components/Layout/MobileTopBar.test.tsx create mode 100644 client/src/components/Layout/MobileTopBar.tsx diff --git a/client/src/components/Layout/BottomNav.test.tsx b/client/src/components/Layout/BottomNav.test.tsx index bda3c40a..0647ec6f 100644 --- a/client/src/components/Layout/BottomNav.test.tsx +++ b/client/src/components/Layout/BottomNav.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009 +// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-006 vi.mock('../../api/websocket', () => ({ connect: vi.fn(), @@ -16,7 +16,7 @@ vi.mock('react-router-dom', async () => { return { ...actual, useNavigate: () => mockNavigate }; }); -import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import { render, screen } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; @@ -39,82 +39,25 @@ describe('BottomNav', () => { expect(document.body).toBeInTheDocument(); }); - it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => { + it('FE-COMP-BOTTOMNAV-002: shows the dashboard nav item', () => { render(); expect(screen.getByText('My Trips')).toBeInTheDocument(); }); - it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => { - render(); - expect(screen.getByText('Profile')).toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => { + it('FE-COMP-BOTTOMNAV-003: centre create button creates a new trip by default', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText('Profile')); - // Profile sheet shows username - expect(screen.getByText('testuser')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'New Trip' })); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard?create=1'); }); - it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('Profile')); - expect(screen.getByText('testuser')).toBeInTheDocument(); - expect(screen.getByText('test@example.com')).toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('Profile')); - expect(screen.getByText('Settings')).toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('Profile')); - expect(screen.getByText('Logout')).toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => { - const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' }); - seedStore(useAuthStore, { user: adminUser, isAuthenticated: true }); - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('Profile')); - expect(screen.getByText('Admin')).toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('Profile')); - // Sheet is open — username visible - expect(screen.getByText('testuser')).toBeInTheDocument(); - // The outermost fixed div is the backdrop wrapper, clicking it triggers onClose - const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; - expect(backdrop).toBeTruthy(); - fireEvent.click(backdrop); - // Sheet should be closed — username no longer visible (only the nav Profile text remains) - expect(screen.queryByText('testuser')).not.toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', async () => { + it('FE-COMP-BOTTOMNAV-004: dashboard label translates when language is fr', async () => { seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); render(); expect(await screen.findByText('Mes voyages')).toBeInTheDocument(); }); - it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', async () => { - seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); - render(); - expect(await screen.findByText('Profil')).toBeInTheDocument(); - }); - - it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', async () => { + it('FE-COMP-BOTTOMNAV-005: addon labels translate when language is fr', async () => { seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); seedStore(useAddonStore, { addons: [ @@ -129,7 +72,7 @@ describe('BottomNav', () => { expect(await screen.findByText('Journal de voyage')).toBeInTheDocument(); }); - it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => { + it('FE-COMP-BOTTOMNAV-006: unknown addon id is not rendered', () => { seedStore(useAddonStore, { addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }], }); diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx index 59fd9854..5f763712 100644 --- a/client/src/components/Layout/BottomNav.tsx +++ b/client/src/components/Layout/BottomNav.tsx @@ -1,10 +1,8 @@ -import { useState } from 'react' -import { NavLink, useNavigate } from 'react-router-dom' +import { useNavigate, useLocation, useMatch } from 'react-router-dom' import { useAddonStore } from '../../store/addonStore' -import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' -import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react' +import { LayoutGrid, CalendarDays, Globe, Compass, Plus } from 'lucide-react' import type { LucideIcon } from 'lucide-react' const ADDON_NAV: Record = { @@ -13,150 +11,106 @@ const ADDON_NAV: Record = { journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' }, } +interface NavItem { to: string; label: string; icon: LucideIcon } + +// The centre "+" means something different per context: inside a trip it adds a +// place, on the journey list it starts a journey, inside a journey it adds an +// entry — everywhere else it creates a new trip. Pages pick the intent up from +// the ?create= query param. +function useCreateAction(): { label: string; run: () => void } { + const navigate = useNavigate() + const { t } = useTranslation() + const inTrip = useMatch('/trips/:id') + const inJourney = useMatch('/journey/:id') + const onJourneyList = useMatch('/journey') + + if (inTrip) { + return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) } + } + if (inJourney) { + return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) } + } + if (onJourneyList) { + return { label: t('journey.new'), run: () => navigate('/journey?create=1') } + } + return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') } +} + export default function BottomNav() { const { t } = useTranslation() + const navigate = useNavigate() const darkMode = useSettingsStore(s => s.settings.dark_mode) const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const addons = useAddonStore(s => s.addons) const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) - const [showProfile, setShowProfile] = useState(false) + const location = useLocation() + const create = useCreateAction() - const items: { to: string; label: string; icon: LucideIcon }[] = [ - { to: '/trips', label: t('nav.myTrips'), icon: Plane }, + const items: NavItem[] = [ + { to: '/dashboard', label: t('nav.myTrips'), icon: LayoutGrid }, ...globalAddons.flatMap(addon => { const nav = ADDON_NAV[addon.id] return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [] }), ] + // Split the items so the raised "+" sits dead centre. + const splitAt = Math.ceil(items.length / 2) + const left = items.slice(0, splitAt) + const right = items.slice(splitAt) + + const isActive = (to: string) => + to === '/dashboard' ? location.pathname === '/dashboard' : location.pathname.startsWith(to) + + const renderItem = ({ to, label, icon: Icon }: NavItem) => { + const active = isActive(to) + return ( + + ) + } return ( - <> - + + - {showProfile && setShowProfile(false)} />} - - ) -} - -function ProfileSheet({ onClose }: { onClose: () => void }) { - const { t } = useTranslation() - const { user, logout } = useAuthStore() - const navigate = useNavigate() - - const handleNav = (path: string) => { - onClose() - navigate(path) - } - - const handleLogout = () => { - onClose() - logout() - navigate('/login') - } - - return ( -
- {/* Backdrop */} -
- - {/* Sheet */} -
e.stopPropagation()} - > - {/* Handle */} -
-
-
- - {/* User info */} -
-
-
- {(user?.username || '?')[0].toUpperCase()} -
-
-

{user?.username}

-

{user?.email}

-
- {user?.role === 'admin' && ( - - Admin - - )} -
-
- -
- - {/* Links */} -
- - - {user?.role === 'admin' && ( - - )} -
- -
- - {/* Logout */} -
- -
- -
-
-
+
{right.map(renderItem)}
+ ) } diff --git a/client/src/components/Layout/MobileTopBar.test.tsx b/client/src/components/Layout/MobileTopBar.test.tsx new file mode 100644 index 00000000..c556f62f --- /dev/null +++ b/client/src/components/Layout/MobileTopBar.test.tsx @@ -0,0 +1,80 @@ +// FE-COMP-MOBILETOPBAR-001 to FE-COMP-MOBILETOPBAR-007 + +vi.mock('./InAppNotificationBell', () => ({ default: () => null })); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildUser, buildSettings } from '../../../tests/helpers/factories'; +import MobileTopBar from './MobileTopBar'; + +const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); + +beforeEach(() => { + resetAllStores(); + mockNavigate.mockClear(); + seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); +}); + +describe('MobileTopBar', () => { + it('FE-COMP-MOBILETOPBAR-001: renders the profile avatar (no brand logo)', () => { + render(, { initialEntries: ['/dashboard'] }); + expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument(); + expect(screen.queryByText('trek')).not.toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPBAR-002: avatar opens the profile sheet', async () => { + const user = userEvent.setup(); + render(, { initialEntries: ['/dashboard'] }); + await user.click(screen.getByRole('button', { name: 'Profile' })); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPBAR-003: profile sheet shows Settings', async () => { + const user = userEvent.setup(); + render(, { initialEntries: ['/dashboard'] }); + await user.click(screen.getByRole('button', { name: 'Profile' })); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPBAR-004: profile sheet shows Logout', async () => { + const user = userEvent.setup(); + render(, { initialEntries: ['/dashboard'] }); + await user.click(screen.getByRole('button', { name: 'Profile' })); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPBAR-005: admin badge shown for admin users', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 2, username: 'adminuser', role: 'admin' }), isAuthenticated: true }); + const user = userEvent.setup(); + render(, { initialEntries: ['/dashboard'] }); + await user.click(screen.getByRole('button', { name: 'Profile' })); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPBAR-006: backdrop click closes the profile sheet', async () => { + const user = userEvent.setup(); + render(, { initialEntries: ['/dashboard'] }); + await user.click(screen.getByRole('button', { name: 'Profile' })); + expect(screen.getByText('testuser')).toBeInTheDocument(); + const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop); + expect(screen.queryByText('testuser')).not.toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPBAR-007: profile label translates when language is fr', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); + render(, { initialEntries: ['/dashboard'] }); + expect(await screen.findByRole('button', { name: 'Profil' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Layout/MobileTopBar.tsx b/client/src/components/Layout/MobileTopBar.tsx new file mode 100644 index 00000000..89a89915 --- /dev/null +++ b/client/src/components/Layout/MobileTopBar.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '../../store/authStore' +import { useInAppNotificationStore } from '../../store/inAppNotificationStore' +import { useTranslation } from '../../i18n' +import { Bell, Settings, Shield, LogOut } from 'lucide-react' + +// Mobile-only: a slim strip at the very top of the dashboard with the +// notification + profile icons (right-aligned). Scrolls with the page. +export default function MobileTopBar() { + const { t } = useTranslation() + const navigate = useNavigate() + const { user, isAuthenticated } = useAuthStore() + const unread = useInAppNotificationStore(s => s.unreadCount) + const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount) + const [showProfile, setShowProfile] = useState(false) + + useEffect(() => { if (isAuthenticated) fetchUnreadCount() }, [isAuthenticated]) + + return ( + <> +
+ + +
+ + {showProfile && createPortal( setShowProfile(false)} />, document.body)} + + ) +} + +function ProfileSheet({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const { user, logout } = useAuthStore() + const navigate = useNavigate() + + const handleNav = (path: string) => { onClose(); navigate(path) } + const handleLogout = () => { onClose(); logout(); navigate('/login') } + + return ( +
+
+
e.stopPropagation()} + > +
+
+
+ +
+
+
+ {(user?.username || '?')[0].toUpperCase()} +
+
+

{user?.username}

+

{user?.email}

+
+ {user?.role === 'admin' && ( + + Admin + + )} +
+
+ +
+ +
+ + + {user?.role === 'admin' && ( + + )} +
+ +
+ +
+ +
+ +
+
+
+ ) +} diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 9e26acaf..9d704e25 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -65,7 +65,7 @@ describe('DashboardPage', () => { }); describe('FE-PAGE-DASH-004: Empty state when no trips', () => { - it('shows empty state message when API returns no trips', async () => { + it('shows the add-trip card when API returns no trips', async () => { server.use( http.get('/api/trips', () => { return HttpResponse.json({ trips: [] }); @@ -74,8 +74,9 @@ describe('DashboardPage', () => { render(); + // With no trips the planned filter falls back to the "add trip" card await waitFor(() => { - expect(screen.getByText(/no trips yet/i)).toBeInTheDocument(); + expect(screen.getByText(/plan a new trip from scratch/i)).toBeInTheDocument(); }); }); }); @@ -206,17 +207,11 @@ describe('DashboardPage', () => { }); }); - describe('FE-PAGE-DASH-011: Archive trip moves it to archived section', () => { - it('archiving a trip removes it from active and shows it in archived section', async () => { + describe('FE-PAGE-DASH-011: Archive trip moves it to the archive filter', () => { + it('archiving a trip removes it from active and shows it under the archive filter', async () => { const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: true }); server.use( - http.put('/api/trips/:id', async ({ request }) => { - const body = await request.json() as Record; - if (body.is_archived === true) { - return HttpResponse.json({ trip: archivedTrip }); - } - return HttpResponse.json({ trip: archivedTrip }); - }), + http.put('/api/trips/:id', () => HttpResponse.json({ trip: archivedTrip })), ); const user = userEvent.setup(); @@ -226,17 +221,12 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); - // Click archive button - const archiveButtons = screen.getAllByRole('button', { name: /archive/i }); + // The spotlight hero exposes an icon-only archive action + const archiveButtons = screen.getAllByRole('button', { name: /archive/i }).filter(b => !b.textContent?.trim()); await user.click(archiveButtons[0]); - // Wait for archived section toggle to appear - await waitFor(() => { - expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); - }); - - // Click "Archived" toggle to show archived trips - await user.click(screen.getByRole('button', { name: /archived/i })); + // Switch to the archive filter segment + await user.click(screen.getByText('Archive')); await waitFor(() => { expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); @@ -272,8 +262,8 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); - // Find the view mode toggle button (shows List icon when in grid mode, title "List view") - const viewToggle = screen.getByTitle(/list view/i); + // The view-mode toggle flips grid ↔ list and persists the choice + const viewToggle = screen.getByRole('button', { name: /toggle view/i }); await user.click(viewToggle); // localStorage should be updated to 'list' @@ -281,8 +271,8 @@ describe('DashboardPage', () => { }); }); - describe('FE-PAGE-DASH-014: Archived trips section toggles visibility', () => { - it('shows archived trips when the archived section toggle is clicked', async () => { + describe('FE-PAGE-DASH-014: Archive filter reveals archived trips', () => { + it('shows archived trips when the archive filter is selected', async () => { const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true }); server.use( http.get('/api/trips', ({ request }) => { @@ -302,13 +292,8 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); - // Archived section toggle should be present - await waitFor(() => { - expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); - }); - - // Click to expand - await user.click(screen.getByRole('button', { name: /archived/i })); + // Switch to the archive filter + await user.click(screen.getByText('Archive')); await waitFor(() => { expect(screen.getByText('Old Rome Trip')).toBeInTheDocument(); @@ -343,7 +328,7 @@ describe('DashboardPage', () => { }); // Switch to list view - const viewToggle = screen.getByTitle(/list view/i); + const viewToggle = screen.getByRole('button', { name: /toggle view/i }); await user.click(viewToggle); // Non-spotlight trips should be visible in list view @@ -367,7 +352,7 @@ describe('DashboardPage', () => { }); // Switch to list view - const viewToggle = screen.getByTitle(/list view/i); + const viewToggle = screen.getByRole('button', { name: /toggle view/i }); await user.click(viewToggle); // Non-spotlight trips render in list view @@ -397,8 +382,8 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); - // Find copy buttons - const copyButtons = screen.getAllByRole('button', { name: /copy/i }); + // Find duplicate buttons (the copy action is labelled "Duplicate") + const copyButtons = screen.getAllByRole('button', { name: /duplicate/i }); await user.click(copyButtons[0]); // Confirm the copy dialog @@ -411,28 +396,18 @@ describe('DashboardPage', () => { }); }); - describe('FE-PAGE-DASH-019: Widget settings dropdown opens and closes', () => { - it('clicking the settings button shows the widget toggles', async () => { - const user = userEvent.setup(); + describe('FE-PAGE-DASH-019: Currency converter widget renders in the sidebar', () => { + it('shows the currency widget with from/to fields', async () => { render(); await waitFor(() => { expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0); }); - // Find settings button — the gear icon button (icon-only, no visible label) - const allBtns = screen.getAllByRole('button'); - const settingsButton = allBtns.find(btn => - btn.querySelector('.lucide-settings') && !btn.textContent?.trim() - ); - - expect(settingsButton).toBeDefined(); - if (settingsButton) { - await user.click(settingsButton); - await waitFor(() => { - expect(screen.getByText('Widgets:')).toBeInTheDocument(); - }); - } + // The sidebar currency tool exposes its title and the From/To converter fields + expect(screen.getByText('Currency')).toBeInTheDocument(); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); }); }); @@ -463,23 +438,23 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); + expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); - // Expand archived section - await user.click(screen.getByRole('button', { name: /archived/i })); + // Switch to the archive filter + await user.click(screen.getByText('Archive')); await waitFor(() => { expect(screen.getByText('Old Rome Trip')).toBeInTheDocument(); }); - // Click restore button - const restoreBtn = screen.getByRole('button', { name: /restore/i }); - await user.click(restoreBtn); + // An archived card's archive action is labelled "Restore" and toggles the trip back to active + const card = screen.getByText('Old Rome Trip').closest('.trip-card') as HTMLElement; + await user.click(card.querySelector('[aria-label="Restore"]') as HTMLElement); - // After restore, archived section should disappear (no more archived trips) + // Once restored there are no archived trips left to show await waitFor(() => { - expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument(); + expect(screen.queryByText('Old Rome Trip')).not.toBeInTheDocument(); }); }); }); @@ -572,15 +547,12 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Current Voyage').length).toBeGreaterThan(0); }); - // Live badge text appears (mobile + desktop spotlight) + // Live badge appears on the boarding-pass hero for an ongoing trip await waitFor(() => { expect(screen.getAllByText(/live now/i).length).toBeGreaterThan(0); }); - // Progress bar label "Trip progress" appears - expect(screen.getAllByText(/trip progress/i).length).toBeGreaterThan(0); - - // "days left" label appears inside the progress section + // The countdown ring labels the remaining days expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0); }); }); @@ -612,11 +584,10 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Upcoming Safari').length).toBeGreaterThan(0); }); - // Badge should show "X days left" countdown (not "Live now") + // An upcoming trip is not "live", and the countdown cell counts down to the start expect(screen.queryByText(/live now/i)).not.toBeInTheDocument(); - // The SpotlightCard renders a badge with the countdown text containing "days" await waitFor(() => { - expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/trip starts in/i).length).toBeGreaterThan(0); }); }); }); @@ -636,39 +607,22 @@ describe('DashboardPage', () => { }); }); - describe('FE-PAGE-DASH-026: Widget settings toggles currency and timezone', () => { - it('toggling currency widget off hides it from settings', async () => { - const user = userEvent.setup(); + describe('FE-PAGE-DASH-026: Timezone widget renders in the sidebar', () => { + it('shows the timezone widget with an add-zone control', async () => { render(); await waitFor(() => { expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0); }); - // Open widget settings — gear icon button (icon-only, no visible label) - const allBtns = screen.getAllByRole('button'); - const settingsButton = allBtns.find(btn => - btn.querySelector('.lucide-settings') && !btn.textContent?.trim() - ); - - expect(settingsButton).toBeDefined(); - if (settingsButton) { - await user.click(settingsButton); - - await waitFor(() => { - expect(screen.getByText('Widgets:')).toBeInTheDocument(); - }); - - // Both currency and timezone toggle labels should be visible - // Use getAllByText because labels may appear in both widget settings and quick actions - expect(screen.getAllByText(/currency/i).length).toBeGreaterThan(0); - expect(screen.getAllByText(/timezone/i).length).toBeGreaterThan(0); - } + // The timezone tool title and its add-zone button are present + expect(screen.getByText('Timezones')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument(); }); }); - describe('FE-PAGE-DASH-027: Archived section expand and collapse', () => { - it('expands and then collapses the archived trips section', async () => { + describe('FE-PAGE-DASH-027: Archive filter toggles archived trips in and out of view', () => { + it('shows archived trips under the archive filter and hides them under planned', async () => { const activeTrip = buildTrip({ title: 'Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' }); const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: true }); @@ -686,17 +640,17 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); + expect(screen.getAllByText('Active Trip')[0]).toBeInTheDocument(); }); - // Expand - await user.click(screen.getByRole('button', { name: /archived/i })); + // Archive filter reveals the archived trip + await user.click(screen.getByText('Archive')); await waitFor(() => { expect(screen.getByText('Old Archived Trip')).toBeInTheDocument(); }); - // Collapse - await user.click(screen.getByRole('button', { name: /archived/i })); + // Switching back to the planned filter hides it again + await user.click(screen.getByText('Planned')); await waitFor(() => { expect(screen.queryByText('Old Archived Trip')).not.toBeInTheDocument(); }); @@ -730,21 +684,22 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument(); + expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /archived/i })); + await user.click(screen.getByText('Archive')); await waitFor(() => { expect(screen.getByText('Restored Trip')).toBeInTheDocument(); }); - const restoreBtn = screen.getByRole('button', { name: /restore/i }); - await user.click(restoreBtn); + // An archived card's archive action is labelled "Restore" and restores the trip + const card = screen.getByText('Restored Trip').closest('.trip-card') as HTMLElement; + await user.click(card.querySelector('[aria-label="Restore"]') as HTMLElement); - // After restore, the archived section should disappear (no archived trips left) + // After restore the archive filter has nothing left to show await waitFor(() => { - expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument(); + expect(screen.queryByText('Restored Trip')).not.toBeInTheDocument(); }); }); }); @@ -765,8 +720,8 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument(); }); - // Find copy buttons (may appear in mobile + desktop) - const copyButtons = screen.getAllByRole('button', { name: /copy/i }); + // Find duplicate buttons (the copy action is labelled "Duplicate") + const copyButtons = screen.getAllByRole('button', { name: /duplicate/i }); expect(copyButtons.length).toBeGreaterThan(0); await user.click(copyButtons[0]); @@ -791,10 +746,10 @@ describe('DashboardPage', () => { render(); await waitFor(() => { - expect(screen.getByText(/no trips yet/i)).toBeInTheDocument(); + expect(screen.getByText(/plan a new trip from scratch/i)).toBeInTheDocument(); }); - // Empty state should show a descriptive text and a create button + // The add-trip card and the floating action button both offer a way to create a trip const createButtons = screen.getAllByRole('button'); const createBtn = createButtons.find(btn => btn.textContent?.toLowerCase().includes('trip')); expect(createBtn).toBeDefined(); @@ -828,16 +783,15 @@ describe('DashboardPage', () => { expect(screen.getAllByText('Live Adventure').length).toBeGreaterThan(0); }); - // Stats section: places count "5" and buddies count "2" appear + // Boarding pass summarises destinations and travelers for the spotlight trip await waitFor(() => { - expect(screen.getAllByText('5').length).toBeGreaterThan(0); - expect(screen.getAllByText('2').length).toBeGreaterThan(0); + expect(screen.getByText(/5 destinations/i)).toBeInTheDocument(); + // shared_count (2) + the owner = 3 travelers + expect(screen.getByText(/3 travelers/i)).toBeInTheDocument(); }); - // Days stat label - expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0); - // Places stat label - expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0); + // The countdown ring labels the remaining days of the ongoing trip + expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0); }); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 9965d704..7dbd929b 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -11,6 +11,8 @@ import TripFormModal from '../components/Trips/TripFormModal' import ConfirmDialog from '../components/shared/ConfirmDialog' import CopyTripDialog from '../components/shared/CopyTripDialog' import CustomSelect from '../components/shared/CustomSelect' +import PlaceAvatar from '../components/shared/PlaceAvatar' +import MobileTopBar from '../components/Layout/MobileTopBar' import { useToast } from '../components/shared/Toast' import { Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin, @@ -110,7 +112,11 @@ function initials(name: string | null | undefined): string { } interface Member { id: number; username: string; avatar_url?: string | null } -interface Place { id: number; name: string; image_url?: string | null } +interface Place { + id: number; name: string; image_url: string | null; lat: number | null; lng: number | null + google_place_id: string | null; osm_id: string | null + category_color?: string | null; category_icon?: string | null +} interface HeroBundle { members: Member[]; places: Place[] } interface TravelStats { totalTrips?: number; totalDays?: number; totalPlaces?: number; totalDistanceKm?: number; countries?: string[] } interface UpcomingReservation { @@ -124,6 +130,18 @@ const RES_ICON: Record = { } const RES_TYPE_CLASS: Record = { flight: 'flight', hotel: 'hotel', restaurant: 'food' } +// Mobile gets a different boarding-pass treatment (separate card under the hero). +function useIsMobile(): boolean { + const [mobile, setMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 720px)').matches) + useEffect(() => { + const mq = window.matchMedia('(max-width: 720px)') + const onChange = () => setMobile(mq.matches) + mq.addEventListener('change', onChange) + return () => mq.removeEventListener('change', onChange) + }, []) + return mobile +} + export default function DashboardPage(): React.ReactElement { const [trips, setTrips] = useState([]) const [archivedTrips, setArchivedTrips] = useState([]) @@ -274,10 +292,11 @@ export default function DashboardPage(): React.ReactElement { : rest.filter(t => getTripStatus(t) !== 'past') return ( -
+
{demoMode && } -
+
+
{spotlight && ( @@ -304,10 +323,10 @@ export default function DashboardPage(): React.ReactElement {
- -
@@ -395,45 +414,36 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch onEdit: () => void; onCopy: () => void; onArchive: () => void; onDelete: () => void }): React.ReactElement { const { t } = useTranslation() + const mobile = useIsMobile() const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() } const status = getTripStatus(trip) const start = splitDate(trip.start_date, locale) const end = splitDate(trip.end_date, locale) - const dayCount = trip.day_count || (trip.start_date && trip.end_date - ? Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / MS_PER_DAY) + 1 - : null) + // Countdown cell — plain text in the same style as the trip-dates cell: + // days remaining while the trip runs, days until departure before it starts. const until = daysUntil(trip.start_date) const ongoing = status === 'ongoing' - let ringFraction = 0 + let countdownTop = '' let countdownNumber = '' let countdownLabel = '' if (ongoing && trip.end_date) { const todayMid = new Date(); todayMid.setHours(0, 0, 0, 0) const endMid = new Date(trip.end_date + 'T00:00:00') const daysLeft = Math.max(0, Math.round((endMid.getTime() - todayMid.getTime()) / MS_PER_DAY)) - // Ring tracks progress through the trip; the number shows what's left. - if (trip.start_date && dayCount) { - const elapsed = Math.round((Date.now() - new Date(trip.start_date + 'T00:00:00').getTime()) / MS_PER_DAY) + 1 - ringFraction = Math.min(1, Math.max(0.04, elapsed / dayCount)) - } else { - ringFraction = 0.5 - } + countdownTop = t('dashboard.status.ongoing') countdownNumber = String(daysLeft) countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : daysLeft === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft') } else if (until !== null && until >= 0) { - // Closer trips fill more of the ring (1-year horizon). - ringFraction = Math.min(1, Math.max(0.04, 1 - until / 365)) + countdownTop = t('dashboard.hero.startsIn') countdownNumber = String(until) - countdownLabel = until === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft') + countdownLabel = until === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany') } - const RING_LEN = 170 - const dashOffset = RING_LEN * (1 - ringFraction) const members = bundle?.members || [] - const places = (bundle?.places || []).filter(p => p.image_url) + const places = bundle?.places || [] const buddyCount = trip.shared_count != null ? trip.shared_count + 1 : members.length - const placeCount = trip.place_count || (bundle?.places.length ?? 0) + const placeCount = trip.place_count || places.length const badge = status === 'ongoing' ? t('dashboard.hero.badgeLive') : status === 'today' ? t('dashboard.hero.badgeToday') @@ -441,7 +451,61 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch : status === 'future' ? t('dashboard.hero.badgeNext') : t('dashboard.hero.badgeRecent') + const passCells = ( + <> +
+
{t('dashboard.members')}
+
+ {members.slice(0, 4).map((m, i) => ( + m.avatar_url + ? {m.username} + :
{initials(m.username)}
+ ))} + {members.length > 4 &&
+{members.length - 4}
} + {members.length === 0 &&
{initials(trip.owner_username)}
} +
+
{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}
+
+ +
+
{t('dashboard.hero.tripDates')}
+
+ {start ?
{start.d}
{start.m}
+ :
} +
+ {end ?
{end.d}
{end.m}
+ :
} +
+
+ +
+ {countdownNumber && ( + <> +
{countdownTop}
+
{countdownNumber}
+
{countdownLabel}
+ + )} +
+ +
+
{t('dashboard.places')}
+
+ {places.slice(0, 3).map(p => ( +
+ +
+ ))} + {places.length === 0 &&
} + {places.length > 3 &&
+{places.length - 3}
} +
+
{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}
+
+ + ) + return ( + <>
{trip.cover_image ? {trip.title} @@ -454,10 +518,10 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {badge}
- - - - + + + +
@@ -465,66 +529,13 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch

{trip.title}

-
{ e.stopPropagation(); onOpen() }}> -
-
{t('dashboard.members')}
-
- {members.slice(0, 4).map((m, i) => ( - m.avatar_url - ? {m.username} - :
{initials(m.username)}
- ))} - {members.length > 4 &&
+{members.length - 4}
} - {members.length === 0 &&
{initials(trip.owner_username)}
} -
-
{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}
-
- -
-
{t('dashboard.hero.tripDates')}
-
- {start ?
{start.d}
{start.m}
- :
} -
- {end ?
{end.d}
{end.m}
- :
} -
-
{dayCount ? `${dayCount} ${dayCount === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany')}` : t('dashboard.hero.noDates')}
-
- -
- {countdownNumber && ( - <> -
- - - - - -
{Math.round(ringFraction * 100)}%
-
-
-
{countdownNumber}
-
{countdownLabel}
-
- - )} -
- -
-
{t('dashboard.places')}
-
- {places.slice(0, 4).map(p => ( - {p.name} - ))} - {places.length === 0 &&
} - {placeCount > 4 &&
+{placeCount - 4}
} -
-
{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}
-
-
+ {!mobile && ( +
{ e.stopPropagation(); onOpen() }}>{passCells}
+ )}
+ {mobile &&
{passCells}
} + ) } @@ -612,10 +623,10 @@ function TripCard({ trip, locale, onOpen, onEdit, onCopy, onArchive, onDelete }: :
}
{statusLabel}
- - - - + + + +

{trip.title}

@@ -672,7 +683,7 @@ function CurrencyTool(): React.ReactElement {
{t('dashboard.currency')}
- +
@@ -680,7 +691,7 @@ function CurrencyTool(): React.ReactElement { setAmount(e.target.value)} inputMode="decimal" />
- +
{t('dashboard.fx.to')}
@@ -753,7 +764,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
{t('dashboard.timezone')}
-
@@ -771,7 +782,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
{offsetLabel(tz)}
{timeIn(tz)}
- +
))} {zones.length === 0 && ( diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index d0c20609..35da24ae 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -3,7 +3,7 @@ import { formatLocationName } from '../utils/formatters' import { normalizeImageFiles } from '../utils/convertHeic' import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue' import { createPortal } from 'react-dom' -import { useParams, useNavigate } from 'react-router-dom' +import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' import { useTranslation } from '../i18n' @@ -113,6 +113,16 @@ export default function JourneyDetailPage() { const [deleteTarget, setDeleteTarget] = useState(null) const [showInvite, setShowInvite] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false) + const [searchParams, setSearchParams] = useSearchParams() + + // The bottom-nav "+" starts a new entry via ?create=entry. + useEffect(() => { + if (searchParams.get('create') === 'entry' && current && canEditEntries) { + const today = new Date().toISOString().slice(0, 10) + setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry) + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } + }, [searchParams, current, canEditEntries]) const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) const [showSettings, setShowSettings] = useState(false) const [hideSkeletons, setHideSkeletons] = useState(false) diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx index 92a7d3ec..916b1169 100644 --- a/client/src/pages/JourneyPage.tsx +++ b/client/src/pages/JourneyPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useMemo, useRef } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { journeyApi } from '../api/client' import Navbar from '../components/Layout/Navbar' @@ -52,11 +52,21 @@ export default function JourneyPage() { const [suggestions, setSuggestions] = useState([]) const [dismissedSuggestions, setDismissedSuggestions] = useState>(new Set()) + const [searchParams, setSearchParams] = useSearchParams() + useEffect(() => { loadJourneys() journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {}) }, []) + // The bottom-nav "+" opens the new-journey modal via ?create=1. + useEffect(() => { + if (searchParams.get('create') === '1') { + openCreateModal() + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } + }, [searchParams]) + const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id)) const activeJourney = useMemo(() => { diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 27826347..bff7268a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import ReactDOM from 'react-dom' -import { useParams, useNavigate } from 'react-router-dom' +import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useTripStore } from '../store/tripStore' import { useCanDo } from '../store/permissionsStore' import { useSettingsStore } from '../store/settingsStore' @@ -261,6 +261,15 @@ export default function TripPlannerPage(): React.ReactElement | null { const [editingPlace, setEditingPlace] = useState(null) const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null) const [editingAssignmentId, setEditingAssignmentId] = useState(null) + const [searchParams, setSearchParams] = useSearchParams() + + // The bottom-nav "+" opens the new-place form via ?create=place. + useEffect(() => { + if (searchParams.get('create') === 'place') { + setEditingPlace(null); setEditingAssignmentId(null); setShowPlaceForm(true) + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } + }, [searchParams]) const [showTripForm, setShowTripForm] = useState(false) const [showMembersModal, setShowMembersModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false) diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index e1b0ab21..ba8e7ceb 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -42,19 +42,27 @@ font-feature-settings: "ss01", "cv11"; letter-spacing: -0.005em; min-height: 100%; + + /* liquid-glass surface tokens (shared by all tiles) */ + --glass-bg: linear-gradient(135deg, oklch(1 0 0 / .72) 0%, oklch(0.99 0.006 75 / .5) 100%); + --glass-border: oklch(0.88 0.008 70 / .7); + --glass-shadow: 0 1px 2px oklch(0.4 0.02 60 / .05), 0 12px 32px -14px oklch(0.3 0.02 60 / .2); + --glass-shadow-hover: 0 2px 6px oklch(0.4 0.02 60 / .07), 0 26px 56px -20px oklch(0.25 0.04 60 / .32); + --glass-highlight: inset 0 1px 0 oklch(1 0 0 / .8); + --glass-blur: blur(22px) saturate(1.7); } /* dark variant — same geometry, dark surfaces, accent kept */ .dark .trek-dash { - --bg: oklch(0.17 0.012 65); - --bg-2: oklch(0.21 0.012 65); - --surface: oklch(0.225 0.012 65); - --surface-2: oklch(0.255 0.012 65); - --ink: oklch(0.96 0.006 70); - --ink-2: oklch(0.78 0.008 70); - --ink-3: oklch(0.6 0.008 70); - --line: oklch(0.32 0.01 65); - --line-2: oklch(0.38 0.012 65); + --bg: oklch(0.17 0 0); + --bg-2: oklch(0.21 0 0); + --surface: oklch(0.225 0 0); + --surface-2: oklch(0.255 0 0); + --ink: oklch(0.96 0 0); + --ink-2: oklch(0.78 0 0); + --ink-3: oklch(0.6 0 0); + --line: oklch(0.32 0 0); + --line-2: oklch(0.38 0 0); --accent: oklch(0.7 0.16 40); --accent-ink: oklch(0.82 0.13 50); --accent-soft: oklch(0.32 0.07 45); @@ -65,8 +73,20 @@ --sh-sm: 0 1px 2px oklch(0 0 0 / .3), 0 2px 6px oklch(0 0 0 / .35); --sh-md: 0 1px 2px oklch(0 0 0 / .35), 0 8px 24px -8px oklch(0 0 0 / .5); --sh-lg: 0 2px 4px oklch(0 0 0 / .4), 0 20px 50px -16px oklch(0 0 0 / .7); + + /* liquid-glass surface tokens — dark */ + --glass-bg: linear-gradient(135deg, oklch(0.31 0 0 / .58) 0%, oklch(0.25 0 0 / .42) 100%); + --glass-border: oklch(1 0 0 / .1); + --glass-shadow: 0 1px 2px oklch(0 0 0 / .3), 0 12px 32px -14px oklch(0 0 0 / .55); + --glass-shadow-hover: 0 2px 6px oklch(0 0 0 / .4), 0 26px 56px -20px oklch(0 0 0 / .72); + --glass-highlight: inset 0 1px 0 oklch(1 0 0 / .09); } +/* App shell: desktop is a fixed full-height column with its own scroll area. + On mobile (see media query) it flows normally inside the global chrome. */ +.trek-dash-shell { position: fixed; inset: 0; display: flex; flex-direction: column; } +.trek-dash-scroll { flex: 1; overflow: auto; overscroll-behavior: contain; margin-top: var(--nav-h); } + .trek-dash * { box-sizing: border-box; } .trek-dash .mono { font-family: "Poppins", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font-feature-settings: "tnum"; } .trek-dash button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; } @@ -224,7 +244,7 @@ transform: translateZ(0); } .dark .trek-dash .hero-pass { - background: linear-gradient(135deg, oklch(0.28 0.012 65 / .8) 0%, oklch(0.25 0.012 65 / .85) 50%, oklch(0.23 0.012 65 / .8) 100%); + background: linear-gradient(135deg, oklch(0.28 0 0 / .8) 0%, oklch(0.25 0 0 / .85) 50%, oklch(0.23 0 0 / .8) 100%); border: 1px solid oklch(1 0 0 / .1); box-shadow: 0 2px 8px -2px oklch(0 0 0 / .3), 0 8px 24px -6px oklch(0 0 0 / .4), @@ -280,33 +300,39 @@ .dark .trek-dash .buddy-avatar, .dark .trek-dash .place-thumb, .dark .trek-dash .buddy-more, -.dark .trek-dash .place-more { border-color: oklch(0.25 0.012 65 / .95); } +.dark .trek-dash .place-more { border-color: oklch(0.25 0 0 / .95); } .dark .trek-dash .buddy-more, -.dark .trek-dash .place-more { background: oklch(0.32 0.01 70); } +.dark .trek-dash .place-more { background: oklch(0.32 0 0); } .trek-dash .place-thumb { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 2px solid oklch(0.985 0.008 75 / .95); box-shadow: 0 2px 6px rgba(0,0,0,0.1); margin-left: -8px; } .trek-dash .place-thumb:first-child { margin-left: 0; } +.trek-dash .places-preview .place-av { + border-radius: 50%; line-height: 0; margin-left: -8px; + border: 2px solid oklch(0.985 0.008 75 / .95); box-shadow: 0 2px 6px rgba(0,0,0,0.1); +} +.trek-dash .places-preview .place-av:first-child { margin-left: 0; } +.dark .trek-dash .places-preview .place-av { border-color: oklch(0.25 0 0 / .95); } -.trek-dash .pass-cell.countdown { flex-direction: row; align-items: center; text-align: left; gap: 12px; } -.trek-dash .countdown-ring { position: relative; width: 64px; height: 64px; flex-shrink: 0; } -.trek-dash .countdown-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); } -.trek-dash .countdown-ring .track { stroke: oklch(0.92 0.01 70); stroke-width: 5; } -.dark .trek-dash .countdown-ring .track { stroke: oklch(0.4 0.01 70); } -.trek-dash .countdown-ring .fill { stroke: var(--ink); stroke-linecap: round; stroke-width: 5; } -.trek-dash .countdown-ring .glow { stroke: var(--ink); stroke-width: 5; stroke-linecap: round; opacity: 0.15; filter: blur(3px); } -.trek-dash .countdown-ring .pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; } -.trek-dash .countdown-info { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; } -.trek-dash .countdown-days { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; color: var(--ink); line-height: 1; } -.trek-dash .countdown-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); font-weight: 500; } +.trek-dash .pass-cell.countdown { gap: 6px; } /* ----------------- atlas / stats ----------------- */ .trek-dash .atlas { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 56px; } .trek-dash .atlas-card { - background: var(--surface); border-radius: var(--r-lg); padding: 24px 26px; - box-shadow: var(--sh-sm); position: relative; overflow: hidden; + background: var(--glass-bg); border-radius: var(--r-lg); padding: 24px 26px; + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow), var(--glass-highlight); + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + position: relative; overflow: hidden; + transition: transform .3s cubic-bezier(.2,.7,.2,1), box-shadow .3s, border-color .3s; } +.trek-dash .atlas-card:not(.passport):hover { + transform: translateY(-3px); + box-shadow: var(--glass-shadow-hover), var(--glass-highlight); + border-color: oklch(0.8 0.01 70 / .8); +} +.dark .trek-dash .atlas-card:not(.passport):hover { border-color: oklch(1 0 0 / .2); } .trek-dash .atlas-card .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); font-weight: 500; } .trek-dash .atlas-card .value { font-size: 44px; font-weight: 600; letter-spacing: -0.035em; line-height: 1; margin-top: 16px; display: flex; align-items: baseline; gap: 8px; } .trek-dash .atlas-card .value .unit { font-size: 17px; color: var(--ink-3); font-weight: 500; letter-spacing: -0.01em; } @@ -315,7 +341,7 @@ .trek-dash .atlas-card.passport { background: linear-gradient(135deg, oklch(0.95 0.01 70 / .15) 0%, oklch(0.98 0.005 70 / .08) 50%, oklch(1 0 0 / .12) 100%), - linear-gradient(180deg, oklch(0.15 0.02 65), oklch(0.08 0.01 70)); + linear-gradient(180deg, oklch(0.16 0 0), oklch(0.09 0 0)); color: #fff; border: 1px solid oklch(1 0 0 / .12); box-shadow: 0 8px 32px oklch(0 0 0 / .15), inset 0 1px 0 oklch(1 0 0 / .15), inset 0 -1px 0 oklch(0 0 0 / .3); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); @@ -355,11 +381,18 @@ .trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; } .trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; } .trek-dash .trip-card { - position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--surface); - box-shadow: var(--sh-md); transition: transform .25s cubic-bezier(.2,.7,.2,1), box-shadow .25s; + position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow), var(--glass-highlight); + transition: transform .25s cubic-bezier(.2,.7,.2,1), box-shadow .25s, border-color .25s; cursor: pointer; isolation: isolate; } -.trek-dash .trip-card:hover { transform: translateY(-4px); box-shadow: var(--sh-lg); } +.trek-dash .trip-card:hover { + transform: translateY(-4px); + box-shadow: var(--glass-shadow-hover), var(--glass-highlight); + border-color: oklch(0.8 0.01 70 / .8); +} +.dark .trek-dash .trip-card:hover { border-color: oklch(1 0 0 / .2); } .trek-dash .trip-cover { position: relative; aspect-ratio: 4 / 3; overflow: hidden; } .trek-dash .trip-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s cubic-bezier(.2,.7,.2,1); } .trek-dash .trip-card:hover .trip-cover img { transform: scale(1.04); } @@ -399,11 +432,16 @@ .trek-dash .trip-meta .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 500; } .trek-dash .add-trip-card { border-radius: var(--r-xl); border: 1.5px dashed var(--line-2); - background: oklch(0.97 0.008 75 / .5); display: grid; place-items: center; - text-align: center; padding: 32px; transition: background .15s, border-color .15s; cursor: pointer; min-height: 240px; + background: var(--glass-bg); display: grid; place-items: center; + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-highlight); + text-align: center; padding: 32px; cursor: pointer; min-height: 240px; + transition: transform .3s cubic-bezier(.2,.7,.2,1), box-shadow .3s, border-color .3s, color .15s; +} +.trek-dash .add-trip-card:hover { + transform: translateY(-3px); border-color: var(--ink); color: var(--ink); + box-shadow: var(--glass-shadow-hover), var(--glass-highlight); } -.dark .trek-dash .add-trip-card { background: oklch(0.22 0.01 65 / .5); } -.trek-dash .add-trip-card:hover { background: var(--surface-2); border-color: var(--ink); color: var(--ink); } .trek-dash .add-trip-card .circ { width: 48px; height: 48px; border-radius: 50%; background: #111827; color: #fff; display: grid; place-items: center; margin: 0 auto 14px; box-shadow: var(--sh-sm); @@ -415,7 +453,19 @@ .trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); } /* ----------------- tools sidebar ----------------- */ -.trek-dash .tool { background: var(--surface); border-radius: var(--r-xl); padding: 24px 26px; box-shadow: var(--sh-sm); } +.trek-dash .tool { + background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px; + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow), var(--glass-highlight); + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + transition: transform .3s cubic-bezier(.2,.7,.2,1), box-shadow .3s, border-color .3s; +} +.trek-dash .tool:hover { + transform: translateY(-2px); + box-shadow: var(--glass-shadow-hover), var(--glass-highlight); + border-color: oklch(0.8 0.01 70 / .8); +} +.dark .trek-dash .tool:hover { border-color: oklch(1 0 0 / .2); } .trek-dash .tool-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; } .trek-dash .tool-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); font-weight: 500; display: flex; align-items: center; gap: 8px; } .trek-dash .tool-title svg { width: 14px; height: 14px; } @@ -467,14 +517,72 @@ .trek-dash .atlas { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 720px) { - .trek-dash .page { padding: 24px 16px 96px; } + /* Flow inside the global chrome (top bar + floating tab bar) instead of fixed */ + .trek-dash-shell { position: static; inset: auto; display: block; min-height: 100%; } + .trek-dash-scroll { overflow: visible; margin-top: 0; } + .trek-dash .page { padding: 16px 16px 120px; gap: 0; } .trek-dash .greeting { grid-template-columns: 1fr; } .trek-dash .hello { font-size: 40px; } - .trek-dash .hero-trip { height: 420px; } - .trek-dash .hero-title { font-size: 52px; } - .trek-dash .hero-pass { grid-template-columns: 1fr 1fr; gap: 16px 0; } - .trek-dash .trips { grid-template-columns: 1fr; } - .trek-dash .atlas { grid-template-columns: 1fr 1fr; } + + /* Hero — immersive cover, title only (the pass is its own card below) */ + .trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); } + .trek-dash .hero-content { padding: 18px; } + /* the page already opens with the notification/profile strip, trim its top gap */ + .trek-dash .page { padding-top: 4px; } + .trek-dash .hero-title { font-size: 48px; } + + /* Boarding pass — separate 2×2 glass card under the hero (mockup) */ + .trek-dash .pass-card { + display: grid; grid-template-columns: 1fr 1fr; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); border-radius: 20px; overflow: hidden; + box-shadow: var(--glass-shadow), var(--glass-highlight); + margin-bottom: 22px; cursor: pointer; + } + .trek-dash .pass-card .pass-cell { + padding: 14px 12px; gap: 8px; flex: none; + border-right: 1px dashed var(--line-2); border-bottom: 1px dashed var(--line-2); + } + .trek-dash .pass-card .pass-cell:nth-child(2n) { border-right: 0; } + .trek-dash .pass-card .pass-cell:nth-last-child(-n+2) { border-bottom: 0; } + .trek-dash .pass-card .pass-cell + .pass-cell::before { display: none; } + .trek-dash .pass-card .date-num { font-size: 22px; } + /* Buddies + places circles: identical size, ring and overlap */ + .trek-dash .pass-card .buddies-avatars, + .trek-dash .pass-card .places-preview { display: flex; justify-content: center; align-items: center; } + .trek-dash .pass-card .buddy-avatar, + .trek-dash .pass-card .buddy-more, + .trek-dash .pass-card .place-av, + .trek-dash .pass-card .place-more { + width: 28px; height: 28px; border-radius: 50%; flex: none; + border: 2px solid var(--surface); margin-left: -8px; + box-shadow: 0 1px 3px oklch(0 0 0 / .12); + font-size: 10px; font-weight: 700; line-height: 0; + } + .trek-dash .pass-card .buddy-avatar:first-child, + .trek-dash .pass-card .place-av:first-child { margin-left: 0; } + + /* Atlas → single row of stat cards. Passport (countries) and distance are + hidden on mobile; only Trips total + Days traveled remain. */ + .trek-dash .atlas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 0 0 26px; } + .trek-dash .atlas-card.passport, + .trek-dash .atlas-card:last-child { display: none; } + .trek-dash .atlas .spark { display: none; } + .trek-dash .atlas-card .value { font-size: 30px; margin-top: 10px; } + + /* Trips — stacked header + full-width cards */ + .trek-dash .sec-head { flex-direction: column; align-items: stretch; gap: 14px; margin-bottom: 16px; } + .trek-dash .sec-title { font-size: 24px; } + .trek-dash .sec-tools { gap: 8px; } + .trek-dash .seg { flex: 1; } + .trek-dash .seg button { flex: 1; text-align: center; padding: 9px 8px; } + .trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; } + .trek-dash .add-trip-card { min-height: 180px; } + + /* Tools — stacked full-width cards (mockup) */ + .trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; } + .trek-dash .page-sidebar .tool { flex: none; width: auto; } } /* Floating action button — Neuer Trip */ @@ -496,10 +604,6 @@ .trek-dash .fab-new-trip svg { flex-shrink: 0; } @media (max-width: 768px) { - /* collapse to a round button and lift above the bottom nav */ - .trek-dash .fab-new-trip { - right: 18px; bottom: calc(84px + env(safe-area-inset-bottom, 0px) + 16px); - width: 56px; padding: 0; justify-content: center; gap: 0; - } - .trek-dash .fab-new-trip .fab-label { display: none; } + /* The bottom tab bar's centre "+" replaces the floating FAB on mobile */ + .trek-dash .fab-new-trip { display: none; } } diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index beed1a41..27831dfe 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -43,7 +43,7 @@ router.get('/login', async (req: Request, res: Response) => { const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; const inviteToken = req.query.invite as string | undefined; - const state = createState(redirectUri, inviteToken); + const { state, codeChallenge } = createState(redirectUri, inviteToken); const params = new URLSearchParams({ response_type: 'code', @@ -51,6 +51,8 @@ router.get('/login', async (req: Request, res: Response) => { redirect_uri: redirectUri, scope: process.env.OIDC_SCOPE || 'openid email profile', state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', }); res.redirect(`${doc.authorization_endpoint}?${params}`); @@ -92,7 +94,7 @@ router.get('/callback', async (req: Request, res: Response) => { try { const doc = await discover(config.issuer, config.discoveryUrl); - const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret); + const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier); if (!tokenData._ok || !tokenData.access_token) { console.error('[OIDC] Token exchange failed: status', tokenData._status); return res.redirect(frontendUrl('/login?oidc_error=token_failed')); diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 245e576c..d328263c 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -57,7 +57,7 @@ const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour // State management – pending OIDC states // --------------------------------------------------------------------------- -const pendingStates = new Map(); +const pendingStates = new Map(); setInterval(() => { const now = Date.now(); @@ -66,10 +66,19 @@ setInterval(() => { } }, STATE_CLEANUP); -export function createState(redirectUri: string, inviteToken?: string): string { +function base64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// Creates the login state and a matching PKCE pair. The verifier stays server +// side (in pendingStates); the S256 challenge goes to the provider so PKCE- +// required setups (e.g. Pocket ID with PKCE = required) work. +export function createState(redirectUri: string, inviteToken?: string): { state: string; codeChallenge: string } { const state = crypto.randomBytes(32).toString('hex'); - pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken }); - return state; + const codeVerifier = base64url(crypto.randomBytes(32)); + const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest()); + pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier }); + return { state, codeChallenge }; } export function consumeState(state: string) { @@ -204,17 +213,20 @@ export async function exchangeCodeForToken( redirectUri: string, clientId: string, clientSecret: string, + codeVerifier?: string, ): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }); + if (codeVerifier) body.set('code_verifier', codeVerifier); const tokenRes = await fetch(doc.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: clientId, - client_secret: clientSecret, - }), + body, }); const tokenData = (await tokenRes.json()) as OidcTokenResponse; return { ...tokenData, _ok: tokenRes.ok, _status: tokenRes.status }; diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index d57a935f..a2c1a0d1 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -160,7 +160,7 @@ describe('GET /api/auth/oidc/callback', () => { }); // Create a valid state token - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`); @@ -178,7 +178,7 @@ describe('GET /api/auth/oidc/callback', () => { name: 'New User', }); - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`); @@ -215,7 +215,7 @@ describe('GET /api/auth/oidc/callback', () => { mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC); mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 }); - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`); @@ -227,7 +227,7 @@ describe('GET /api/auth/oidc/callback', () => { mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC); mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`); @@ -240,7 +240,7 @@ describe('GET /api/auth/oidc/callback', () => { mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 }); mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' }); - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`); @@ -258,7 +258,7 @@ describe('GET /api/auth/oidc/callback', () => { name: 'Alice', }); - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`); @@ -281,7 +281,7 @@ describe('GET /api/auth/oidc/callback', () => { name: 'Blocked', }); - const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); + const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback'); const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`); diff --git a/server/tests/unit/services/oidcService.test.ts b/server/tests/unit/services/oidcService.test.ts index 5de82a4b..2b3c16f0 100644 --- a/server/tests/unit/services/oidcService.test.ts +++ b/server/tests/unit/services/oidcService.test.ts @@ -83,17 +83,20 @@ afterAll(() => { // ── createState / consumeState ──────────────────────────────────────────────── describe('createState / consumeState', () => { - it('OIDC-SVC-001: createState returns a hex token', () => { - const state = createState('https://example.com/callback'); + it('OIDC-SVC-001: createState returns a hex token + PKCE S256 challenge', () => { + const { state, codeChallenge } = createState('https://example.com/callback'); expect(state).toMatch(/^[0-9a-f]{64}$/); + expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); // base64url SHA-256, no padding }); - it('OIDC-SVC-002: consumeState returns stored data and deletes state', () => { - const state = createState('https://example.com/callback', 'invite-abc'); + it('OIDC-SVC-002: consumeState returns stored data (incl. verifier) and deletes state', () => { + const { state } = createState('https://example.com/callback', 'invite-abc'); const data = consumeState(state); expect(data).not.toBeNull(); expect(data!.redirectUri).toBe('https://example.com/callback'); expect(data!.inviteToken).toBe('invite-abc'); + expect(typeof data!.codeVerifier).toBe('string'); + expect(data!.codeVerifier.length).toBeGreaterThan(20); // State is consumed — second call returns null expect(consumeState(state)).toBeNull(); }); @@ -103,8 +106,8 @@ describe('createState / consumeState', () => { }); it('OIDC-SVC-004: two different states do not conflict', () => { - const s1 = createState('http://a.example.com'); - const s2 = createState('http://b.example.com'); + const { state: s1 } = createState('http://a.example.com'); + const { state: s2 } = createState('http://b.example.com'); expect(s1).not.toBe(s2); expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com'); expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com'); diff --git a/shared/src/i18n/ar/dashboard.ts b/shared/src/i18n/ar/dashboard.ts index 5dbea4fe..12b12e0e 100644 --- a/shared/src/i18n/ar/dashboard.ts +++ b/shared/src/i18n/ar/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} رحلات نشطة', 'dashboard.subtitle.archivedSuffix': ' · {count} مؤرشفة', 'dashboard.newTrip': 'رحلة جديدة', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'خطّط لرحلة جديدة من الصفر', 'dashboard.gridView': 'عرض شبكي', 'dashboard.listView': 'عرض قائمة', 'dashboard.currency': 'العملة', @@ -79,80 +79,89 @@ const dashboard: TranslationStrings = { 'dashboard.coverRemoveError': 'فشل الإزالة', 'dashboard.titleRequired': 'العنوان مطلوب', 'dashboard.endDateError': 'يجب أن يكون تاريخ النهاية بعد البداية', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'مخطط لها', + 'dashboard.hero.badgeLive': 'مباشر الآن', + 'dashboard.hero.badgeToday': 'تبدأ اليوم', + 'dashboard.hero.badgeTomorrow': 'غدًا', + 'dashboard.hero.badgeNext': 'التالية', + 'dashboard.hero.badgeRecent': 'مؤخرًا', + 'dashboard.hero.tripDates': 'تواريخ الرحلة', + 'dashboard.hero.noDates': 'لا توجد تواريخ', + 'dashboard.hero.travelerOne': '{count} مسافر', + 'dashboard.hero.travelerMany': '{count} مسافرين', + 'dashboard.hero.destinationOne': '{count} وجهة', + 'dashboard.hero.destinationMany': '{count} وجهات', + 'dashboard.hero.dayUnitOne': 'يوم', + 'dashboard.hero.dayUnitMany': 'أيام', + 'dashboard.hero.dayLeft': 'بقي يوم', + 'dashboard.hero.daysLeft': 'الأيام المتبقية', + 'dashboard.hero.lastDay': 'اليوم الأخير', + 'dashboard.hero.untilStart': 'حتى البداية', + 'dashboard.hero.startsIn': 'تبدأ الرحلة بعد', + 'dashboard.atlas.countriesVisited': 'أطلس · الدول المُزارة', + 'dashboard.atlas.ofTotal': 'من {total}', + 'dashboard.atlas.tripsTotal': 'إجمالي الرحلات', + 'dashboard.atlas.placesMapped': '{count} مكان على الخريطة', + 'dashboard.atlas.daysTraveled': 'أيام السفر', + 'dashboard.atlas.daysUnit': 'أيام', + 'dashboard.atlas.acrossAllTrips': 'عبر جميع الرحلات', + 'dashboard.atlas.distanceFlown': 'المسافة المقطوعة جوًا', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', - 'dashboard.greeting.morning': 'Good morning,', - 'dashboard.greeting.afternoon': 'Good afternoon,', - 'dashboard.greeting.evening': 'Good evening,', - 'dashboard.mobile.liveNow': 'Live Now', - 'dashboard.mobile.tripProgress': 'Trip progress', - 'dashboard.mobile.daysLeft': '{count} days left', - 'dashboard.mobile.places': 'Places', - 'dashboard.mobile.buddies': 'Buddies', - 'dashboard.mobile.newTrip': 'New Trip', - 'dashboard.mobile.currency': 'Currency', - 'dashboard.mobile.timezone': 'Timezone', - 'dashboard.mobile.upcomingTrips': 'Upcoming Trips', - 'dashboard.mobile.yourTrips': 'Your Trips', - 'dashboard.mobile.trips': 'trips', - 'dashboard.mobile.starts': 'Starts', - 'dashboard.mobile.duration': 'Duration', - 'dashboard.mobile.day': 'day', - 'dashboard.mobile.days': 'days', - 'dashboard.mobile.ongoing': 'Ongoing', - 'dashboard.mobile.startsToday': 'Starts today', - 'dashboard.mobile.tomorrow': 'Tomorrow', - 'dashboard.mobile.inDays': 'In {count} days', - 'dashboard.mobile.inMonths': 'In {count} months', - 'dashboard.mobile.completed': 'Completed', - 'dashboard.mobile.currencyConverter': 'Currency Converter', + 'dashboard.atlas.aroundEquator': '≈ {count}× حول خط الاستواء', + 'dashboard.card.idea': 'فكرة', + 'dashboard.card.buddyOne': 'رفيق', + 'dashboard.fx.from': 'من', + 'dashboard.fx.to': 'إلى', + 'dashboard.fx.unavailable': 'السعر غير متاح', + 'dashboard.tz.searchPlaceholder': 'ابحث عن منطقة زمنية…', + 'dashboard.tz.empty': 'لا توجد مناطق زمنية أخرى بعد — أضف واحدة بالزر +', + 'dashboard.upcoming.title': 'الحجوزات القادمة', + 'dashboard.upcoming.empty': 'لا شيء محجوز بعد.', + 'dashboard.confirm.copy.title': 'نسخ هذه الرحلة؟', + 'dashboard.confirm.copy.willCopy': 'سيتم نسخه', + 'dashboard.confirm.copy.will1': 'الأيام والأماكن وتوزيعات اليوم', + 'dashboard.confirm.copy.will2': 'أماكن الإقامة والحجوزات', + 'dashboard.confirm.copy.will3': 'بنود الميزانية وترتيب الفئات', + 'dashboard.confirm.copy.will4': 'قوائم الأمتعة (غير محددة)', + 'dashboard.confirm.copy.will5': 'المهام (غير معيّنة وغير محددة)', + 'dashboard.confirm.copy.will6': 'ملاحظات اليوم', + 'dashboard.confirm.copy.wontCopy': 'لن يتم نسخه', + 'dashboard.confirm.copy.wont1': 'المتعاونون وتعيينات الأعضاء', + 'dashboard.confirm.copy.wont2': 'الملاحظات والاستطلاعات والرسائل المشتركة', + 'dashboard.confirm.copy.wont3': 'الملفات والصور', + 'dashboard.confirm.copy.wont4': 'رموز المشاركة', + 'dashboard.confirm.copy.confirm': 'نسخ الرحلة', + 'dashboard.greeting.morning': 'صباح الخير،', + 'dashboard.greeting.afternoon': 'مساء الخير،', + 'dashboard.greeting.evening': 'مساء الخير،', + 'dashboard.mobile.liveNow': 'مباشر الآن', + 'dashboard.mobile.tripProgress': 'تقدّم الرحلة', + 'dashboard.mobile.daysLeft': 'بقي {count} يوم', + 'dashboard.mobile.places': 'الأماكن', + 'dashboard.mobile.buddies': 'الرفاق', + 'dashboard.mobile.newTrip': 'رحلة جديدة', + 'dashboard.mobile.currency': 'العملة', + 'dashboard.mobile.timezone': 'المنطقة الزمنية', + 'dashboard.mobile.upcomingTrips': 'الرحلات القادمة', + 'dashboard.mobile.yourTrips': 'رحلاتك', + 'dashboard.mobile.trips': 'رحلات', + 'dashboard.mobile.starts': 'تبدأ', + 'dashboard.mobile.duration': 'المدة', + 'dashboard.mobile.day': 'يوم', + 'dashboard.mobile.days': 'أيام', + 'dashboard.mobile.ongoing': 'جارية', + 'dashboard.mobile.startsToday': 'تبدأ اليوم', + 'dashboard.mobile.tomorrow': 'غدًا', + 'dashboard.mobile.inDays': 'خلال {count} يوم', + 'dashboard.mobile.inMonths': 'خلال {count} شهر', + 'dashboard.mobile.completed': 'مكتملة', + 'dashboard.mobile.currencyConverter': 'محوّل العملات', + 'dashboard.aria.toggleView': 'تبديل العرض', + 'dashboard.aria.filter': 'تصفية', + 'dashboard.aria.duplicate': 'تكرار', + 'dashboard.aria.refreshRates': 'تحديث الأسعار', + 'dashboard.aria.swapCurrencies': 'تبديل العملات', + 'dashboard.aria.addTimezone': 'إضافة منطقة زمنية', + 'dashboard.aria.removeTimezone': 'إزالة {city}', }; export default dashboard; diff --git a/shared/src/i18n/ar/nav.ts b/shared/src/i18n/ar/nav.ts index 8eff09f5..c3da5875 100644 --- a/shared/src/i18n/ar/nav.ts +++ b/shared/src/i18n/ar/nav.ts @@ -11,5 +11,10 @@ const nav: TranslationStrings = { 'nav.autoMode': 'الوضع التلقائي', 'nav.administrator': 'المسؤول', 'nav.myTrips': 'رحلاتي', + 'nav.profile': 'الملف الشخصي', + 'nav.bottomSettings': 'الإعدادات', + 'nav.bottomAdmin': 'إعدادات المسؤول', + 'nav.bottomLogout': 'تسجيل الخروج', + 'nav.bottomAdminBadge': 'مسؤول', }; export default nav; diff --git a/shared/src/i18n/br/dashboard.ts b/shared/src/i18n/br/dashboard.ts index f36fae1e..9b124b58 100644 --- a/shared/src/i18n/br/dashboard.ts +++ b/shared/src/i18n/br/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} viagens ativas', 'dashboard.subtitle.archivedSuffix': ' · {count} arquivadas', 'dashboard.newTrip': 'Nova viagem', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Planeje uma nova viagem do zero', 'dashboard.gridView': 'Grade', 'dashboard.listView': 'Lista', 'dashboard.currency': 'Moeda', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Em {count} meses', 'dashboard.mobile.completed': 'Concluído', 'dashboard.mobile.currencyConverter': 'Conversor de moedas', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Planejadas', + 'dashboard.hero.badgeLive': 'AO VIVO AGORA', + 'dashboard.hero.badgeToday': 'COMEÇA HOJE', + 'dashboard.hero.badgeTomorrow': 'AMANHÃ', + 'dashboard.hero.badgeNext': 'A SEGUIR', + 'dashboard.hero.badgeRecent': 'RECENTE', + 'dashboard.hero.tripDates': 'Datas da viagem', + 'dashboard.hero.noDates': 'Sem datas definidas', + 'dashboard.hero.travelerOne': '{count} viajante', + 'dashboard.hero.travelerMany': '{count} viajantes', + 'dashboard.hero.destinationOne': '{count} destino', + 'dashboard.hero.destinationMany': '{count} destinos', + 'dashboard.hero.dayUnitOne': 'dia', + 'dashboard.hero.dayUnitMany': 'dias', + 'dashboard.hero.dayLeft': 'Dia restante', + 'dashboard.hero.daysLeft': 'Dias restantes', + 'dashboard.hero.lastDay': 'Último dia', + 'dashboard.hero.untilStart': 'Até o início', + 'dashboard.hero.startsIn': 'A viagem começa em', + 'dashboard.atlas.countriesVisited': 'Atlas · Países visitados', + 'dashboard.atlas.ofTotal': 'de {total}', + 'dashboard.atlas.tripsTotal': 'Total de viagens', + 'dashboard.atlas.placesMapped': '{count} lugares mapeados', + 'dashboard.atlas.daysTraveled': 'Dias de viagem', + 'dashboard.atlas.daysUnit': 'dias', + 'dashboard.atlas.acrossAllTrips': 'em todas as viagens', + 'dashboard.atlas.distanceFlown': 'Distância voada', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× ao redor do equador', + 'dashboard.card.idea': 'Ideia', + 'dashboard.card.buddyOne': 'Parceiro', + 'dashboard.fx.from': 'De', + 'dashboard.fx.to': 'Para', + 'dashboard.fx.unavailable': 'Taxa indisponível', + 'dashboard.tz.searchPlaceholder': 'Buscar fuso horário…', + 'dashboard.tz.empty': 'Ainda sem outros fusos horários — adicione um com +', + 'dashboard.upcoming.title': 'Próximas reservas', + 'dashboard.upcoming.empty': 'Nada reservado ainda.', + 'dashboard.confirm.copy.title': 'Copiar esta viagem?', + 'dashboard.confirm.copy.willCopy': 'Será copiado', + 'dashboard.confirm.copy.will1': 'Dias, lugares e atribuições por dia', + 'dashboard.confirm.copy.will2': 'Hospedagens e reservas', + 'dashboard.confirm.copy.will3': 'Itens de orçamento e ordem das categorias', + 'dashboard.confirm.copy.will4': 'Listas de bagagem (desmarcadas)', + 'dashboard.confirm.copy.will5': 'Tarefas (não atribuídas e desmarcadas)', + 'dashboard.confirm.copy.will6': 'Notas do dia', + 'dashboard.confirm.copy.wontCopy': 'Não será copiado', + 'dashboard.confirm.copy.wont1': 'Colaboradores e atribuições de membros', + 'dashboard.confirm.copy.wont2': 'Notas, enquetes e mensagens compartilhadas', + 'dashboard.confirm.copy.wont3': 'Arquivos e fotos', + 'dashboard.confirm.copy.wont4': 'Tokens de compartilhamento', + 'dashboard.confirm.copy.confirm': 'Copiar viagem', + 'dashboard.aria.toggleView': 'Alternar visualização', + 'dashboard.aria.filter': 'Filtrar', + 'dashboard.aria.duplicate': 'Duplicar', + 'dashboard.aria.refreshRates': 'Atualizar taxas', + 'dashboard.aria.swapCurrencies': 'Trocar moedas', + 'dashboard.aria.addTimezone': 'Adicionar fuso horário', + 'dashboard.aria.removeTimezone': 'Remover {city}', }; export default dashboard; diff --git a/shared/src/i18n/cs/dashboard.ts b/shared/src/i18n/cs/dashboard.ts index 69308ead..e3ded619 100644 --- a/shared/src/i18n/cs/dashboard.ts +++ b/shared/src/i18n/cs/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} aktivních cest', 'dashboard.subtitle.archivedSuffix': ' · {count} archivováno', 'dashboard.newTrip': 'Nová cesta', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Naplánujte novou cestu od začátku', 'dashboard.gridView': 'Mřížka', 'dashboard.listView': 'Seznam', 'dashboard.currency': 'Měna', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Za {count} měsíců', 'dashboard.mobile.completed': 'Dokončeno', 'dashboard.mobile.currencyConverter': 'Převodník měn', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Plánované', + 'dashboard.hero.badgeLive': 'ŽIVĚ', + 'dashboard.hero.badgeToday': 'ZAČÍNÁ DNES', + 'dashboard.hero.badgeTomorrow': 'ZÍTRA', + 'dashboard.hero.badgeNext': 'NÁSLEDUJE', + 'dashboard.hero.badgeRecent': 'NEDÁVNO', + 'dashboard.hero.tripDates': 'Termíny cesty', + 'dashboard.hero.noDates': 'Bez termínu', + 'dashboard.hero.travelerOne': '{count} cestovatel', + 'dashboard.hero.travelerMany': '{count} cestovatelů', + 'dashboard.hero.destinationOne': '{count} cíl', + 'dashboard.hero.destinationMany': '{count} cílů', + 'dashboard.hero.dayUnitOne': 'den', + 'dashboard.hero.dayUnitMany': 'dní', + 'dashboard.hero.dayLeft': 'Zbývá den', + 'dashboard.hero.daysLeft': 'Zbývá dní', + 'dashboard.hero.lastDay': 'Poslední den', + 'dashboard.hero.untilStart': 'Do začátku', + 'dashboard.hero.startsIn': 'Začíná za', + 'dashboard.atlas.countriesVisited': 'Atlas · Navštívené země', + 'dashboard.atlas.ofTotal': 'z {total}', + 'dashboard.atlas.tripsTotal': 'Cest celkem', + 'dashboard.atlas.placesMapped': '{count} míst na mapě', + 'dashboard.atlas.daysTraveled': 'Dní na cestách', + 'dashboard.atlas.daysUnit': 'dní', + 'dashboard.atlas.acrossAllTrips': 'napříč všemi cestami', + 'dashboard.atlas.distanceFlown': 'Naletěná vzdálenost', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× kolem rovníku', + 'dashboard.card.idea': 'Nápad', + 'dashboard.card.buddyOne': 'Parťák', + 'dashboard.fx.from': 'Z', + 'dashboard.fx.to': 'Na', + 'dashboard.fx.unavailable': 'Kurz není dostupný', + 'dashboard.tz.searchPlaceholder': 'Hledat časové pásmo…', + 'dashboard.tz.empty': 'Zatím žádná další časová pásma — přidejte je pomocí +', + 'dashboard.upcoming.title': 'Nadcházející rezervace', + 'dashboard.upcoming.empty': 'Zatím nic rezervováno.', + 'dashboard.confirm.copy.title': 'Zkopírovat tuto cestu?', + 'dashboard.confirm.copy.willCopy': 'Bude zkopírováno', + 'dashboard.confirm.copy.will1': 'Dny, místa a přiřazení ke dnům', + 'dashboard.confirm.copy.will2': 'Ubytování a rezervace', + 'dashboard.confirm.copy.will3': 'Položky rozpočtu a pořadí kategorií', + 'dashboard.confirm.copy.will4': 'Seznamy věcí (neodškrtnuté)', + 'dashboard.confirm.copy.will5': 'Úkoly (nepřiřazené a neodškrtnuté)', + 'dashboard.confirm.copy.will6': 'Poznámky ke dni', + 'dashboard.confirm.copy.wontCopy': 'Nebude zkopírováno', + 'dashboard.confirm.copy.wont1': 'Spolupracovníci a přiřazení členů', + 'dashboard.confirm.copy.wont2': 'Sdílené poznámky, ankety a zprávy', + 'dashboard.confirm.copy.wont3': 'Soubory a fotky', + 'dashboard.confirm.copy.wont4': 'Sdílecí tokeny', + 'dashboard.confirm.copy.confirm': 'Kopírovat cestu', + 'dashboard.aria.toggleView': 'Přepnout zobrazení', + 'dashboard.aria.filter': 'Filtr', + 'dashboard.aria.duplicate': 'Duplikovat', + 'dashboard.aria.refreshRates': 'Obnovit kurzy', + 'dashboard.aria.swapCurrencies': 'Prohodit měny', + 'dashboard.aria.addTimezone': 'Přidat časové pásmo', + 'dashboard.aria.removeTimezone': 'Odebrat {city}', }; export default dashboard; diff --git a/shared/src/i18n/de/dashboard.ts b/shared/src/i18n/de/dashboard.ts index 2c3eea9b..56e6ad6e 100644 --- a/shared/src/i18n/de/dashboard.ts +++ b/shared/src/i18n/de/dashboard.ts @@ -122,6 +122,8 @@ const dashboard: TranslationStrings = { 'dashboard.hero.dayLeft': 'Tag übrig', 'dashboard.hero.daysLeft': 'Tage übrig', 'dashboard.hero.lastDay': 'Letzter Tag', + 'dashboard.hero.untilStart': 'Bis Start', + 'dashboard.hero.startsIn': 'Reise beginnt in', 'dashboard.atlas.countriesVisited': 'Atlas · Besuchte Länder', 'dashboard.atlas.ofTotal': 'von {total}', 'dashboard.atlas.tripsTotal': 'Reisen gesamt', @@ -155,5 +157,12 @@ const dashboard: TranslationStrings = { 'dashboard.confirm.copy.wont3': 'Dateien & Fotos', 'dashboard.confirm.copy.wont4': 'Freigabe-Tokens', 'dashboard.confirm.copy.confirm': 'Reise kopieren', + 'dashboard.aria.toggleView': 'Ansicht wechseln', + 'dashboard.aria.filter': 'Filter', + 'dashboard.aria.duplicate': 'Duplizieren', + 'dashboard.aria.refreshRates': 'Kurse aktualisieren', + 'dashboard.aria.swapCurrencies': 'Währungen tauschen', + 'dashboard.aria.addTimezone': 'Zeitzone hinzufügen', + 'dashboard.aria.removeTimezone': '{city} entfernen', }; export default dashboard; diff --git a/shared/src/i18n/en/dashboard.ts b/shared/src/i18n/en/dashboard.ts index 538854ea..1c9ff17a 100644 --- a/shared/src/i18n/en/dashboard.ts +++ b/shared/src/i18n/en/dashboard.ts @@ -135,6 +135,8 @@ const dashboard: TranslationStrings = { 'dashboard.hero.dayLeft': 'Day left', 'dashboard.hero.daysLeft': 'Days left', 'dashboard.hero.lastDay': 'Last day', + 'dashboard.hero.untilStart': 'Until start', + 'dashboard.hero.startsIn': 'Trip starts in', 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', 'dashboard.atlas.ofTotal': 'of {total}', 'dashboard.atlas.tripsTotal': 'Trips total', @@ -154,5 +156,12 @@ const dashboard: TranslationStrings = { 'dashboard.tz.empty': 'No other timezones yet — add one with +', 'dashboard.upcoming.title': 'Upcoming reservations', 'dashboard.upcoming.empty': 'Nothing booked yet.', + 'dashboard.aria.toggleView': 'Toggle view', + 'dashboard.aria.filter': 'Filter', + 'dashboard.aria.duplicate': 'Duplicate', + 'dashboard.aria.refreshRates': 'Refresh rates', + 'dashboard.aria.swapCurrencies': 'Swap currencies', + 'dashboard.aria.addTimezone': 'Add timezone', + 'dashboard.aria.removeTimezone': 'Remove {city}', }; export default dashboard; diff --git a/shared/src/i18n/es/dashboard.ts b/shared/src/i18n/es/dashboard.ts index f25e88d9..a2345cd9 100644 --- a/shared/src/i18n/es/dashboard.ts +++ b/shared/src/i18n/es/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} viajes activos', 'dashboard.subtitle.archivedSuffix': ' · {count} archivados', 'dashboard.newTrip': 'Nuevo viaje', - 'dashboard.newTripSub': 'Empezar de cero · o importar desde otro planificador', + 'dashboard.newTripSub': 'Planifica un nuevo viaje desde cero', 'dashboard.gridView': 'Vista de cuadrícula', 'dashboard.listView': 'Vista de lista', 'dashboard.currency': 'Divisa', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'En {count} meses', 'dashboard.mobile.completed': 'Completado', 'dashboard.mobile.currencyConverter': 'Conversor de monedas', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Planificados', + 'dashboard.hero.badgeLive': 'EN VIVO AHORA', + 'dashboard.hero.badgeToday': 'EMPIEZA HOY', + 'dashboard.hero.badgeTomorrow': 'MAÑANA', + 'dashboard.hero.badgeNext': 'SIGUIENTE', + 'dashboard.hero.badgeRecent': 'RECIENTE', + 'dashboard.hero.tripDates': 'Fechas del viaje', + 'dashboard.hero.noDates': 'Sin fechas', + 'dashboard.hero.travelerOne': '{count} viajero', + 'dashboard.hero.travelerMany': '{count} viajeros', + 'dashboard.hero.destinationOne': '{count} destino', + 'dashboard.hero.destinationMany': '{count} destinos', + 'dashboard.hero.dayUnitOne': 'día', + 'dashboard.hero.dayUnitMany': 'días', + 'dashboard.hero.dayLeft': 'Día restante', + 'dashboard.hero.daysLeft': 'Días restantes', + 'dashboard.hero.lastDay': 'Último día', + 'dashboard.hero.untilStart': 'Hasta el inicio', + 'dashboard.hero.startsIn': 'Empieza en', + 'dashboard.atlas.countriesVisited': 'Atlas · Países visitados', + 'dashboard.atlas.ofTotal': 'de {total}', + 'dashboard.atlas.tripsTotal': 'Viajes en total', + 'dashboard.atlas.placesMapped': '{count} lugares en el mapa', + 'dashboard.atlas.daysTraveled': 'Días de viaje', + 'dashboard.atlas.daysUnit': 'días', + 'dashboard.atlas.acrossAllTrips': 'en todos los viajes', + 'dashboard.atlas.distanceFlown': 'Distancia volada', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', + 'dashboard.atlas.aroundEquator': '≈ {count}× alrededor del ecuador', 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.card.buddyOne': 'Compañero', + 'dashboard.fx.from': 'De', + 'dashboard.fx.to': 'A', + 'dashboard.fx.unavailable': 'Tipo de cambio no disponible', + 'dashboard.tz.searchPlaceholder': 'Buscar zona horaria…', + 'dashboard.tz.empty': 'Aún no hay otras zonas horarias — añade una con +', + 'dashboard.upcoming.title': 'Próximas reservas', + 'dashboard.upcoming.empty': 'Aún no hay nada reservado.', + 'dashboard.confirm.copy.title': '¿Copiar este viaje?', + 'dashboard.confirm.copy.willCopy': 'Se copiará', + 'dashboard.confirm.copy.will1': 'Días, lugares y asignaciones por día', + 'dashboard.confirm.copy.will2': 'Alojamientos y reservas', + 'dashboard.confirm.copy.will3': 'Partidas de presupuesto y orden de categorías', + 'dashboard.confirm.copy.will4': 'Listas de equipaje (sin marcar)', + 'dashboard.confirm.copy.will5': 'Tareas (sin asignar ni marcar)', + 'dashboard.confirm.copy.will6': 'Notas del día', + 'dashboard.confirm.copy.wontCopy': 'No se copiará', + 'dashboard.confirm.copy.wont1': 'Colaboradores y asignaciones de miembros', + 'dashboard.confirm.copy.wont2': 'Notas, encuestas y mensajes compartidos', + 'dashboard.confirm.copy.wont3': 'Archivos y fotos', + 'dashboard.confirm.copy.wont4': 'Tokens de uso compartido', + 'dashboard.confirm.copy.confirm': 'Copiar viaje', + 'dashboard.aria.toggleView': 'Cambiar vista', + 'dashboard.aria.filter': 'Filtrar', + 'dashboard.aria.duplicate': 'Duplicar', + 'dashboard.aria.refreshRates': 'Actualizar tipos de cambio', + 'dashboard.aria.swapCurrencies': 'Intercambiar monedas', + 'dashboard.aria.addTimezone': 'Añadir zona horaria', + 'dashboard.aria.removeTimezone': 'Eliminar {city}', }; export default dashboard; diff --git a/shared/src/i18n/fr/dashboard.ts b/shared/src/i18n/fr/dashboard.ts index e93ca98b..bf431647 100644 --- a/shared/src/i18n/fr/dashboard.ts +++ b/shared/src/i18n/fr/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} voyages actifs', 'dashboard.subtitle.archivedSuffix': ' · {count} archivés', 'dashboard.newTrip': 'Nouveau voyage', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Planifiez un nouveau voyage à partir de zéro', 'dashboard.gridView': 'Vue en grille', 'dashboard.listView': 'Vue en liste', 'dashboard.currency': 'Devise', @@ -107,55 +107,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Dans {count} mois', 'dashboard.mobile.completed': 'Terminé', 'dashboard.mobile.currencyConverter': 'Convertisseur de devises', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', + 'dashboard.filter.planned': 'Planifiés', + 'dashboard.hero.badgeLive': 'EN DIRECT', + 'dashboard.hero.badgeToday': 'DÉBUTE AUJOURD\'HUI', + 'dashboard.hero.badgeTomorrow': 'DEMAIN', + 'dashboard.hero.badgeNext': 'À SUIVRE', + 'dashboard.hero.badgeRecent': 'RÉCENT', + 'dashboard.hero.tripDates': 'Dates du voyage', + 'dashboard.hero.noDates': 'Aucune date définie', + 'dashboard.hero.travelerOne': '{count} voyageur', + 'dashboard.hero.travelerMany': '{count} voyageurs', 'dashboard.hero.destinationOne': '{count} destination', 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.hero.dayUnitOne': 'jour', + 'dashboard.hero.dayUnitMany': 'jours', + 'dashboard.hero.dayLeft': 'Jour restant', + 'dashboard.hero.daysLeft': 'Jours restants', + 'dashboard.hero.lastDay': 'Dernier jour', + 'dashboard.hero.untilStart': 'Avant le départ', + 'dashboard.hero.startsIn': 'Départ dans', + 'dashboard.atlas.countriesVisited': 'Atlas · Pays visités', + 'dashboard.atlas.ofTotal': 'sur {total}', + 'dashboard.atlas.tripsTotal': 'Voyages au total', + 'dashboard.atlas.placesMapped': '{count} lieux cartographiés', + 'dashboard.atlas.daysTraveled': 'Jours de voyage', + 'dashboard.atlas.daysUnit': 'jours', + 'dashboard.atlas.acrossAllTrips': 'sur tous les voyages', + 'dashboard.atlas.distanceFlown': 'Distance parcourue en avion', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× le tour de l\'équateur', + 'dashboard.card.idea': 'Idée', + 'dashboard.card.buddyOne': 'Compagnon', + 'dashboard.fx.from': 'De', + 'dashboard.fx.to': 'Vers', + 'dashboard.fx.unavailable': 'Taux indisponible', + 'dashboard.tz.searchPlaceholder': 'Rechercher un fuseau horaire…', + 'dashboard.tz.empty': 'Pas encore d\'autres fuseaux horaires — ajoutez-en un avec +', + 'dashboard.upcoming.title': 'Prochaines réservations', + 'dashboard.upcoming.empty': 'Rien de réservé pour l\'instant.', + 'dashboard.confirm.copy.title': 'Copier ce voyage ?', + 'dashboard.confirm.copy.willCopy': 'Sera copié', + 'dashboard.confirm.copy.will1': 'Jours, lieux et affectations par jour', + 'dashboard.confirm.copy.will2': 'Hébergements et réservations', + 'dashboard.confirm.copy.will3': 'Postes de budget et ordre des catégories', + 'dashboard.confirm.copy.will4': 'Listes de bagages (non cochées)', + 'dashboard.confirm.copy.will5': 'Tâches (non attribuées et non cochées)', + 'dashboard.confirm.copy.will6': 'Notes du jour', + 'dashboard.confirm.copy.wontCopy': 'Ne sera pas copié', + 'dashboard.confirm.copy.wont1': 'Collaborateurs et affectations des membres', + 'dashboard.confirm.copy.wont2': 'Notes, sondages et messages partagés', + 'dashboard.confirm.copy.wont3': 'Fichiers et photos', + 'dashboard.confirm.copy.wont4': 'Jetons de partage', + 'dashboard.confirm.copy.confirm': 'Copier le voyage', + 'dashboard.aria.toggleView': 'Changer d\'affichage', + 'dashboard.aria.filter': 'Filtrer', + 'dashboard.aria.duplicate': 'Dupliquer', + 'dashboard.aria.refreshRates': 'Actualiser les taux', + 'dashboard.aria.swapCurrencies': 'Inverser les devises', + 'dashboard.aria.addTimezone': 'Ajouter un fuseau horaire', + 'dashboard.aria.removeTimezone': 'Supprimer {city}', }; export default dashboard; diff --git a/shared/src/i18n/hu/dashboard.ts b/shared/src/i18n/hu/dashboard.ts index 9913d4b7..f829ffd2 100644 --- a/shared/src/i18n/hu/dashboard.ts +++ b/shared/src/i18n/hu/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} aktív utazás', 'dashboard.subtitle.archivedSuffix': ' · {count} archivált', 'dashboard.newTrip': 'Új utazás', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Tervezz egy új utazást a nulláról', 'dashboard.gridView': 'Rácsnézet', 'dashboard.listView': 'Listanézet', 'dashboard.currency': 'Pénznem', @@ -105,55 +105,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': '{count} hónap múlva', 'dashboard.mobile.completed': 'Befejezett', 'dashboard.mobile.currencyConverter': 'Pénznemváltó', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Tervezett', + 'dashboard.hero.badgeLive': 'MOST ÉLŐBEN', + 'dashboard.hero.badgeToday': 'MA KEZDŐDIK', + 'dashboard.hero.badgeTomorrow': 'HOLNAP', + 'dashboard.hero.badgeNext': 'KÖVETKEZŐ', + 'dashboard.hero.badgeRecent': 'LEGUTÓBBI', + 'dashboard.hero.tripDates': 'Utazás dátumai', + 'dashboard.hero.noDates': 'Nincs dátum megadva', + 'dashboard.hero.travelerOne': '{count} utazó', + 'dashboard.hero.travelerMany': '{count} utazó', + 'dashboard.hero.destinationOne': '{count} úti cél', + 'dashboard.hero.destinationMany': '{count} úti cél', + 'dashboard.hero.dayUnitOne': 'nap', + 'dashboard.hero.dayUnitMany': 'nap', + 'dashboard.hero.dayLeft': 'nap van hátra', + 'dashboard.hero.daysLeft': 'nap van hátra', + 'dashboard.hero.lastDay': 'Utolsó nap', + 'dashboard.hero.untilStart': 'Indulásig', + 'dashboard.hero.startsIn': 'Indulásig', + 'dashboard.atlas.countriesVisited': 'Atlas · Meglátogatott országok', + 'dashboard.atlas.ofTotal': '/ {total}', + 'dashboard.atlas.tripsTotal': 'Utazások összesen', + 'dashboard.atlas.placesMapped': '{count} hely a térképen', + 'dashboard.atlas.daysTraveled': 'Utazási napok', + 'dashboard.atlas.daysUnit': 'nap', + 'dashboard.atlas.acrossAllTrips': 'az összes utazásban', + 'dashboard.atlas.distanceFlown': 'Megtett távolság', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× az Egyenlítő körül', + 'dashboard.card.idea': 'Ötlet', + 'dashboard.card.buddyOne': 'Útitárs', + 'dashboard.fx.from': 'Erről', + 'dashboard.fx.to': 'Erre', + 'dashboard.fx.unavailable': 'Árfolyam nem elérhető', + 'dashboard.tz.searchPlaceholder': 'Időzóna keresése…', + 'dashboard.tz.empty': 'Még nincs több időzóna — adj hozzá egyet a + gombbal', + 'dashboard.upcoming.title': 'Közelgő foglalások', + 'dashboard.upcoming.empty': 'Még nincs semmi lefoglalva.', + 'dashboard.confirm.copy.title': 'Másolja ezt az utazást?', + 'dashboard.confirm.copy.willCopy': 'Másolásra kerül', + 'dashboard.confirm.copy.will1': 'Napok, helyek és napi hozzárendelések', + 'dashboard.confirm.copy.will2': 'Szállások és foglalások', + 'dashboard.confirm.copy.will3': 'Költségtételek és kategóriasorrend', + 'dashboard.confirm.copy.will4': 'Csomaglisták (kipipálatlan)', + 'dashboard.confirm.copy.will5': 'Teendők (hozzárendelés és pipa nélkül)', + 'dashboard.confirm.copy.will6': 'Napi jegyzetek', + 'dashboard.confirm.copy.wontCopy': 'Nem kerül másolásra', + 'dashboard.confirm.copy.wont1': 'Közreműködők és tag-hozzárendelések', + 'dashboard.confirm.copy.wont2': 'Közös jegyzetek, szavazások és üzenetek', + 'dashboard.confirm.copy.wont3': 'Fájlok és fotók', + 'dashboard.confirm.copy.wont4': 'Megosztási tokenek', + 'dashboard.confirm.copy.confirm': 'Utazás másolása', + 'dashboard.aria.toggleView': 'Nézet váltása', + 'dashboard.aria.filter': 'Szűrő', + 'dashboard.aria.duplicate': 'Duplikálás', + 'dashboard.aria.refreshRates': 'Árfolyamok frissítése', + 'dashboard.aria.swapCurrencies': 'Pénznemek cseréje', + 'dashboard.aria.addTimezone': 'Időzóna hozzáadása', + 'dashboard.aria.removeTimezone': '{city} eltávolítása', }; export default dashboard; diff --git a/shared/src/i18n/id/dashboard.ts b/shared/src/i18n/id/dashboard.ts index 6c7dd4df..bb84b36e 100644 --- a/shared/src/i18n/id/dashboard.ts +++ b/shared/src/i18n/id/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} perjalanan aktif', 'dashboard.subtitle.archivedSuffix': ' · {count} diarsipkan', 'dashboard.newTrip': 'Perjalanan Baru', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Rencanakan perjalanan baru dari awal', 'dashboard.gridView': 'Tampilan grid', 'dashboard.listView': 'Tampilan daftar', 'dashboard.currency': 'Mata uang', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Dalam {count} bulan', 'dashboard.mobile.completed': 'Selesai', 'dashboard.mobile.currencyConverter': 'Konverter Mata Uang', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Direncanakan', + 'dashboard.hero.badgeLive': 'SEDANG BERLANGSUNG', + 'dashboard.hero.badgeToday': 'MULAI HARI INI', + 'dashboard.hero.badgeTomorrow': 'BESOK', + 'dashboard.hero.badgeNext': 'BERIKUTNYA', + 'dashboard.hero.badgeRecent': 'TERBARU', + 'dashboard.hero.tripDates': 'Tanggal perjalanan', + 'dashboard.hero.noDates': 'Tanggal belum diatur', + 'dashboard.hero.travelerOne': '{count} pelancong', + 'dashboard.hero.travelerMany': '{count} pelancong', + 'dashboard.hero.destinationOne': '{count} destinasi', + 'dashboard.hero.destinationMany': '{count} destinasi', + 'dashboard.hero.dayUnitOne': 'hari', + 'dashboard.hero.dayUnitMany': 'hari', + 'dashboard.hero.dayLeft': 'Hari tersisa', + 'dashboard.hero.daysLeft': 'Hari tersisa', + 'dashboard.hero.lastDay': 'Hari terakhir', + 'dashboard.hero.untilStart': 'Hingga mulai', + 'dashboard.hero.startsIn': 'Mulai dalam', + 'dashboard.atlas.countriesVisited': 'Atlas · Negara dikunjungi', + 'dashboard.atlas.ofTotal': 'dari {total}', + 'dashboard.atlas.tripsTotal': 'Total perjalanan', + 'dashboard.atlas.placesMapped': '{count} tempat dipetakan', + 'dashboard.atlas.daysTraveled': 'Hari perjalanan', + 'dashboard.atlas.daysUnit': 'hari', + 'dashboard.atlas.acrossAllTrips': 'di semua perjalanan', + 'dashboard.atlas.distanceFlown': 'Jarak terbang', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× keliling khatulistiwa', + 'dashboard.card.idea': 'Ide', + 'dashboard.card.buddyOne': 'Teman', + 'dashboard.fx.from': 'Dari', + 'dashboard.fx.to': 'Ke', + 'dashboard.fx.unavailable': 'Kurs tidak tersedia', + 'dashboard.tz.searchPlaceholder': 'Cari zona waktu…', + 'dashboard.tz.empty': 'Belum ada zona waktu lain — tambahkan dengan +', + 'dashboard.upcoming.title': 'Reservasi mendatang', + 'dashboard.upcoming.empty': 'Belum ada yang dipesan.', + 'dashboard.confirm.copy.title': 'Salin perjalanan ini?', + 'dashboard.confirm.copy.willCopy': 'Akan disalin', + 'dashboard.confirm.copy.will1': 'Hari, tempat & penugasan harian', + 'dashboard.confirm.copy.will2': 'Akomodasi & reservasi', + 'dashboard.confirm.copy.will3': 'Item anggaran & urutan kategori', + 'dashboard.confirm.copy.will4': 'Daftar bawaan (belum dicentang)', + 'dashboard.confirm.copy.will5': 'Tugas (belum ditugaskan & dicentang)', + 'dashboard.confirm.copy.will6': 'Catatan harian', + 'dashboard.confirm.copy.wontCopy': 'Tidak akan disalin', + 'dashboard.confirm.copy.wont1': 'Kolaborator & penugasan anggota', + 'dashboard.confirm.copy.wont2': 'Catatan, jajak pendapat & pesan bersama', + 'dashboard.confirm.copy.wont3': 'Berkas & foto', + 'dashboard.confirm.copy.wont4': 'Token berbagi', + 'dashboard.confirm.copy.confirm': 'Salin perjalanan', + 'dashboard.aria.toggleView': 'Ganti tampilan', + 'dashboard.aria.filter': 'Filter', + 'dashboard.aria.duplicate': 'Duplikat', + 'dashboard.aria.refreshRates': 'Segarkan kurs', + 'dashboard.aria.swapCurrencies': 'Tukar mata uang', + 'dashboard.aria.addTimezone': 'Tambah zona waktu', + 'dashboard.aria.removeTimezone': 'Hapus {city}', }; export default dashboard; diff --git a/shared/src/i18n/it/dashboard.ts b/shared/src/i18n/it/dashboard.ts index 7a9b363a..e0d0537f 100644 --- a/shared/src/i18n/it/dashboard.ts +++ b/shared/src/i18n/it/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} viaggi attivi', 'dashboard.subtitle.archivedSuffix': ' · {count} archiviati', 'dashboard.newTrip': 'Nuovo Viaggio', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Pianifica un nuovo viaggio da zero', 'dashboard.gridView': 'Vista a griglia', 'dashboard.listView': 'Vista a lista', 'dashboard.currency': 'Valuta', @@ -107,55 +107,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Tra {count} mesi', 'dashboard.mobile.completed': 'Completato', 'dashboard.mobile.currencyConverter': 'Convertitore di valuta', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Pianificati', + 'dashboard.hero.badgeLive': 'IN DIRETTA', + 'dashboard.hero.badgeToday': 'INIZIA OGGI', + 'dashboard.hero.badgeTomorrow': 'DOMANI', + 'dashboard.hero.badgeNext': 'PROSSIMO', + 'dashboard.hero.badgeRecent': 'RECENTE', + 'dashboard.hero.tripDates': 'Date del viaggio', + 'dashboard.hero.noDates': 'Nessuna data impostata', + 'dashboard.hero.travelerOne': '{count} viaggiatore', + 'dashboard.hero.travelerMany': '{count} viaggiatori', + 'dashboard.hero.destinationOne': '{count} destinazione', + 'dashboard.hero.destinationMany': '{count} destinazioni', + 'dashboard.hero.dayUnitOne': 'giorno', + 'dashboard.hero.dayUnitMany': 'giorni', + 'dashboard.hero.dayLeft': 'Giorno rimasto', + 'dashboard.hero.daysLeft': 'Giorni rimasti', + 'dashboard.hero.lastDay': 'Ultimo giorno', + 'dashboard.hero.untilStart': 'All\'inizio', + 'dashboard.hero.startsIn': 'Si parte tra', + 'dashboard.atlas.countriesVisited': 'Atlas · Paesi visitati', + 'dashboard.atlas.ofTotal': 'di {total}', + 'dashboard.atlas.tripsTotal': 'Viaggi totali', + 'dashboard.atlas.placesMapped': '{count} luoghi mappati', + 'dashboard.atlas.daysTraveled': 'Giorni in viaggio', + 'dashboard.atlas.daysUnit': 'giorni', + 'dashboard.atlas.acrossAllTrips': 'su tutti i viaggi', + 'dashboard.atlas.distanceFlown': 'Distanza in volo', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', + 'dashboard.atlas.aroundEquator': '≈ {count}× intorno all\'equatore', 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.card.buddyOne': 'Compagno', + 'dashboard.fx.from': 'Da', + 'dashboard.fx.to': 'A', + 'dashboard.fx.unavailable': 'Tasso non disponibile', + 'dashboard.tz.searchPlaceholder': 'Cerca fuso orario…', + 'dashboard.tz.empty': 'Ancora nessun altro fuso orario — aggiungine uno con +', + 'dashboard.upcoming.title': 'Prossime prenotazioni', + 'dashboard.upcoming.empty': 'Niente ancora prenotato.', + 'dashboard.confirm.copy.title': 'Copiare questo viaggio?', + 'dashboard.confirm.copy.willCopy': 'Verrà copiato', + 'dashboard.confirm.copy.will1': 'Giorni, luoghi e assegnazioni giornaliere', + 'dashboard.confirm.copy.will2': 'Alloggi e prenotazioni', + 'dashboard.confirm.copy.will3': 'Voci di budget e ordine delle categorie', + 'dashboard.confirm.copy.will4': 'Liste bagagli (non spuntate)', + 'dashboard.confirm.copy.will5': 'Attività (non assegnate e non spuntate)', + 'dashboard.confirm.copy.will6': 'Note del giorno', + 'dashboard.confirm.copy.wontCopy': 'Non verrà copiato', + 'dashboard.confirm.copy.wont1': 'Collaboratori e assegnazioni dei membri', + 'dashboard.confirm.copy.wont2': 'Note, sondaggi e messaggi condivisi', + 'dashboard.confirm.copy.wont3': 'File e foto', + 'dashboard.confirm.copy.wont4': 'Token di condivisione', + 'dashboard.confirm.copy.confirm': 'Copia viaggio', + 'dashboard.aria.toggleView': 'Cambia vista', + 'dashboard.aria.filter': 'Filtra', + 'dashboard.aria.duplicate': 'Duplica', + 'dashboard.aria.refreshRates': 'Aggiorna i tassi', + 'dashboard.aria.swapCurrencies': 'Inverti valute', + 'dashboard.aria.addTimezone': 'Aggiungi fuso orario', + 'dashboard.aria.removeTimezone': 'Rimuovi {city}', }; export default dashboard; diff --git a/shared/src/i18n/ja/dashboard.ts b/shared/src/i18n/ja/dashboard.ts index 3c72d9f9..ab1e1f27 100644 --- a/shared/src/i18n/ja/dashboard.ts +++ b/shared/src/i18n/ja/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '進行中の旅行 {count}件', 'dashboard.subtitle.archivedSuffix': ' · アーカイブ {count}件', 'dashboard.newTrip': '新しい旅行', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': '新しい旅行をゼロから計画', 'dashboard.gridView': 'グリッド表示', 'dashboard.listView': 'リスト表示', 'dashboard.currency': '通貨', @@ -117,41 +117,50 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': '{count}か月後', 'dashboard.mobile.completed': '完了', 'dashboard.mobile.currencyConverter': '通貨換算', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': '予定', + 'dashboard.hero.badgeLive': '進行中', + 'dashboard.hero.badgeToday': '本日開始', + 'dashboard.hero.badgeTomorrow': '明日', + 'dashboard.hero.badgeNext': '次の旅行', + 'dashboard.hero.badgeRecent': '最近', + 'dashboard.hero.tripDates': '旅行の日程', + 'dashboard.hero.noDates': '日程未設定', + 'dashboard.hero.travelerOne': '{count}人の旅行者', + 'dashboard.hero.travelerMany': '{count}人の旅行者', + 'dashboard.hero.destinationOne': '{count}件の目的地', + 'dashboard.hero.destinationMany': '{count}件の目的地', + 'dashboard.hero.dayUnitOne': '日', + 'dashboard.hero.dayUnitMany': '日', + 'dashboard.hero.dayLeft': '残り1日', + 'dashboard.hero.daysLeft': '残り日数', + 'dashboard.hero.lastDay': '最終日', + 'dashboard.hero.untilStart': '開始まで', + 'dashboard.hero.startsIn': '出発まで', + 'dashboard.atlas.countriesVisited': 'アトラス · 訪問した国', + 'dashboard.atlas.ofTotal': '/ {total}', + 'dashboard.atlas.tripsTotal': '旅行の総数', + 'dashboard.atlas.placesMapped': '{count}件の場所を記録', + 'dashboard.atlas.daysTraveled': '旅行日数', + 'dashboard.atlas.daysUnit': '日', + 'dashboard.atlas.acrossAllTrips': 'すべての旅行を通じて', + 'dashboard.atlas.distanceFlown': '飛行距離', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', + 'dashboard.atlas.aroundEquator': '≈ 赤道{count}周', + 'dashboard.card.idea': 'アイデア', + 'dashboard.card.buddyOne': '同行者', + 'dashboard.fx.from': '変換元', + 'dashboard.fx.to': '変換先', + 'dashboard.fx.unavailable': 'レートを取得できません', + 'dashboard.tz.searchPlaceholder': 'タイムゾーンを検索…', + 'dashboard.tz.empty': '他のタイムゾーンはまだありません — + で追加', + 'dashboard.upcoming.title': '今後の予約', + 'dashboard.upcoming.empty': 'まだ予約はありません。', + 'dashboard.aria.toggleView': '表示を切り替え', + 'dashboard.aria.filter': 'フィルター', + 'dashboard.aria.duplicate': '複製', + 'dashboard.aria.refreshRates': 'レートを更新', + 'dashboard.aria.swapCurrencies': '通貨を入れ替え', + 'dashboard.aria.addTimezone': 'タイムゾーンを追加', + 'dashboard.aria.removeTimezone': '{city}を削除', }; export default dashboard; diff --git a/shared/src/i18n/ko/dashboard.ts b/shared/src/i18n/ko/dashboard.ts index f3b89bde..98f4fe86 100644 --- a/shared/src/i18n/ko/dashboard.ts +++ b/shared/src/i18n/ko/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '활성 여행 {count}개', 'dashboard.subtitle.archivedSuffix': ' · {count}개 보관됨', 'dashboard.newTrip': '새 여행', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': '새 여행을 처음부터 계획하기', 'dashboard.gridView': '격자 보기', 'dashboard.listView': '목록 보기', 'dashboard.currency': '통화', @@ -117,41 +117,50 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': '{count}개월 후', 'dashboard.mobile.completed': '완료됨', 'dashboard.mobile.currencyConverter': '환율 계산기', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': '예정', + 'dashboard.hero.badgeLive': '진행 중', + 'dashboard.hero.badgeToday': '오늘 시작', + 'dashboard.hero.badgeTomorrow': '내일', + 'dashboard.hero.badgeNext': '다음 여행', + 'dashboard.hero.badgeRecent': '최근', + 'dashboard.hero.tripDates': '여행 날짜', + 'dashboard.hero.noDates': '날짜 미설정', + 'dashboard.hero.travelerOne': '여행자 {count}명', + 'dashboard.hero.travelerMany': '여행자 {count}명', + 'dashboard.hero.destinationOne': '목적지 {count}곳', + 'dashboard.hero.destinationMany': '목적지 {count}곳', + 'dashboard.hero.dayUnitOne': '일', + 'dashboard.hero.dayUnitMany': '일', + 'dashboard.hero.dayLeft': '하루 남음', + 'dashboard.hero.daysLeft': '남은 일수', + 'dashboard.hero.lastDay': '마지막 날', + 'dashboard.hero.untilStart': '시작까지', + 'dashboard.hero.startsIn': '출발까지', + 'dashboard.atlas.countriesVisited': '아틀라스 · 방문한 국가', + 'dashboard.atlas.ofTotal': '/ {total}', + 'dashboard.atlas.tripsTotal': '총 여행', + 'dashboard.atlas.placesMapped': '{count}개 장소 기록', + 'dashboard.atlas.daysTraveled': '여행 일수', + 'dashboard.atlas.daysUnit': '일', + 'dashboard.atlas.acrossAllTrips': '모든 여행 통틀어', + 'dashboard.atlas.distanceFlown': '비행 거리', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', + 'dashboard.atlas.aroundEquator': '≈ 적도 {count}바퀴', + 'dashboard.card.idea': '아이디어', + 'dashboard.card.buddyOne': '동행자', + 'dashboard.fx.from': '보낼 통화', + 'dashboard.fx.to': '받을 통화', + 'dashboard.fx.unavailable': '환율을 사용할 수 없음', + 'dashboard.tz.searchPlaceholder': '시간대 검색…', + 'dashboard.tz.empty': '다른 시간대가 아직 없습니다 — +로 추가하세요', + 'dashboard.upcoming.title': '예정된 예약', + 'dashboard.upcoming.empty': '아직 예약이 없습니다.', + 'dashboard.aria.toggleView': '보기 전환', + 'dashboard.aria.filter': '필터', + 'dashboard.aria.duplicate': '복제', + 'dashboard.aria.refreshRates': '환율 새로고침', + 'dashboard.aria.swapCurrencies': '통화 바꾸기', + 'dashboard.aria.addTimezone': '시간대 추가', + 'dashboard.aria.removeTimezone': '{city} 제거', }; export default dashboard; diff --git a/shared/src/i18n/nl/dashboard.ts b/shared/src/i18n/nl/dashboard.ts index 60bfc1e1..0034e1f4 100644 --- a/shared/src/i18n/nl/dashboard.ts +++ b/shared/src/i18n/nl/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} actieve reizen', 'dashboard.subtitle.archivedSuffix': ' · {count} gearchiveerd', 'dashboard.newTrip': 'Nieuwe reis', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Plan een nieuwe reis vanaf nul', 'dashboard.gridView': 'Rasterweergave', 'dashboard.listView': 'Lijstweergave', 'dashboard.currency': 'Valuta', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Over {count} maanden', 'dashboard.mobile.completed': 'Voltooid', 'dashboard.mobile.currencyConverter': 'Valutaomrekener', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', + 'dashboard.filter.planned': 'Gepland', + 'dashboard.hero.badgeLive': 'NU LIVE', + 'dashboard.hero.badgeToday': 'START VANDAAG', + 'dashboard.hero.badgeTomorrow': 'MORGEN', + 'dashboard.hero.badgeNext': 'VOLGENDE', 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.hero.tripDates': 'Reisdata', + 'dashboard.hero.noDates': 'Geen data ingesteld', + 'dashboard.hero.travelerOne': '{count} reiziger', + 'dashboard.hero.travelerMany': '{count} reizigers', + 'dashboard.hero.destinationOne': '{count} bestemming', + 'dashboard.hero.destinationMany': '{count} bestemmingen', + 'dashboard.hero.dayUnitOne': 'dag', + 'dashboard.hero.dayUnitMany': 'dagen', + 'dashboard.hero.dayLeft': 'Dag over', + 'dashboard.hero.daysLeft': 'Dagen over', + 'dashboard.hero.lastDay': 'Laatste dag', + 'dashboard.hero.untilStart': 'Tot start', + 'dashboard.hero.startsIn': 'Reis begint over', + 'dashboard.atlas.countriesVisited': 'Atlas · Bezochte landen', + 'dashboard.atlas.ofTotal': 'van {total}', + 'dashboard.atlas.tripsTotal': 'Reizen totaal', + 'dashboard.atlas.placesMapped': '{count} plaatsen in kaart', + 'dashboard.atlas.daysTraveled': 'Reisdagen', + 'dashboard.atlas.daysUnit': 'dagen', + 'dashboard.atlas.acrossAllTrips': 'over alle reizen', + 'dashboard.atlas.distanceFlown': 'Gevlogen afstand', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× rond de evenaar', + 'dashboard.card.idea': 'Idee', + 'dashboard.card.buddyOne': 'Reisgenoot', + 'dashboard.fx.from': 'Van', + 'dashboard.fx.to': 'Naar', + 'dashboard.fx.unavailable': 'Koers niet beschikbaar', + 'dashboard.tz.searchPlaceholder': 'Tijdzone zoeken…', + 'dashboard.tz.empty': 'Nog geen andere tijdzones — voeg er een toe met +', + 'dashboard.upcoming.title': 'Aankomende reserveringen', + 'dashboard.upcoming.empty': 'Nog niets geboekt.', + 'dashboard.confirm.copy.title': 'Deze reis kopiëren?', + 'dashboard.confirm.copy.willCopy': 'Wordt gekopieerd', + 'dashboard.confirm.copy.will1': 'Dagen, plaatsen & dagindelingen', + 'dashboard.confirm.copy.will2': 'Accommodaties & reserveringen', + 'dashboard.confirm.copy.will3': 'Budgetposten & categorievolgorde', + 'dashboard.confirm.copy.will4': 'Paklijsten (niet afgevinkt)', + 'dashboard.confirm.copy.will5': 'Taken (niet toegewezen & niet afgevinkt)', + 'dashboard.confirm.copy.will6': 'Dagnotities', + 'dashboard.confirm.copy.wontCopy': 'Wordt niet gekopieerd', + 'dashboard.confirm.copy.wont1': 'Medewerkers & ledentoewijzingen', + 'dashboard.confirm.copy.wont2': 'Gedeelde notities, peilingen & berichten', + 'dashboard.confirm.copy.wont3': 'Bestanden & foto\'s', + 'dashboard.confirm.copy.wont4': 'Deeltokens', + 'dashboard.confirm.copy.confirm': 'Reis kopiëren', + 'dashboard.aria.toggleView': 'Weergave wisselen', + 'dashboard.aria.filter': 'Filter', + 'dashboard.aria.duplicate': 'Dupliceren', + 'dashboard.aria.refreshRates': 'Koersen vernieuwen', + 'dashboard.aria.swapCurrencies': 'Valuta\'s omwisselen', + 'dashboard.aria.addTimezone': 'Tijdzone toevoegen', + 'dashboard.aria.removeTimezone': '{city} verwijderen', }; export default dashboard; diff --git a/shared/src/i18n/pl/dashboard.ts b/shared/src/i18n/pl/dashboard.ts index 9412afb1..dc823707 100644 --- a/shared/src/i18n/pl/dashboard.ts +++ b/shared/src/i18n/pl/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} aktywnych podróży', 'dashboard.subtitle.archivedSuffix': ' · {count} zarchiwizowanych', 'dashboard.newTrip': 'Nowa podróż', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Zaplanuj nową podróż od zera', 'dashboard.gridView': 'Widok siatki', 'dashboard.listView': 'Widok listy', 'dashboard.currency': 'Waluta', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Za {count} miesięcy', 'dashboard.mobile.completed': 'Zakończone', 'dashboard.mobile.currencyConverter': 'Przelicznik walut', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Zaplanowane', + 'dashboard.hero.badgeLive': 'NA ŻYWO', + 'dashboard.hero.badgeToday': 'ZACZYNA SIĘ DZIŚ', + 'dashboard.hero.badgeTomorrow': 'JUTRO', + 'dashboard.hero.badgeNext': 'NASTĘPNA', + 'dashboard.hero.badgeRecent': 'NIEDAWNO', + 'dashboard.hero.tripDates': 'Daty podróży', + 'dashboard.hero.noDates': 'Brak ustawionych dat', + 'dashboard.hero.travelerOne': '{count} podróżny', + 'dashboard.hero.travelerMany': '{count} podróżnych', + 'dashboard.hero.destinationOne': '{count} cel', + 'dashboard.hero.destinationMany': '{count} celów', + 'dashboard.hero.dayUnitOne': 'dzień', + 'dashboard.hero.dayUnitMany': 'dni', + 'dashboard.hero.dayLeft': 'Pozostał dzień', + 'dashboard.hero.daysLeft': 'Pozostało dni', + 'dashboard.hero.lastDay': 'Ostatni dzień', + 'dashboard.hero.untilStart': 'Do startu', + 'dashboard.hero.startsIn': 'Start za', + 'dashboard.atlas.countriesVisited': 'Atlas · Odwiedzone kraje', + 'dashboard.atlas.ofTotal': 'z {total}', + 'dashboard.atlas.tripsTotal': 'Podróże łącznie', + 'dashboard.atlas.placesMapped': '{count} miejsc na mapie', + 'dashboard.atlas.daysTraveled': 'Dni w podróży', + 'dashboard.atlas.daysUnit': 'dni', + 'dashboard.atlas.acrossAllTrips': 'we wszystkich podróżach', + 'dashboard.atlas.distanceFlown': 'Przebyty dystans', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× dookoła równika', + 'dashboard.card.idea': 'Pomysł', + 'dashboard.card.buddyOne': 'Towarzysz', + 'dashboard.fx.from': 'Z', + 'dashboard.fx.to': 'Na', + 'dashboard.fx.unavailable': 'Kurs niedostępny', + 'dashboard.tz.searchPlaceholder': 'Szukaj strefy czasowej…', + 'dashboard.tz.empty': 'Brak innych stref czasowych — dodaj jedną za pomocą +', + 'dashboard.upcoming.title': 'Nadchodzące rezerwacje', + 'dashboard.upcoming.empty': 'Nic jeszcze nie zarezerwowano.', + 'dashboard.confirm.copy.title': 'Skopiować tę podróż?', + 'dashboard.confirm.copy.willCopy': 'Zostanie skopiowane', + 'dashboard.confirm.copy.will1': 'Dni, miejsca i przypisania do dni', + 'dashboard.confirm.copy.will2': 'Noclegi i rezerwacje', + 'dashboard.confirm.copy.will3': 'Pozycje budżetu i kolejność kategorii', + 'dashboard.confirm.copy.will4': 'Listy pakowania (niezaznaczone)', + 'dashboard.confirm.copy.will5': 'Zadania (nieprzypisane i niezaznaczone)', + 'dashboard.confirm.copy.will6': 'Notatki dnia', + 'dashboard.confirm.copy.wontCopy': 'Nie zostanie skopiowane', + 'dashboard.confirm.copy.wont1': 'Współpracownicy i przypisania członków', + 'dashboard.confirm.copy.wont2': 'Wspólne notatki, ankiety i wiadomości', + 'dashboard.confirm.copy.wont3': 'Pliki i zdjęcia', + 'dashboard.confirm.copy.wont4': 'Tokeny udostępniania', + 'dashboard.confirm.copy.confirm': 'Kopiuj podróż', + 'dashboard.aria.toggleView': 'Przełącz widok', + 'dashboard.aria.filter': 'Filtruj', + 'dashboard.aria.duplicate': 'Duplikuj', + 'dashboard.aria.refreshRates': 'Odśwież kursy', + 'dashboard.aria.swapCurrencies': 'Zamień waluty', + 'dashboard.aria.addTimezone': 'Dodaj strefę czasową', + 'dashboard.aria.removeTimezone': 'Usuń {city}', }; export default dashboard; diff --git a/shared/src/i18n/ru/dashboard.ts b/shared/src/i18n/ru/dashboard.ts index e8aafd2e..c9065b81 100644 --- a/shared/src/i18n/ru/dashboard.ts +++ b/shared/src/i18n/ru/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} активных поездок', 'dashboard.subtitle.archivedSuffix': ' · {count} в архиве', 'dashboard.newTrip': 'Новая поездка', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Спланируйте новую поездку с нуля', 'dashboard.gridView': 'Плитка', 'dashboard.listView': 'Список', 'dashboard.currency': 'Валюта', @@ -104,55 +104,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Через {count} мес.', 'dashboard.mobile.completed': 'Завершено', 'dashboard.mobile.currencyConverter': 'Конвертер валют', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Запланированные', + 'dashboard.hero.badgeLive': 'СЕЙЧАС В ПУТИ', + 'dashboard.hero.badgeToday': 'НАЧИНАЕТСЯ СЕГОДНЯ', + 'dashboard.hero.badgeTomorrow': 'ЗАВТРА', + 'dashboard.hero.badgeNext': 'ДАЛЕЕ', + 'dashboard.hero.badgeRecent': 'НЕДАВНО', + 'dashboard.hero.tripDates': 'Даты поездки', + 'dashboard.hero.noDates': 'Даты не заданы', + 'dashboard.hero.travelerOne': '{count} путешественник', + 'dashboard.hero.travelerMany': '{count} путешественников', + 'dashboard.hero.destinationOne': '{count} направление', + 'dashboard.hero.destinationMany': '{count} направлений', + 'dashboard.hero.dayUnitOne': 'день', + 'dashboard.hero.dayUnitMany': 'дн.', + 'dashboard.hero.dayLeft': 'День остался', + 'dashboard.hero.daysLeft': 'Дней осталось', + 'dashboard.hero.lastDay': 'Последний день', + 'dashboard.hero.untilStart': 'До начала', + 'dashboard.hero.startsIn': 'Старт через', + 'dashboard.atlas.countriesVisited': 'Атлас · Посещённые страны', + 'dashboard.atlas.ofTotal': 'из {total}', + 'dashboard.atlas.tripsTotal': 'Всего поездок', + 'dashboard.atlas.placesMapped': '{count} мест на карте', + 'dashboard.atlas.daysTraveled': 'Дней в пути', + 'dashboard.atlas.daysUnit': 'дн.', + 'dashboard.atlas.acrossAllTrips': 'по всем поездкам', + 'dashboard.atlas.distanceFlown': 'Пройдено по воздуху', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ {count}× вокруг экватора', + 'dashboard.card.idea': 'Идея', + 'dashboard.card.buddyOne': 'Попутчик', + 'dashboard.fx.from': 'Из', + 'dashboard.fx.to': 'В', + 'dashboard.fx.unavailable': 'Курс недоступен', + 'dashboard.tz.searchPlaceholder': 'Поиск часового пояса…', + 'dashboard.tz.empty': 'Других часовых поясов пока нет — добавьте с помощью +', + 'dashboard.upcoming.title': 'Ближайшие брони', + 'dashboard.upcoming.empty': 'Пока ничего не забронировано.', + 'dashboard.confirm.copy.title': 'Скопировать эту поездку?', + 'dashboard.confirm.copy.willCopy': 'Будет скопировано', + 'dashboard.confirm.copy.will1': 'Дни, места и распределение по дням', + 'dashboard.confirm.copy.will2': 'Жильё и брони', + 'dashboard.confirm.copy.will3': 'Статьи бюджета и порядок категорий', + 'dashboard.confirm.copy.will4': 'Списки вещей (без отметок)', + 'dashboard.confirm.copy.will5': 'Задачи (без назначений и отметок)', + 'dashboard.confirm.copy.will6': 'Заметки дня', + 'dashboard.confirm.copy.wontCopy': 'Не будет скопировано', + 'dashboard.confirm.copy.wont1': 'Участники и назначения', + 'dashboard.confirm.copy.wont2': 'Совместные заметки, опросы и сообщения', + 'dashboard.confirm.copy.wont3': 'Файлы и фото', + 'dashboard.confirm.copy.wont4': 'Токены доступа', + 'dashboard.confirm.copy.confirm': 'Копировать поездку', + 'dashboard.aria.toggleView': 'Переключить вид', + 'dashboard.aria.filter': 'Фильтр', + 'dashboard.aria.duplicate': 'Дублировать', + 'dashboard.aria.refreshRates': 'Обновить курсы', + 'dashboard.aria.swapCurrencies': 'Поменять валюты', + 'dashboard.aria.addTimezone': 'Добавить часовой пояс', + 'dashboard.aria.removeTimezone': 'Удалить {city}', }; export default dashboard; diff --git a/shared/src/i18n/tr/dashboard.ts b/shared/src/i18n/tr/dashboard.ts index 56cffef3..40fffa8f 100644 --- a/shared/src/i18n/tr/dashboard.ts +++ b/shared/src/i18n/tr/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} etkin seyahat', 'dashboard.subtitle.archivedSuffix': ' · {count} arşivde', 'dashboard.newTrip': 'Yeni Seyahat', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Sıfırdan yeni bir seyahat planla', 'dashboard.gridView': 'Izgara görünümü', 'dashboard.listView': 'Liste görünümü', 'dashboard.currency': 'Para birimi', @@ -65,7 +65,7 @@ const dashboard: TranslationStrings = { 'dashboard.confirm.copy.will4': 'Paket listeleri (işaretlenmemiş)', 'dashboard.confirm.copy.will5': 'Yapılacaklar (atanmamış ve işaretlenmemiş)', 'dashboard.confirm.copy.will6': 'Gün notları', - 'dashboard.confirm.copy.wontCopy': "Won't be copied", + 'dashboard.confirm.copy.wontCopy': 'Kopyalanmayacak', 'dashboard.confirm.copy.wont1': 'İşbirlikçiler ve üye atamaları', 'dashboard.confirm.copy.wont2': 'Collab notları, anketler ve mesajlar', 'dashboard.confirm.copy.wont3': 'Dosyalar ve fotoğraflar', @@ -113,45 +113,54 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.ongoing': 'Devam ediyor', 'dashboard.mobile.startsToday': 'Bugün başlıyor', 'dashboard.mobile.tomorrow': 'Yarın', - 'dashboard.mobile.inDays': '{count} Gün sonra', - 'dashboard.mobile.inMonths': '{count} Ay sonra', + 'dashboard.mobile.inDays': '{count} gün içinde', + 'dashboard.mobile.inMonths': '{count} ay içinde', 'dashboard.mobile.completed': 'Tamamlandı', - 'dashboard.mobile.currencyConverter': 'Para birimi dönüştürücü', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.mobile.currencyConverter': 'Döviz çevirici', + 'dashboard.filter.planned': 'Planlanan', + 'dashboard.hero.badgeLive': 'ŞİMDİ CANLI', + 'dashboard.hero.badgeToday': 'BUGÜN BAŞLIYOR', + 'dashboard.hero.badgeTomorrow': 'YARIN', + 'dashboard.hero.badgeNext': 'SIRADAKİ', + 'dashboard.hero.badgeRecent': 'YAKIN ZAMANDA', + 'dashboard.hero.tripDates': 'Gezi tarihleri', + 'dashboard.hero.noDates': 'Tarih ayarlanmadı', + 'dashboard.hero.travelerOne': '{count} gezgin', + 'dashboard.hero.travelerMany': '{count} gezgin', + 'dashboard.hero.destinationOne': '{count} varış noktası', + 'dashboard.hero.destinationMany': '{count} varış noktası', + 'dashboard.hero.dayUnitOne': 'gün', + 'dashboard.hero.dayUnitMany': 'gün', + 'dashboard.hero.dayLeft': 'gün kaldı', + 'dashboard.hero.daysLeft': 'gün kaldı', + 'dashboard.hero.lastDay': 'Son gün', + 'dashboard.hero.untilStart': 'Başlangıca', + 'dashboard.hero.startsIn': 'Başlamasına', + 'dashboard.atlas.countriesVisited': 'Atlas · Gezilen ülkeler', + 'dashboard.atlas.ofTotal': '/ {total}', + 'dashboard.atlas.tripsTotal': 'Toplam gezi', + 'dashboard.atlas.placesMapped': '{count} yer haritalandı', + 'dashboard.atlas.daysTraveled': 'Seyahat günleri', + 'dashboard.atlas.daysUnit': 'gün', + 'dashboard.atlas.acrossAllTrips': 'tüm gezilerde', + 'dashboard.atlas.distanceFlown': 'Uçulan mesafe', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', + 'dashboard.atlas.aroundEquator': '≈ ekvatorun {count}× çevresi', + 'dashboard.card.idea': 'Fikir', + 'dashboard.card.buddyOne': 'Arkadaş', + 'dashboard.fx.from': 'Kaynak', + 'dashboard.fx.to': 'Hedef', + 'dashboard.fx.unavailable': 'Kur kullanılamıyor', + 'dashboard.tz.searchPlaceholder': 'Saat dilimi ara…', + 'dashboard.tz.empty': 'Henüz başka saat dilimi yok — + ile bir tane ekleyin', + 'dashboard.upcoming.title': 'Yaklaşan rezervasyonlar', + 'dashboard.upcoming.empty': 'Henüz bir şey rezerve edilmedi.', + 'dashboard.aria.toggleView': 'Görünümü değiştir', + 'dashboard.aria.filter': 'Filtrele', + 'dashboard.aria.duplicate': 'Çoğalt', + 'dashboard.aria.refreshRates': 'Kurları yenile', + 'dashboard.aria.swapCurrencies': 'Para birimlerini değiştir', + 'dashboard.aria.addTimezone': 'Saat dilimi ekle', + 'dashboard.aria.removeTimezone': '{city} kaldır', }; export default dashboard; diff --git a/shared/src/i18n/uk/dashboard.ts b/shared/src/i18n/uk/dashboard.ts index c136e535..159716f4 100644 --- a/shared/src/i18n/uk/dashboard.ts +++ b/shared/src/i18n/uk/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} активних поїздок', 'dashboard.subtitle.archivedSuffix': ' · {count} в архіві', 'dashboard.newTrip': 'Нова поїздка', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': 'Сплануйте нову поїздку з нуля', 'dashboard.gridView': 'Плитка', 'dashboard.listView': 'Список', 'dashboard.currency': 'Валюта', @@ -118,41 +118,50 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': 'Через {count} міс.', 'dashboard.mobile.completed': 'Завершено', 'dashboard.mobile.currencyConverter': 'Конвертер валют', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': 'Заплановані', + 'dashboard.hero.badgeLive': 'ЗАРАЗ У ДОРОЗІ', + 'dashboard.hero.badgeToday': 'ПОЧИНАЄТЬСЯ СЬОГОДНІ', + 'dashboard.hero.badgeTomorrow': 'ЗАВТРА', + 'dashboard.hero.badgeNext': 'ДАЛІ', + 'dashboard.hero.badgeRecent': 'НЕЩОДАВНО', + 'dashboard.hero.tripDates': 'Дати поїздки', + 'dashboard.hero.noDates': 'Дати не задано', + 'dashboard.hero.travelerOne': '{count} мандрівник', + 'dashboard.hero.travelerMany': '{count} мандрівників', + 'dashboard.hero.destinationOne': '{count} напрямок', + 'dashboard.hero.destinationMany': '{count} напрямків', + 'dashboard.hero.dayUnitOne': 'день', + 'dashboard.hero.dayUnitMany': 'дн.', + 'dashboard.hero.dayLeft': 'Залишився день', + 'dashboard.hero.daysLeft': 'Залишилось днів', + 'dashboard.hero.lastDay': 'Останній день', + 'dashboard.hero.untilStart': 'До початку', + 'dashboard.hero.startsIn': 'Старт через', + 'dashboard.atlas.countriesVisited': 'Атлас · Відвідані країни', + 'dashboard.atlas.ofTotal': 'з {total}', + 'dashboard.atlas.tripsTotal': 'Усього поїздок', + 'dashboard.atlas.placesMapped': '{count} місць на карті', + 'dashboard.atlas.daysTraveled': 'Днів у дорозі', + 'dashboard.atlas.daysUnit': 'дн.', + 'dashboard.atlas.acrossAllTrips': 'за всіма поїздками', + 'dashboard.atlas.distanceFlown': 'Подолана відстань', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', + 'dashboard.atlas.aroundEquator': '≈ {count}× навколо екватора', + 'dashboard.card.idea': 'Ідея', + 'dashboard.card.buddyOne': 'Супутник', + 'dashboard.fx.from': 'З', + 'dashboard.fx.to': 'У', + 'dashboard.fx.unavailable': 'Курс недоступний', + 'dashboard.tz.searchPlaceholder': 'Пошук часового поясу…', + 'dashboard.tz.empty': 'Інших часових поясів поки немає — додайте за допомогою +', + 'dashboard.upcoming.title': 'Найближчі бронювання', + 'dashboard.upcoming.empty': 'Поки нічого не заброньовано.', + 'dashboard.aria.toggleView': 'Перемкнути вигляд', + 'dashboard.aria.filter': 'Фільтр', + 'dashboard.aria.duplicate': 'Дублювати', + 'dashboard.aria.refreshRates': 'Оновити курси', + 'dashboard.aria.swapCurrencies': 'Поміняти валюти', + 'dashboard.aria.addTimezone': 'Додати часовий пояс', + 'dashboard.aria.removeTimezone': 'Вилучити {city}', }; export default dashboard; diff --git a/shared/src/i18n/zh-TW/dashboard.ts b/shared/src/i18n/zh-TW/dashboard.ts index 7d2e3662..9ba7d90a 100644 --- a/shared/src/i18n/zh-TW/dashboard.ts +++ b/shared/src/i18n/zh-TW/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} 個進行中的旅行', 'dashboard.subtitle.archivedSuffix': ' · {count} 已歸檔', 'dashboard.newTrip': '新建旅行', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': '從零開始規劃新旅行', 'dashboard.gridView': '網格檢視', 'dashboard.listView': '列表檢視', 'dashboard.currency': '貨幣', @@ -102,55 +102,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': '{count} 個月後', 'dashboard.mobile.completed': '已完成', 'dashboard.mobile.currencyConverter': '匯率轉換', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': '已規劃', + 'dashboard.hero.badgeLive': '進行中', + 'dashboard.hero.badgeToday': '今天開始', + 'dashboard.hero.badgeTomorrow': '明天', + 'dashboard.hero.badgeNext': '即將開始', + 'dashboard.hero.badgeRecent': '最近', + 'dashboard.hero.tripDates': '旅行日期', + 'dashboard.hero.noDates': '未設定日期', + 'dashboard.hero.travelerOne': '{count} 位旅客', + 'dashboard.hero.travelerMany': '{count} 位旅客', + 'dashboard.hero.destinationOne': '{count} 個目的地', + 'dashboard.hero.destinationMany': '{count} 個目的地', + 'dashboard.hero.dayUnitOne': '天', + 'dashboard.hero.dayUnitMany': '天', + 'dashboard.hero.dayLeft': '剩 1 天', + 'dashboard.hero.daysLeft': '剩餘天數', + 'dashboard.hero.lastDay': '最後一天', + 'dashboard.hero.untilStart': '距開始', + 'dashboard.hero.startsIn': '距出發', + 'dashboard.atlas.countriesVisited': '地圖集 · 已造訪國家', + 'dashboard.atlas.ofTotal': '/ {total}', + 'dashboard.atlas.tripsTotal': '旅行總數', + 'dashboard.atlas.placesMapped': '已標記 {count} 個地點', + 'dashboard.atlas.daysTraveled': '旅行天數', + 'dashboard.atlas.daysUnit': '天', + 'dashboard.atlas.acrossAllTrips': '所有旅行累計', + 'dashboard.atlas.distanceFlown': '飛行距離', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ 繞赤道 {count} 圈', + 'dashboard.card.idea': '想法', + 'dashboard.card.buddyOne': '旅伴', + 'dashboard.fx.from': '從', + 'dashboard.fx.to': '到', + 'dashboard.fx.unavailable': '無法取得匯率', + 'dashboard.tz.searchPlaceholder': '搜尋時區…', + 'dashboard.tz.empty': '還沒有其他時區 — 用 + 新增一個', + 'dashboard.upcoming.title': '即將到來的預訂', + 'dashboard.upcoming.empty': '尚未預訂任何內容。', + 'dashboard.confirm.copy.title': '複製這次旅行?', + 'dashboard.confirm.copy.willCopy': '將被複製', + 'dashboard.confirm.copy.will1': '天數、地點和每日安排', + 'dashboard.confirm.copy.will2': '住宿和預訂', + 'dashboard.confirm.copy.will3': '預算項目和分類順序', + 'dashboard.confirm.copy.will4': '打包清單(未勾選)', + 'dashboard.confirm.copy.will5': '待辦事項(未指派且未勾選)', + 'dashboard.confirm.copy.will6': '每日筆記', + 'dashboard.confirm.copy.wontCopy': '不會被複製', + 'dashboard.confirm.copy.wont1': '協作者和成員指派', + 'dashboard.confirm.copy.wont2': '協作筆記、投票和訊息', + 'dashboard.confirm.copy.wont3': '檔案和照片', + 'dashboard.confirm.copy.wont4': '共享權杖', + 'dashboard.confirm.copy.confirm': '複製旅行', + 'dashboard.aria.toggleView': '切換檢視', + 'dashboard.aria.filter': '篩選', + 'dashboard.aria.duplicate': '複製', + 'dashboard.aria.refreshRates': '重新整理匯率', + 'dashboard.aria.swapCurrencies': '交換貨幣', + 'dashboard.aria.addTimezone': '新增時區', + 'dashboard.aria.removeTimezone': '移除 {city}', }; export default dashboard; diff --git a/shared/src/i18n/zh/dashboard.ts b/shared/src/i18n/zh/dashboard.ts index 8a42dd22..16e50e8d 100644 --- a/shared/src/i18n/zh/dashboard.ts +++ b/shared/src/i18n/zh/dashboard.ts @@ -9,7 +9,7 @@ const dashboard: TranslationStrings = { 'dashboard.subtitle.activeMany': '{count} 个进行中的旅行', 'dashboard.subtitle.archivedSuffix': ' · {count} 已归档', 'dashboard.newTrip': '新建旅行', - 'dashboard.newTripSub': 'Start blank · or import from another planner', + 'dashboard.newTripSub': '从零开始规划新旅行', 'dashboard.gridView': '网格视图', 'dashboard.listView': '列表视图', 'dashboard.currency': '货币', @@ -102,55 +102,64 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inMonths': '{count} 个月后', 'dashboard.mobile.completed': '已完成', 'dashboard.mobile.currencyConverter': '汇率转换', - 'dashboard.filter.planned': 'Planned', - 'dashboard.hero.badgeLive': 'LIVE NOW', - 'dashboard.hero.badgeToday': 'STARTS TODAY', - 'dashboard.hero.badgeTomorrow': 'TOMORROW', - 'dashboard.hero.badgeNext': 'UP NEXT', - 'dashboard.hero.badgeRecent': 'RECENT', - 'dashboard.hero.tripDates': 'Trip dates', - 'dashboard.hero.noDates': 'No dates set', - 'dashboard.hero.travelerOne': '{count} traveler', - 'dashboard.hero.travelerMany': '{count} travelers', - 'dashboard.hero.destinationOne': '{count} destination', - 'dashboard.hero.destinationMany': '{count} destinations', - 'dashboard.hero.dayUnitOne': 'day', - 'dashboard.hero.dayUnitMany': 'days', - 'dashboard.hero.dayLeft': 'Day left', - 'dashboard.hero.daysLeft': 'Days left', - 'dashboard.hero.lastDay': 'Last day', - 'dashboard.atlas.countriesVisited': 'Atlas · Countries visited', - 'dashboard.atlas.ofTotal': 'of {total}', - 'dashboard.atlas.tripsTotal': 'Trips total', - 'dashboard.atlas.placesMapped': '{count} places mapped', - 'dashboard.atlas.daysTraveled': 'Days traveled', - 'dashboard.atlas.daysUnit': 'days', - 'dashboard.atlas.acrossAllTrips': 'across all trips', - 'dashboard.atlas.distanceFlown': 'Distance flown', + 'dashboard.filter.planned': '已计划', + 'dashboard.hero.badgeLive': '进行中', + 'dashboard.hero.badgeToday': '今天开始', + 'dashboard.hero.badgeTomorrow': '明天', + 'dashboard.hero.badgeNext': '即将开始', + 'dashboard.hero.badgeRecent': '最近', + 'dashboard.hero.tripDates': '旅行日期', + 'dashboard.hero.noDates': '未设置日期', + 'dashboard.hero.travelerOne': '{count} 位旅客', + 'dashboard.hero.travelerMany': '{count} 位旅客', + 'dashboard.hero.destinationOne': '{count} 个目的地', + 'dashboard.hero.destinationMany': '{count} 个目的地', + 'dashboard.hero.dayUnitOne': '天', + 'dashboard.hero.dayUnitMany': '天', + 'dashboard.hero.dayLeft': '剩 1 天', + 'dashboard.hero.daysLeft': '剩余天数', + 'dashboard.hero.lastDay': '最后一天', + 'dashboard.hero.untilStart': '距开始', + 'dashboard.hero.startsIn': '距出发', + 'dashboard.atlas.countriesVisited': '地图集 · 已访问国家', + 'dashboard.atlas.ofTotal': '/ {total}', + 'dashboard.atlas.tripsTotal': '旅行总数', + 'dashboard.atlas.placesMapped': '已标记 {count} 个地点', + 'dashboard.atlas.daysTraveled': '旅行天数', + 'dashboard.atlas.daysUnit': '天', + 'dashboard.atlas.acrossAllTrips': '所有旅行累计', + 'dashboard.atlas.distanceFlown': '飞行距离', 'dashboard.atlas.kmUnit': 'km', - 'dashboard.atlas.aroundEquator': '≈ {count}× around the equator', - 'dashboard.card.idea': 'Idea', - 'dashboard.card.buddyOne': 'Buddy', - 'dashboard.fx.from': 'From', - 'dashboard.fx.to': 'To', - 'dashboard.fx.unavailable': 'Rate unavailable', - 'dashboard.tz.searchPlaceholder': 'Search timezone…', - 'dashboard.tz.empty': 'No other timezones yet — add one with +', - 'dashboard.upcoming.title': 'Upcoming reservations', - 'dashboard.upcoming.empty': 'Nothing booked yet.', - 'dashboard.confirm.copy.title': 'Copy this trip?', - 'dashboard.confirm.copy.willCopy': 'Will be copied', - 'dashboard.confirm.copy.will1': 'Days, places & day assignments', - 'dashboard.confirm.copy.will2': 'Accommodations & reservations', - 'dashboard.confirm.copy.will3': 'Budget items & category order', - 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', - 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', - 'dashboard.confirm.copy.will6': 'Day notes', - 'dashboard.confirm.copy.wontCopy': 'Won\'t be copied', - 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', - 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', - 'dashboard.confirm.copy.wont3': 'Files & photos', - 'dashboard.confirm.copy.wont4': 'Share tokens', - 'dashboard.confirm.copy.confirm': 'Copy trip', + 'dashboard.atlas.aroundEquator': '≈ 绕赤道 {count} 圈', + 'dashboard.card.idea': '想法', + 'dashboard.card.buddyOne': '旅伴', + 'dashboard.fx.from': '从', + 'dashboard.fx.to': '到', + 'dashboard.fx.unavailable': '无法获取汇率', + 'dashboard.tz.searchPlaceholder': '搜索时区…', + 'dashboard.tz.empty': '还没有其他时区 — 用 + 添加一个', + 'dashboard.upcoming.title': '即将到来的预订', + 'dashboard.upcoming.empty': '尚未预订任何内容。', + 'dashboard.confirm.copy.title': '复制这次旅行?', + 'dashboard.confirm.copy.willCopy': '将被复制', + 'dashboard.confirm.copy.will1': '天数、地点和每日安排', + 'dashboard.confirm.copy.will2': '住宿和预订', + 'dashboard.confirm.copy.will3': '预算项和分类顺序', + 'dashboard.confirm.copy.will4': '打包清单(未勾选)', + 'dashboard.confirm.copy.will5': '待办事项(未分配且未勾选)', + 'dashboard.confirm.copy.will6': '每日笔记', + 'dashboard.confirm.copy.wontCopy': '不会被复制', + 'dashboard.confirm.copy.wont1': '协作者和成员分配', + 'dashboard.confirm.copy.wont2': '协作笔记、投票和消息', + 'dashboard.confirm.copy.wont3': '文件和照片', + 'dashboard.confirm.copy.wont4': '共享令牌', + 'dashboard.confirm.copy.confirm': '复制旅行', + 'dashboard.aria.toggleView': '切换视图', + 'dashboard.aria.filter': '筛选', + 'dashboard.aria.duplicate': '复制', + 'dashboard.aria.refreshRates': '刷新汇率', + 'dashboard.aria.swapCurrencies': '交换货币', + 'dashboard.aria.addTimezone': '添加时区', + 'dashboard.aria.removeTimezone': '移除 {city}', }; export default dashboard;