From 49afee161d9e6eb715e2eb3cd56dbef9d30e1922 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 21:26:41 +0200 Subject: [PATCH] feat(mobile): phone styling for tours list, trip settings and admin cards The tours list in the places browser now uses the same rows as places: a grade or type tile, the day plan's figure badges and a day badge or plus on the right. A shared "Planned on desktop" badge replaces the grey hint box there and sits under the road trip's empty stage. Trip settings and trip edit open phone sheets instead of the desktop dialogs; the edit sheet gains the day count of a dateless trip and the reminder. Questions are asked in phone sheets, not the browser. Admin on the phone: the paddle regions and school holiday cards are phone cards on their shared hooks, long addon descriptions fold to two lines, the addon header keeps the wordmark inside its sentence, the group bands drop the dash and the permissions actions get a row of their own. The Tours addon gets its catalog name and description in every locale. The journey map on the phone shows every layer without the switch bar. --- client/scripts/feature-pairs.json | 18 + client/scripts/i18n-unused-baseline.json | 2 +- client/scripts/layers-baseline.json | 1 - client/scripts/strict-baseline.json | 3 - client/scripts/theme-baseline.json | 10 +- .../src/components/Admin/addons/addonModel.ts | 14 +- .../Admin/paddle/PaddleAutoLoadParts.tsx | 19 +- .../Admin/paddle/PaddleRegionsPanel.tsx | 4 +- .../Admin/paddle/paddleRegionsModel.ts | 23 +- .../Journey/JourneyLayeredMap.test.tsx | 6 + .../components/Journey/JourneyLayeredMap.tsx | 14 +- .../components/Journey/MobileMapTimeline.tsx | 4 +- .../Journey/useJourneyMapLayers.test.ts | 27 + .../components/Journey/useJourneyMapLayers.ts | 9 +- .../Trips/TripMembersModal.test.tsx | 74 --- .../src/components/Trips/TripMembersModal.tsx | 400 +-------------- .../mobile/components/MPlanOnDesktopBadge.tsx | 26 + client/src/mobile/screens/admin/MAdmin.tsx | 8 +- .../screens/admin/MAdminAddonManager.tsx | 30 +- .../admin/MAdminPaddleRegionsPanel.tsx | 454 +++++++++++++++++ .../screens/admin/MAdminPermissionsPanel.tsx | 30 +- .../admin/MAdminSchoolHolidayCatalog.tsx | 431 ++++++++++++++++ client/src/mobile/screens/admin/MAdminUi.tsx | 19 + .../screens/dashboard/MNewTripSheet.tsx | 99 +++- .../mobile/screens/journey/MJourneyDetail.tsx | 2 +- .../trip/places/MToursSelectionList.test.tsx | 162 +++--- .../trip/places/MToursSelectionList.tsx | 237 +++++---- .../screens/trip/roadtrip/MRoadtripTab.tsx | 10 +- .../trip/sheets/MTripSettingsSheet.tsx | 462 ++++++++++++++++++ .../screens/trip/sheets/MTripSheets.tsx | 21 +- .../pages/journeyDetail/useJourneyDetail.ts | 2 +- .../journeyDetail/useJourneyDetailLayers.ts | 4 + .../pages/journeyPublic/useJourneyPublic.ts | 1 + .../tests/unit/mobile/admin/MAdmin.test.tsx | 2 +- .../mobile/admin/MAdminAddonManager.test.tsx | 9 +- .../admin/MAdminPaddleRegionsPanel.test.tsx | 167 +++++++ .../admin/MAdminSchoolHolidayCatalog.test.tsx | 124 +++++ .../mobile/dashboard/MNewTripSheet.test.tsx | 64 +++ .../unit/mobile/trip/MRoadtripTab.test.tsx | 6 +- .../mobile/trip/MTripSettingsSheet.test.tsx | 228 +++++++++ .../mobile/trip/MTripSheets.members.test.tsx | 7 +- .../unit/mobile/trip/MTripSheets.test.tsx | 12 +- shared/src/i18n/ar/admin.ts | 2 + shared/src/i18n/ar/mobileTrip.ts | 10 +- shared/src/i18n/ar/nav.ts | 1 - shared/src/i18n/ar/tours.ts | 2 - shared/src/i18n/az/admin.ts | 2 + shared/src/i18n/az/mobileTrip.ts | 7 +- shared/src/i18n/az/nav.ts | 1 - shared/src/i18n/az/tours.ts | 2 - shared/src/i18n/br/admin.ts | 2 + shared/src/i18n/br/mobileTrip.ts | 7 +- shared/src/i18n/br/nav.ts | 1 - shared/src/i18n/br/tours.ts | 2 - shared/src/i18n/ca/admin.ts | 2 + shared/src/i18n/ca/mobileTrip.ts | 7 +- shared/src/i18n/ca/nav.ts | 1 - shared/src/i18n/ca/tours.ts | 2 - shared/src/i18n/cs/admin.ts | 2 + shared/src/i18n/cs/mobileTrip.ts | 7 +- shared/src/i18n/cs/nav.ts | 1 - shared/src/i18n/cs/tours.ts | 2 - shared/src/i18n/de/admin.ts | 2 + shared/src/i18n/de/mobileTrip.ts | 6 +- shared/src/i18n/de/nav.ts | 1 - shared/src/i18n/de/tours.ts | 2 - shared/src/i18n/en/admin.ts | 2 + shared/src/i18n/en/mobileTrip.ts | 6 +- shared/src/i18n/en/nav.ts | 1 - shared/src/i18n/en/tours.ts | 2 - shared/src/i18n/es/admin.ts | 2 + shared/src/i18n/es/mobileTrip.ts | 7 +- shared/src/i18n/es/nav.ts | 1 - shared/src/i18n/es/tours.ts | 2 - shared/src/i18n/et/admin.ts | 2 + shared/src/i18n/et/mobileTrip.ts | 7 +- shared/src/i18n/et/nav.ts | 1 - shared/src/i18n/et/tours.ts | 2 - shared/src/i18n/fr/admin.ts | 2 + shared/src/i18n/fr/mobileTrip.ts | 7 +- shared/src/i18n/fr/nav.ts | 1 - shared/src/i18n/fr/tours.ts | 2 - shared/src/i18n/gr/admin.ts | 2 + shared/src/i18n/gr/mobileTrip.ts | 7 +- shared/src/i18n/gr/nav.ts | 1 - shared/src/i18n/gr/tours.ts | 2 - shared/src/i18n/hu/admin.ts | 2 + shared/src/i18n/hu/mobileTrip.ts | 7 +- shared/src/i18n/hu/nav.ts | 1 - shared/src/i18n/hu/tours.ts | 2 - shared/src/i18n/id/admin.ts | 2 + shared/src/i18n/id/mobileTrip.ts | 6 +- shared/src/i18n/id/nav.ts | 1 - shared/src/i18n/id/tours.ts | 2 - shared/src/i18n/it/admin.ts | 2 + shared/src/i18n/it/mobileTrip.ts | 7 +- shared/src/i18n/it/nav.ts | 1 - shared/src/i18n/it/tours.ts | 2 - shared/src/i18n/ja/admin.ts | 2 + shared/src/i18n/ja/mobileTrip.ts | 5 +- shared/src/i18n/ja/nav.ts | 1 - shared/src/i18n/ja/tours.ts | 2 - shared/src/i18n/ko/admin.ts | 2 + shared/src/i18n/ko/mobileTrip.ts | 5 +- shared/src/i18n/ko/nav.ts | 1 - shared/src/i18n/ko/tours.ts | 2 - shared/src/i18n/nl/admin.ts | 2 + shared/src/i18n/nl/mobileTrip.ts | 6 +- shared/src/i18n/nl/nav.ts | 1 - shared/src/i18n/nl/tours.ts | 2 - shared/src/i18n/pl/admin.ts | 2 + shared/src/i18n/pl/mobileTrip.ts | 8 +- shared/src/i18n/pl/nav.ts | 1 - shared/src/i18n/pl/tours.ts | 2 - shared/src/i18n/ru/admin.ts | 2 + shared/src/i18n/ru/mobileTrip.ts | 9 +- shared/src/i18n/ru/nav.ts | 1 - shared/src/i18n/ru/tours.ts | 2 - shared/src/i18n/sk/admin.ts | 2 + shared/src/i18n/sk/mobileTrip.ts | 7 +- shared/src/i18n/sk/nav.ts | 1 - shared/src/i18n/sk/tours.ts | 2 - shared/src/i18n/sv/admin.ts | 2 + shared/src/i18n/sv/mobileTrip.ts | 6 +- shared/src/i18n/sv/nav.ts | 1 - shared/src/i18n/sv/tours.ts | 2 - shared/src/i18n/th/admin.ts | 2 + shared/src/i18n/th/mobileTrip.ts | 5 +- shared/src/i18n/th/nav.ts | 1 - shared/src/i18n/th/tours.ts | 1 - shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/admin.ts | 2 + shared/src/i18n/tr/mobileTrip.ts | 6 +- shared/src/i18n/tr/nav.ts | 1 - shared/src/i18n/tr/tours.ts | 2 - shared/src/i18n/uk/admin.ts | 2 + shared/src/i18n/uk/mobileTrip.ts | 9 +- shared/src/i18n/uk/nav.ts | 1 - shared/src/i18n/uk/tours.ts | 2 - shared/src/i18n/vi/admin.ts | 2 + shared/src/i18n/vi/mobileTrip.ts | 6 +- shared/src/i18n/vi/nav.ts | 1 - shared/src/i18n/vi/tours.ts | 2 - shared/src/i18n/zh-TW/admin.ts | 2 + shared/src/i18n/zh-TW/mobileTrip.ts | 5 +- shared/src/i18n/zh-TW/nav.ts | 1 - shared/src/i18n/zh-TW/tours.ts | 1 - shared/src/i18n/zh/admin.ts | 2 + shared/src/i18n/zh/mobileTrip.ts | 5 +- shared/src/i18n/zh/nav.ts | 1 - shared/src/i18n/zh/tours.ts | 1 - 151 files changed, 2660 insertions(+), 899 deletions(-) create mode 100644 client/src/mobile/components/MPlanOnDesktopBadge.tsx create mode 100644 client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx create mode 100644 client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx create mode 100644 client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx create mode 100644 client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx create mode 100644 client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx create mode 100644 client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index bc2ef93ff..30922b01f 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -899,6 +899,24 @@ "hooks": ["src/components/Vacay/useVacaySettings.ts"], "desktop": ["src/components/Vacay/VacaySettings.tsx"], "mobile": ["src/mobile/screens/vacay/MVacaySettingsSheet.tsx"] + }, + { + "feature": "Paddle regions admin card", + "hooks": ["src/components/Admin/paddle/usePaddleRegionsAdmin.ts", "src/components/Admin/paddle/paddleRegionsModel.ts"], + "desktop": ["src/components/Admin/paddle/PaddleRegionsPanel.tsx"], + "mobile": ["src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx"] + }, + { + "feature": "School holiday catalog admin card", + "hooks": ["src/components/Admin/useSchoolHolidayCatalog.ts"], + "desktop": ["src/components/Admin/SchoolHolidayCatalog.tsx"], + "mobile": ["src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx"] + }, + { + "feature": "Trip settings: members, guests, public and invite link", + "hooks": ["src/components/Trips/useTripShare.ts"], + "desktop": ["src/components/Trips/TripShareDialog.tsx"], + "mobile": ["src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx"] } ], "mobileOnly": { diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index b5db1317a..1b04bea9f 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -1,5 +1,5 @@ { - "admin.ts": 152, + "admin.ts": 126, "atlas.ts": 10, "backup.ts": 10, "budget.ts": 40, diff --git a/client/scripts/layers-baseline.json b/client/scripts/layers-baseline.json index 815ce6cff..bad532aac 100644 --- a/client/scripts/layers-baseline.json +++ b/client/scripts/layers-baseline.json @@ -42,7 +42,6 @@ "src/components/Studio/StudioSidebar.tsx": 1, "src/components/Studio/TravelElements.tsx": 1, "src/components/Todo/useTodoList.ts": 1, - "src/components/Trips/TripMembersModal.tsx": 1, "src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/CountryFlag.tsx": 1, "src/components/shared/EmptyState.tsx": 1, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index 31b55fa95..63f2ed0f5 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -90,7 +90,6 @@ "src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/useTourPlanner.ts": 2, "src/components/Trips/TripFormModal.tsx": 3, - "src/components/Trips/TripMembersModal.tsx": 1, "src/components/Vacay/VacayCalendar.tsx": 1, "src/components/Vacay/VacayPersons.tsx": 4, "src/components/Vacay/VacaySettings.tsx": 2, @@ -110,14 +109,12 @@ "src/mobile/screens/trip/MTripShell.tsx": 1, "src/mobile/screens/trip/map/MMapArea.tsx": 1, "src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2, - "src/mobile/screens/trip/places/MToursSelectionList.tsx": 6, "src/mobile/screens/trip/plan/planTimelineModel.ts": 1, "src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2, "src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2, "src/mobile/screens/trip/sheets/MReservationSheet.tsx": 1, - "src/mobile/screens/trip/sheets/MTripSheets.tsx": 1, "src/mobile/screens/trip/tabs/transportsModel.ts": 1, "src/mobile/screens/vacay/MVacaySettingsSheet.tsx": 1, "src/pages/AtlasPage.tsx": 6, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 5c871f962..dfd366398 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -116,7 +116,6 @@ "components/Todo/TodoListPanel.tsx": 6, "components/Todo/todoListModel.ts": 3, "components/Trips/TripFormModal.tsx": 9, - "components/Trips/TripMembersModal.tsx": 6, "components/Vacay/VacayBadge.tsx": 3, "components/Vacay/VacayCalendar.tsx": 20, "components/Vacay/VacayMonthCard.tsx": 5, @@ -156,7 +155,7 @@ "mobile/components/MToastHost.tsx": 2, "mobile/components/MUserPickerList.tsx": 1, "mobile/screens/admin/MAdmin.tsx": 9, - "mobile/screens/admin/MAdminAddonManager.tsx": 26, + "mobile/screens/admin/MAdminAddonManager.tsx": 22, "mobile/screens/admin/MAdminAuditLogPanel.tsx": 15, "mobile/screens/admin/MAdminBackupPanel.tsx": 8, "mobile/screens/admin/MAdminCategoryManager.tsx": 8, @@ -166,7 +165,7 @@ "mobile/screens/admin/MAdminMcpTokensPanel.tsx": 14, "mobile/screens/admin/MAdminNotifyMatrix.tsx": 5, "mobile/screens/admin/MAdminPackingTemplateManager.tsx": 8, - "mobile/screens/admin/MAdminPermissionsPanel.tsx": 7, + "mobile/screens/admin/MAdminPermissionsPanel.tsx": 5, "mobile/screens/admin/MAdminPluginsPanel.tsx": 129, "mobile/screens/admin/MAdminSettingsSection.tsx": 10, "mobile/screens/admin/MAdminSheets.tsx": 11, @@ -193,7 +192,7 @@ "mobile/screens/collections/MCollections.tsx": 21, "mobile/screens/dashboard/MDashWidgets.tsx": 22, "mobile/screens/dashboard/MDashboard.tsx": 36, - "mobile/screens/dashboard/MNewTripSheet.tsx": 21, + "mobile/screens/dashboard/MNewTripSheet.tsx": 20, "mobile/screens/dashboard/MUserMenu.tsx": 7, "mobile/screens/journey/MJourney.tsx": 10, "mobile/screens/journey/MJourneyCreateSheet.tsx": 10, @@ -230,11 +229,10 @@ "mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3, "mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7, "mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1, - "mobile/screens/trip/places/MToursSelectionList.tsx": 9, "mobile/screens/trip/plan/MPlanTimeline.tsx": 14, "mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26, "mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31, - "mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 17, + "mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16, "mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8, "mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 2, "mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 7, diff --git a/client/src/components/Admin/addons/addonModel.ts b/client/src/components/Admin/addons/addonModel.ts index a2e2da0e7..75a6e4070 100644 --- a/client/src/components/Admin/addons/addonModel.ts +++ b/client/src/components/Admin/addons/addonModel.ts @@ -49,14 +49,16 @@ export function isPhotosAddon(addon: Addon): boolean { /** The catalog name and description for an addon, falling back to what the server sent. */ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { - const nameKey = `admin.addons.catalog.${addon.id}.name`; - const descKey = `admin.addons.catalog.${addon.id}.description`; - const translatedName = t(nameKey); - const translatedDescription = t(descKey); + // Spelled out inside t() so the key check sees which catalog keys this reaches. + const translatedName = t(`admin.addons.catalog.${addon.id}.name`); + const translatedDescription = t(`admin.addons.catalog.${addon.id}.description`); return { - name: translatedName !== nameKey ? translatedName : addon.name, - description: translatedDescription !== descKey ? translatedDescription : addon.description, + name: translatedName !== `admin.addons.catalog.${addon.id}.name` ? translatedName : addon.name, + description: + translatedDescription !== `admin.addons.catalog.${addon.id}.description` + ? translatedDescription + : addon.description, }; } diff --git a/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx b/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx index 45205245b..729bc318f 100644 --- a/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx +++ b/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx @@ -5,24 +5,17 @@ import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } f import { LABEL, Segmented } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; +import { AUTO_LOAD_MODES, autoLoadSizes, formatMegabytes } from './paddleRegionsModel'; import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; type Admin = ReturnType; -const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off']; const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const; -/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */ -const SIZES = [100, 250, 500, 1000, 2000] as const; -/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */ +/** `formatMegabytes` in the reader's language. */ function useMegabytes() { const { language } = useTranslation(); - return (mb: number) => - new Intl.NumberFormat(language, { - style: 'unit', - unit: mb >= 1000 ? 'gigabyte' : 'megabyte', - maximumFractionDigits: mb >= 1000 ? 1 : 0, - }).format(mb >= 1000 ? mb / 1000 : mb); + return (mb: number) => formatMegabytes(mb, language); } const MB = 1024 * 1024; @@ -36,9 +29,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) { const megabytes = useMegabytes(); const { settings, saving, save } = admin.autoLoad; if (!settings) return null; - const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number]) - ? SIZES - : [...SIZES, settings.max_mb].sort((a, b) => a - b); + const sizes = autoLoadSizes(settings.max_mb); return (
@@ -49,7 +40,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) { onChange={(mode) => void save({ mode })} label={t('admin.paddleRegions.auto.title')} fill - options={MODES.map((mode) => { + options={AUTO_LOAD_MODES.map((mode) => { const Icon = MODE_ICONS[mode]; return { value: mode, diff --git a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx index eaf16d784..9982a3f7b 100644 --- a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx +++ b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx @@ -25,8 +25,8 @@ type Admin = ReturnType; * PAD-05A in the admin area: the regions TREK reads paddle obstacles for, * picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a * place a paddler plans in by itself, with the regions that wait for an admin. - * Both the desktop and the phone admin show this one card under the addons, - * while the Tours addon is on. + * The desktop admin shows this card under the addons while the Tours addon + * is on; the phone has MAdminPaddleRegionsPanel on the same hook. */ export default function PaddleRegionsPanel() { return ( diff --git a/client/src/components/Admin/paddle/paddleRegionsModel.ts b/client/src/components/Admin/paddle/paddleRegionsModel.ts index fb00acc98..83a512f92 100644 --- a/client/src/components/Admin/paddle/paddleRegionsModel.ts +++ b/client/src/components/Admin/paddle/paddleRegionsModel.ts @@ -1,4 +1,25 @@ -import type { PaddleRegion } from '@trek/shared'; +import type { PaddleRegion, PaddleRegionAutoMode } from '@trek/shared'; + +/** How TREK loads a paddler's region by itself, in the order the switch shows them. */ +export const AUTO_LOAD_MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off']; +/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */ +export const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const; + +/** The size choices, with a stored size outside the presets kept among them in its place. */ +export function autoLoadSizes(current: number): number[] { + return (AUTO_LOAD_SIZES as readonly number[]).includes(current) + ? [...AUTO_LOAD_SIZES] + : [...AUTO_LOAD_SIZES, current].sort((a, b) => a - b); +} + +/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */ +export function formatMegabytes(mb: number, language: string): string { + return new Intl.NumberFormat(language, { + style: 'unit', + unit: mb >= 1000 ? 'gigabyte' : 'megabyte', + maximumFractionDigits: mb >= 1000 ? 1 : 0, + }).format(mb >= 1000 ? mb / 1000 : mb); +} /** A region waiting for its read or being read: the list asks again until none is. */ export function isReading(region: Pick): boolean { diff --git a/client/src/components/Journey/JourneyLayeredMap.test.tsx b/client/src/components/Journey/JourneyLayeredMap.test.tsx index de1876e83..25fde6c8b 100644 --- a/client/src/components/Journey/JourneyLayeredMap.test.tsx +++ b/client/src/components/Journey/JourneyLayeredMap.test.tsx @@ -89,6 +89,12 @@ describe('JourneyLayeredMap', () => { expect(last().photos).toBeUndefined(); }); + it('FE-JOURNEY-LAYEREDMAP-002b: the phone draws the layers without their switches', () => { + render(); + expect(last()).toMatchObject({ marks: [MARK], layerVisibility: ALL_VISIBLE }); + expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull(); + }); + it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => { const onOpenEntry = vi.fn(); const { rerender } = render( diff --git a/client/src/components/Journey/JourneyLayeredMap.tsx b/client/src/components/Journey/JourneyLayeredMap.tsx index fe2196089..a9223514f 100644 --- a/client/src/components/Journey/JourneyLayeredMap.tsx +++ b/client/src/components/Journey/JourneyLayeredMap.tsx @@ -20,8 +20,8 @@ interface Props extends MapProps { layers?: JourneyMapLayersView | null; /** A shared page and the phone timeline draw with Leaflet whatever a setting says. */ engine?: 'auto' | 'leaflet'; - /** Icon-only switches, for a phone. */ - compactControls?: boolean; + /** No layer switches: the phone keeps its map clear and shows every layer (the picked card still opens). */ + hideLayerControl?: boolean; /** How far from the map's top the switches sit, clear of a bar laid over the map. */ controlsTop?: number | string; /** Leads from a picked place or stay to its entry. */ @@ -40,7 +40,7 @@ export default function JourneyLayeredMap({ ref, layers, engine = 'auto', - compactControls = false, + hideLayerControl = false, controlsTop = 12, onOpenEntry, entryTitleOf, @@ -88,9 +88,11 @@ export default function JourneyLayeredMap({ className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2" style={{ top: controlsTop }} > -
- -
+ {!hideLayerControl && ( +
+ +
+ )} {card && ( { }); }); + it('FE-JOURNEY-MAPLAYERS-HOOK-002b: the phone shows every layer whatever was kept, and keeps no switch', () => { + vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({ + places: { status: 'ok', items: [] }, + routes: { status: 'ok', items: [] }, + stays: { status: 'ok', items: [] }, + }); + localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false })); + const { result } = renderHook(() => + useJourneyMapLayers({ + source: { journeyId: 8 }, + entryIdsOnMap: NO_ENTRIES, + photoCount: 0, + colorOfDay, + dateOfEntry, + allLayers: true, + }) + ); + + expect(result.current.visibility).toMatchObject({ plan: true, entries: true, recorded: true, photos: true }); + act(() => result.current.toggle('photos')); + expect(result.current.visibility.photos).toBe(false); + expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toEqual({ + plan: false, + entries: false, + }); + }); + it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => { vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down')); const recorded: JourneyTrack = { diff --git a/client/src/components/Journey/useJourneyMapLayers.ts b/client/src/components/Journey/useJourneyMapLayers.ts index 020a102b2..69c665b26 100644 --- a/client/src/components/Journey/useJourneyMapLayers.ts +++ b/client/src/components/Journey/useJourneyMapLayers.ts @@ -50,6 +50,12 @@ interface Options { colorOfDay: (date: string) => string | null; /** The day an entry is on, for the colour of its stays. */ dateOfEntry: (entryId: number) => string | null; + /** + * Every layer on, whatever this browser kept: the phone shows no switches, + * so a layer once switched off there would stay off with no way back. A + * switch that is still offered then holds only for the visit. + */ + allLayers?: boolean; } const NO_TRACKS: JourneyTrack[] = []; @@ -119,13 +125,14 @@ export function useJourneyMapLayers({ recordedRead = 'ready', colorOfDay, dateOfEntry, + allLayers = false, }: Options): JourneyMapLayersView { const { t, locale } = useTranslation(); const journeyId = source && 'journeyId' in source ? source.journeyId : null; const token = source && 'token' in source ? source.token : null; const storageKey = journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null; - const { visibility, toggle } = useLayerSwitches(storageKey); + const { visibility, toggle } = useLayerSwitches(allLayers ? null : storageKey); const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null; // A shared page reads once; a journey's own map only while the plan or the entries are shown. diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 89e338b88..6abe9e1f8 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -1108,77 +1108,3 @@ describe('TripMembersModal', () => { view.unmount(); }); }); - -describe('TripMembersModal on a phone', () => { - let width: number; - beforeEach(() => { - width = window.innerWidth; - Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true }); - }); - afterEach(() => { - Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true }); - }); - - it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => { - seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); - server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ - token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, - }))); - render(); - - expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true'); - }); - - it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => { - render(); - expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument(); - expect(screen.getByText(/Access \(1/)).toBeInTheDocument(); - }); - - it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => { - const user = userEvent.setup(); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); - let deletes = 0; - server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); })); - render(); - - await user.click(await screen.findByTitle('Remove access')); - expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?'); - expect(deletes).toBe(0); - - confirmSpy.mockReturnValue(true); - await user.click(screen.getByTitle('Remove access')); - await waitFor(() => expect(deletes).toBe(1)); - }); - - it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => { - const user = userEvent.setup(); - vi.spyOn(window, 'confirm').mockReturnValue(true); - mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]); - let renamed: string | null = null; - let deleted = false; - server.use( - http.put('/api/trips/1/guests/:userId', async ({ request }) => { - renamed = ((await request.json()) as { name: string }).name; - return HttpResponse.json({ success: true }); - }), - http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }), - ); - render(); - - await user.click(await screen.findByTitle('Rename')); - const input = screen.getByDisplayValue('Grandma'); - fireEvent.keyDown(input, { key: 'Escape' }); - await user.click(screen.getByTitle('Rename')); - fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } }); - fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' }); - await waitFor(() => expect(renamed).toBe('Granny')); - - await user.click(screen.getByTitle('Remove access')); - await waitFor(() => expect(deleted).toBe(true)); - }); -}); diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index d02e8120c..8ff93705b 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -1,148 +1,4 @@ -import { TripJournalLinkSection } from './TripJournalLinkSection' -import Modal from '../shared/Modal' -import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react' -import { useTranslation } from '../../i18n' -import CustomSelect from '../shared/CustomSelect' -import { useIsPhone } from '../../mobile/useIsPhone' -import { TripMemberAvatar as Avatar } from './TripMemberAvatar' import TripShareDialog from './TripShareDialog' -import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers } from './useTripShare' - -function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { - const { loading, url: shareUrl, perms, copied, create: handleCreate, setPerm, remove: handleDelete, copy: handleCopy } = useShareLink(tripId) - const handleUpdatePerms = (key: string, val: boolean) => setPerm(key as Parameters[0], val) - - if (loading) return null - - return ( -
-
- - {t('share.linkTitle')} -
-

{t('share.linkHint')}

- - {/* Permission checkboxes */} -
- {[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => ( - - ))} -
- - {shareUrl ? ( -
-
- - -
- -
- ) : ( - - )} -
- ) -} - -/** - * Trip invite link (#1143). One rotating token per trip that an existing, - * logged-in user opens to join the trip as a member. Mirrors ShareLinkSection - * but the link points at /join/:token (login-required, no registration). - */ -function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { - const { loading, url: inviteUrl, busy, copied, create, remove, copy } = useTripInviteLink(tripId) - - if (loading) return null - - return ( -
-
- - {t('trip.invite.linkTitle')} -
-

{t('trip.invite.linkHint')}

- - {inviteUrl ? ( -
-
- - -
-
- - -
-
- ) : ( - - )} -
- ) -} interface TripMembersModalProps { isOpen: boolean @@ -154,259 +10,7 @@ interface TripMembersModalProps { onMembersChanged?: () => void } -/** Share: the new dialog on the desktop; the phone, which opens this from its More sheet, keeps the sheet below. */ +/** Share: the trip's members, guests and links. The phone has its own sheet (MTripSettingsSheet) on the same hooks. */ export default function TripMembersModal(props: TripMembersModalProps) { - const isPhone = useIsPhone() - return isPhone ? : -} - -function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) { - const { t } = useTranslation() - const m = useTripMembers({ isOpen, tripId, onClose, onMembersChanged }) - const { - user, loading, realMembers, guests, allUsers, availableUsers, isCurrentOwner, canManageMembers, canManageShare, - selectedUserId, setSelectedUserId, adding, transferringId, removingId, - newGuestName, setNewGuestName, addingGuest, renamingGuestId, renameValue, setRenameValue, - } = m - const handleAdd = () => void m.add() - const handleAddGuest = () => void m.addGuest() - const handleRenameGuest = (userId: number) => void m.commitRename(userId) - const handleTransfer = (newOwnerId: number, username: string) => { - if (confirm(t('members.confirmTransfer', { name: username }))) void m.transfer(newOwnerId) - } - const handleRemove = (userId: number, isSelf: boolean) => { - if (confirm(isSelf ? t('members.confirmLeave') : t('members.confirmRemove'))) void m.remove(userId, isSelf) - } - const handleDeleteGuest = (userId: number) => { - if (confirm(t('members.confirmRemoveGuest'))) void m.removeGuest(userId) - } - - return ( - -
- - - {/* Left column: Members */} -
- - {/* Trip name */} -
-
{t('nav.trip')}
-
{tripTitle}
-
- - {/* Add member dropdown */} - {canManageMembers &&
- -
- setSelectedUserId(String(value))} - placeholder={t('members.selectUser')} - options={[ - { value: '', label: t('members.selectUser') }, - // As a string: the select compares values strictly, and the chosen id is - // kept as a string, so a number here left the trigger on its placeholder. - ...availableUsers.map(u => ({ - value: String(u.id), - label: u.username, - })), - ]} - searchable - style={{ flex: 1 }} - size="sm" - /> - -
- {availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && ( -

{t('members.allHaveAccess')}

- )} -
} - - {/* Members list */} -
-
- - - {t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })}) - -
- - {loading ? ( -
- {[1, 2].map(i => ( -
- ))} -
- ) : ( -
- {realMembers.map(member => { - const isSelf = member.id === user?.id - const canRemove = isSelf || (canManageMembers && member.role !== 'owner') - return ( -
- -
-
- {member.username} - {isSelf && ({t('members.you')})} - {member.role === 'owner' && ( - - {t('members.owner')} - - )} -
-
- {isCurrentOwner && member.role !== 'owner' && ( - - )} - {canRemove && ( - - )} -
- ) - })} -
- )} -
- - {/* Guests (#1362) — accountless participants, managed by the owner */} - {(isCurrentOwner || guests.length > 0) && ( -
-
- - - {t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''} - -
-

{t('members.guestsHint')}

- -
- {guests.map(g => ( -
- - {renamingGuestId === g.id ? ( - setRenameValue(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') m.cancelRename() }} - onBlur={() => handleRenameGuest(g.id)} - maxLength={50} - className="bg-surface border border-edge text-content" - style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }} - /> - ) : ( -
- {g.username} - - {t('members.guest')} - -
- )} - {isCurrentOwner && renamingGuestId !== g.id && ( - <> - - - - )} -
- ))} -
- - {isCurrentOwner && ( -
0 ? 8 : 0 }}> - setNewGuestName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }} - placeholder={t('members.guestNamePlaceholder')} - maxLength={50} - className="bg-surface border border-edge text-content" - style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }} - /> - -
- )} -
- )} - -
- - {/* Right column: Share Link */} - {canManageShare &&
- - -
} - - - - -
- - ) + return } diff --git a/client/src/mobile/components/MPlanOnDesktopBadge.tsx b/client/src/mobile/components/MPlanOnDesktopBadge.tsx new file mode 100644 index 000000000..277d81c8b --- /dev/null +++ b/client/src/mobile/components/MPlanOnDesktopBadge.tsx @@ -0,0 +1,26 @@ +import { Monitor } from 'lucide-react'; +import { useTranslation } from '../../i18n'; + +/** + * The one way the phone says "this is planned on a bigger screen": the road + * trip's empty stage and the tours list both carry it, so the sentence around + * it only has to say what the phone does instead. + */ +export default function MPlanOnDesktopBadge({ className = '' }: { className?: string }) { + const { t } = useTranslation(); + return ( + + + + {t('mobileTrip.planOnDesktop')} + + + ); +} diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index f7eb6a799..69973a011 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -1,5 +1,5 @@ -import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog' -import PaddleRegionsPanel from '../../../components/Admin/paddle/PaddleRegionsPanel' +import MAdminSchoolHolidayCatalog from './MAdminSchoolHolidayCatalog' +import MAdminPaddleRegionsPanel from './MAdminPaddleRegionsPanel' import { useState } from 'react' import { ArrowUp, @@ -206,7 +206,7 @@ export default function MAdmin() {
- +
)} {activeTab === 'addons' && ( @@ -217,7 +217,7 @@ export default function MAdmin() { collabFeatures={collabFeatures} onToggleCollabFeature={toggleCollabFeature} /> - +
)} {activeTab === 'plugins' && } diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 25f765ccc..2cfdef8a1 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -23,7 +23,7 @@ import { import { useAddonManager } from '../../../components/Admin/addons/useAddonManager' import { useLlmParsingConfig } from '../../../components/Admin/addons/useLlmParsingConfig' import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons' -import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' +import { MAdminButton, MAdminCard, MAdminClampedText, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' import WandererMark from '../../../components/Tours/wanderer/WandererMark' // Keys are the `icon` column from the addons table; anything unknown falls back to @@ -82,9 +82,10 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Header */}
{t('admin.addons.title')}
-

- {t('admin.addons.subtitleBefore')} - TREK + {/* One run of text with the wordmark inside it, so a line break falls between words and never around the logo. */} +

+ {t('admin.addons.subtitleBefore')}{' '} + TREK{' '} {t('admin.addons.subtitleAfter')}

@@ -100,7 +101,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Trip Addons */} {tripAddons.length > 0 && ( - + {tripAddons.map((addon, i) => (
@@ -157,7 +158,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Global Addons */} {globalAddons.length > 0 && ( - + {globalAddons.map((addon, i) => (
@@ -171,7 +172,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Integration Addons */} {integrationAddons.length > 0 && ( - + {integrationAddons.map((addon, i) => (
@@ -191,11 +192,14 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac } /** Group subheader: small icon + uppercase label, matches the desktop section band. */ -function MGroupHead({ icon: Icon, label }: { icon: typeof Briefcase; label: string }) { +function MGroupHead({ icon: Icon, label, hint }: { icon: typeof Briefcase; label: string; hint: string }) { return ( -
- - {label} +
+
+ + {label} +
+

{hint}

) } @@ -225,7 +229,7 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) { {typeLabel}
-

{label.description}

+ {label.description}
onToggle(addon)} />
@@ -268,7 +272,7 @@ function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enab
{title}
-
{subtitle}
+ {subtitle}
diff --git a/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx new file mode 100644 index 000000000..cc0632fe2 --- /dev/null +++ b/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx @@ -0,0 +1,454 @@ +import type { PaddleRegion, PaddleRegionPending } from '@trek/shared'; +import { + Ban, + Check, + ChevronRight, + CloudDownload, + Hand, + Loader2, + Plus, + Power, + RefreshCw, + Search, + Sparkles, + Trash2, + Waves, + WifiOff, +} from 'lucide-react'; +import { + AUTO_LOAD_MODES, + autoLoadSizes, + formatMegabytes, + progressPercent, + regionStatusTone, +} from '../../../components/Admin/paddle/paddleRegionsModel'; +import { usePaddleRegionsAdmin } from '../../../components/Admin/paddle/usePaddleRegionsAdmin'; +import { useTranslation } from '../../../i18n'; +import MBadge from '../../components/MBadge'; +import MChip from '../../components/MChip'; +import MConfirmSheet from '../settings/MConfirmSheet'; +import { MAdminButton, MAdminCard, MAdminClampedText } from './MAdminUi'; + +type Admin = ReturnType; + +const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const; +const MB = 1024 * 1024; +const LABEL = + 'mb-1.5 block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint'; +const ROWS = + 'flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'; +const ROUND = + 'flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] text-m-muted disabled:opacity-40'; +const TONE = { + neutral: '', + accent: '!text-[color:var(--m-st-info)]', + success: '!text-[color:var(--m-st-confirmed)]', + danger: '!text-[color:var(--m-st-danger)]', +} as const; + +/** How TREK loads a paddler's region by itself, and up to which size. */ +function AutoLoad({ admin }: { admin: Admin }) { + const { t, language } = useTranslation(); + const { settings, saving, save } = admin.autoLoad; + if (!settings) return null; + return ( +
+ {t('admin.paddleRegions.auto.title')} +
+ {AUTO_LOAD_MODES.map((mode) => { + const Icon = MODE_ICONS[mode]; + const active = settings.mode === mode; + return ( + + ); + })} +
+ + {t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)} + + {settings.mode === 'auto' && ( +
+ {t('admin.paddleRegions.auto.maxSize')} +
+ {autoLoadSizes(settings.max_mb).map((mb) => ( + { + if (!saving) void save({ max_mb: mb }); + }} + > + {formatMegabytes(mb, language)} + + ))} +
+ + {t('admin.paddleRegions.auto.maxSizeHint')} + +
+ )} +
+ ); +} + +function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) { + const { t, language } = useTranslation(); + const busy = admin.busyId === request.id; + return ( +
+
+ {request.name} +
+
+ {request.declined ? ( + } className={TONE.danger}> + {t('admin.paddleRegions.requests.declined')} + + ) : ( + + {t(`admin.paddleRegions.requests.reason.${request.reason}`)} + + )} + {request.bytes !== null && ( + {formatMegabytes(Math.max(1, Math.round(request.bytes / MB)), language)} + )} + {request.requested_by && ( + {t('admin.paddleRegions.requests.by', { user: request.requested_by })} + )} + + {new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' })} + +
+
+ {!request.declined && ( + void admin.decline(request.id)}> + {t('admin.paddleRegions.requests.decline')} + + )} + void admin.approve(request.id)}> + {!busy && } + {t('admin.paddleRegions.requests.approve')} + +
+
+ ); +} + +function Requests({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + if (admin.requests.length === 0) return null; + const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined)); + const open = admin.requests.filter((request) => !request.declined).length; + return ( +
+ + {t('admin.paddleRegions.requests.title')} + {open > 0 && ( + + {open} + + )} + +
+ {ordered.map((request) => ( + + ))} +
+
+ ); +} + +function CatalogSearch({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + const { search } = admin; + return ( +
+ {t('admin.paddleRegions.search')} +
+
+ {search.searching && ( +

+ + {t('common.loading')} +

+ )} + {search.error && ( +

+ {search.error} +

+ )} + {search.hits && search.hits.length === 0 && ( +

+ {t('admin.paddleRegions.noHits')} +

+ )} + {search.hits && search.hits.length > 0 && ( +
+ {search.hits.map((hit) => ( +
+
+
+ {hit.name} +
+ {hit.path.length > 0 && ( +
+ {hit.path.map((part, index) => ( + + {index > 0 && + ))} +
+ )} +
+ {admin.added.has(hit.id) ? ( + }> + {t('admin.paddleRegions.added')} + + ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} + +function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { + const { t, language } = useTranslation(); + const busy = admin.busyId === region.id; + const percent = progressPercent(region); + const status = + region.status === 'importing' && region.progress + ? t(`admin.paddleRegions.phase.${region.progress.phase}`) + : t(`admin.paddleRegions.status.${region.status}`); + const date = region.source_timestamp + ? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' }) + : null; + const km = region.network + ? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km) + : null; + return ( +
+ +
+
+ {region.name} +
+
+ : undefined} + > + {status} + {percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`} + + {region.origin === 'auto' && ( + }> + {t('admin.paddleRegions.origin.auto')} + + )} + {region.imported_at && ( + {t('admin.paddleRegions.objects', { count: region.feature_count })} + )} + {region.imported_at && date && {t('admin.paddleRegions.asOf', { date })}} + {km !== null && {t('admin.paddleRegions.network.km', { km })}} + {!region.network && region.imported_at && region.status !== 'importing' && ( + + {t('admin.paddleRegions.network.missing')} + + )} +
+ {region.status === 'failed' && region.error && ( +

+ {region.error} + {region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''} +

+ )} +
+ + +
+ ); +} + +function Regions({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + if (admin.loading) { + return ( +

+ + {t('common.loading')} +

+ ); + } + if (admin.regions.length === 0) { + return ( +

+ {t('admin.paddleRegions.empty')} +

+ ); + } + return ( +
+ {admin.regions.map((region) => ( + + ))} +
+ ); +} + +/** + * PAD-05A/PAD-11C on the phone: the regions TREK reads paddle data for, how it + * loads a paddler's region by itself and the regions that wait for an admin. + * usePaddleRegionsAdmin is the desktop card's state; this is only the markup. + */ +export default function MAdminPaddleRegionsPanel() { + const { t } = useTranslation(); + const admin = usePaddleRegionsAdmin(); + if (!admin.toursOn) return null; + return ( + +
+ + + +
+
+ + {t('admin.paddleRegions.title')} + + {admin.regions.length > 0 && {admin.regions.length}} +
+ + {t('admin.paddleRegions.hint')} + +
+
+ + {admin.offline && ( +

+ + {t('admin.paddleRegions.offline')} +

+ )} + {admin.error && !admin.offline && ( +
+ + {admin.error} + + + {t('admin.paddleRegions.retry')} + +
+ )} + +
+ + + + +
+

+ {t('admin.paddleRegions.attribution')} +

+ + +
+ ); +} diff --git a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx index 7a85a3222..94ce9066c 100644 --- a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx @@ -21,23 +21,21 @@ export default function MAdminPermissionsPanel(): React.ReactElement { return ( -
-
-
{t('perm.title')}
-
{t('perm.subtitle')}
+ {/* The title gets the full width and the actions a row of their own: beside a + long title they ran into its second line. */} +
+
{t('perm.title')}
+
{t('perm.subtitle')}
+
+ + + {t('perm.resetDefaults')} + + + {!saving && } + {t('common.save')} +
- - - - - {!saving && } - {t('common.save')} -
diff --git a/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx new file mode 100644 index 000000000..e603b9a73 --- /dev/null +++ b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx @@ -0,0 +1,431 @@ +import { + schoolHolidayRegionRequestSchema, + type SchoolHolidayRegionDetail, + type SchoolHolidayRegionRequest, +} from '@trek/shared'; +import { ChevronRight, GraduationCap, Loader2, MapPinned, Plus, Trash2, WifiOff } from 'lucide-react'; +import { useState } from 'react'; +import { useSchoolHolidayCatalog } from '../../../components/Admin/useSchoolHolidayCatalog'; +import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'; +import CustomSelect from '../../../components/shared/CustomSelect'; +import { useTranslation } from '../../../i18n'; +import MBadge from '../../components/MBadge'; +import MSheet from '../../components/MSheet'; +import MConfirmSheet from '../settings/MConfirmSheet'; +import { MAdminButton, MAdminCard, MAdminClampedText, MAdminInput, MAdminSheetFrame } from './MAdminUi'; + +const LABEL = + 'mb-1.5 block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint'; +const ROUND = + 'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40'; +const ALERT = + 'rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]'; + +/** A new country of the catalog: its name and its two-letter code. */ +function AddCountrySheet({ + open, + onClose, + onSave, +}: { + open: boolean; + onClose: () => void; + onSave: (code: string, name: string) => Promise; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const valid = name.trim().length > 0 && /^[A-Z]{2}$/.test(code); + const close = () => { + setName(''); + setCode(''); + onClose(); + }; + return ( + + + + {t('common.cancel')} + + void onSave(code, name.trim()).then(close)}> + {t('common.save')} + + + } + > +
+ + +
+
+
+ ); +} + +interface EditorProps { + region: SchoolHolidayRegionDetail; + busy: boolean; + offline: boolean; + error: string; + onClose: () => void; + onSave: (body: SchoolHolidayRegionRequest) => void; +} + +/** A region's holidays, the phone's own sheet over the same request the desktop editor sends. */ +function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorProps) { + const { t } = useTranslation(); + const [name, setName] = useState(region.name); + const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index }))); + const [nextKey, setNextKey] = useState(holidays.length); + const [invalid, setInvalid] = useState(false); + const body = { + name, + revision: region.revision, + holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })), + }; + const edit = (key: number, change: Partial<(typeof holidays)[number]>) => + setHolidays((rows) => rows.map((row) => (row.key === key ? { ...row, ...change } : row))); + const save = () => { + const parsed = schoolHolidayRegionRequestSchema.safeParse(body); + setInvalid(!parsed.success); + if (parsed.success) onSave(parsed.data); + }; + const locked = busy || offline; + return ( + { + if (!busy) onClose(); + }} + variant="card" + material="opaque" + ariaLabel={name.trim() || t('schoolCatalog.region')} + discardGuard={body} + > + { + if (!busy) onClose(); + }} + footer={ + <> + + {t('common.cancel')} + + + {t('common.save')} + + + } + > +
+ + + {t('schoolCatalog.periodHint')} + + {holidays.map((holiday, index) => ( +
+
+ edit(holiday.key, { name: e.target.value })} + className="bg-[color:var(--m-sheetop)]" + /> + +
+
+
+ {t('schoolCatalog.start')} + edit(holiday.key, { startDate })} + /> +
+
+ {t('schoolCatalog.end')} + edit(holiday.key, { endDate })} + /> +
+
+
+ ))} + + {invalid && ( +

+ {t('schoolCatalog.invalid')} +

+ )} + {error && ( +

+ {error} +

+ )} +
+
+
+ ); +} + +/** + * The school holiday catalog on the phone: the shared holiday regions per + * country that everyone can pick in their vacation settings. The state is + * useSchoolHolidayCatalog's, the desktop card's; this is the phone's markup. + */ +export default function MAdminSchoolHolidayCatalog() { + const { t } = useTranslation(); + const state = useSchoolHolidayCatalog(); + const [country, setCountry] = useState(''); + const [adding, setAdding] = useState(false); + const [remove, setRemove] = useState<{ label: string; action: () => Promise } | null>(null); + const selectedCountry = + state.catalog.countries.find((item) => item.code === country)?.code || state.catalog.countries[0]?.code || ''; + const regions = state.catalog.regions.filter((region) => region.country === selectedCountry); + const disabled = state.busy || state.loading || state.offline; + + return ( + +
+ + + +
+
+ + {t('schoolCatalog.title')} + + {state.catalog.countries.length > 0 && {state.catalog.regions.length}} +
+ + {t('schoolCatalog.hint')} + +
+
+ +
+ {state.offline && ( +

+ + {t('schoolCatalog.offline')} +

+ )} + {state.loading && ( +

+ + {t('common.loading')} +

+ )} + {state.error && !state.editor && ( +
+ + {state.error} + + void state.refresh()}> + {t('schoolCatalog.retry')} + +
+ )} + + {state.catalog.countries.length > 0 ? ( +
+ {t('schoolCatalog.country')} +
+ setCountry(String(value))} + searchable + disabled={disabled} + size="sm" + options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))} + placeholder={t('schoolCatalog.country')} + style={{ flex: 1, minWidth: 0 }} + /> + + +
+ {regions.length > 0 && ( +

+ {t('schoolCatalog.deleteHint')} +

+ )} +
+ ) : ( + !state.loading && ( + setAdding(true)} className="self-start"> + + {t('schoolCatalog.addCountry')} + + ) + )} + + {selectedCountry && ( + <> + {regions.length === 0 ? ( +

+ {t('schoolCatalog.empty')} +

+ ) : ( +
+ {regions.map((region) => ( +
+ + +
+ ))} +
+ )} + + + )} +
+ + setAdding(false)} + onSave={async (code, name) => { + if (await state.createCountry({ code, name })) setCountry(code); + }} + /> + {state.editor && ( + state.setEditor(null)} + onSave={(body) => { + if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body); + }} + /> + )} + setRemove(null)} + title={t('common.delete')} + message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + danger + onConfirm={() => { + if (remove) void remove.action(); + setRemove(null); + }} + /> +
+ ); +} diff --git a/client/src/mobile/screens/admin/MAdminUi.tsx b/client/src/mobile/screens/admin/MAdminUi.tsx index 1f1a52a67..774e0ee0a 100644 --- a/client/src/mobile/screens/admin/MAdminUi.tsx +++ b/client/src/mobile/screens/admin/MAdminUi.tsx @@ -60,6 +60,25 @@ export function MAdminRow({ ) } +/** + * A description that keeps to two lines until it is tapped. On a phone the long + * ones (a map provider's terms, how a loader decides) pushed every control off + * screen; the whole text stays one tap away. + */ +export function MAdminClampedText({ children, className = '' }: { children: string; className?: string }) { + const [open, setOpen] = useState(false) + return ( + + ) +} + /** Labelled form field with an optional hint line under the control. */ export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) { return ( diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index d0e4b93dd..5aa655afb 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useRef, useState } from 'react' -import { Archive, ArchiveRestore, Camera, Search, X } from 'lucide-react' +import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react' import { useTranslation } from '../../../i18n' import { useCanDo } from '../../../store/permissionsStore' import { useSettingsStore } from '../../../store/settingsStore' +import { useAuthStore } from '../../../store/authStore' +import MChip from '../../components/MChip' import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker' import CustomSelect from '../../../components/shared/CustomSelect' import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants' import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel' -import { type Trip, type TripCreateRequest } from '@trek/shared' +import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' import MListRow from '../../components/MListRow' @@ -29,6 +31,9 @@ interface MNewTripSheetProps { onArchive?: () => void } +/** The reminder presets the desktop form offers; anything else is a custom number of days. */ +const REMINDER_PRESETS = [0, 1, 3, 9] + function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement { return (
{children}
@@ -37,7 +42,9 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle /** * Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow: - * title, date range and Unsplash cover search (plus device upload). Archiving + * title, date range (or a day count without one), the reminder and the + * Unsplash cover search (plus device upload). The trip planner's More sheet + * opens it for editing too. Archiving * lives here in edit mode, as decided for the grid cards. */ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement { @@ -48,12 +55,19 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda const fileRef = useRef(null) const canEditTrip = !isEditing || can('trip_edit', trip) const canUploadCover = !isEditing || can('trip_cover_upload', trip) + const currentUser = useAuthStore(s => s.user) + const remindersEnabled = useAuthStore(s => s.tripRemindersEnabled) + // As on the desktop: the reminder is the owner's (or an admin's), and set when creating. + const showReminder = !isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin' const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') const [currency, setCurrency] = useState('EUR') + const [dayCount, setDayCount] = useState(7) + const [reminderDays, setReminderDays] = useState(0) + const [customReminder, setCustomReminder] = useState(false) const [error, setError] = useState('') const [isSaving, setIsSaving] = useState(false) const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true }) @@ -71,6 +85,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setStartDate(trip?.start_date || '') setEndDate(trip?.end_date || '') setCurrency(trip?.currency || defaultCurrency) + setDayCount(trip?.day_count || 7) + const reminder = trip ? (trip.reminder_days ?? 3) : (remindersEnabled ? 3 : 0) + setReminderDays(reminder) + setCustomReminder(!REMINDER_PRESETS.includes(reminder)) cover.resetCover(trip?.cover_image || null) setError('') setPendingRemoval(null) @@ -89,13 +107,21 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setError('') const formError = tripFormError({ title, startDate, endDate }, trip, t) if (formError) { setError(formError); return null } + const dateless = !startDate && !endDate + if (dateless && (dayCount === '' || dayCount < 1 || dayCount > MAX_TRIP_DAYS)) { + setError(t('dashboard.dayCountRequired')); return null + } + // The count goes along only when it says something new: the trip's own one + // goes stale in the planner, and sending it back would trim a day just added. + const sendDayCount = dateless && (!isEditing || dayCount !== trip?.day_count) return { title: title.trim(), description: description.trim() || null, start_date: startDate || null, end_date: endDate || null, currency, - ...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}), + ...(showReminder ? { reminder_days: reminderDays } : {}), + ...(sendDayCount ? { day_count: Number(dayCount) } : {}), } } @@ -197,8 +223,28 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda />
- {!isEditing && !startDate && !endDate && ( -
{t('dashboard.noDateHint')}
+ {!startDate && !endDate && ( + <> +
+ {t('dashboard.dayCount')} + { + if (!canEditTrip) return + setError('') + const raw = e.target.value + setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1))) + }} + readOnly={!canEditTrip} + className={inputCls} + /> +
+ {!isEditing &&
{t('dashboard.noDateHint')}
} + )}
@@ -214,6 +260,47 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda />
+ {showReminder && ( +
+ + {t('trips.reminder')} + + {!remindersEnabled ? ( +

{t('trips.reminderDisabledHint')}

+ ) : ( + <> +
+ {REMINDER_PRESETS.map(days => ( + { setError(''); setReminderDays(days); setCustomReminder(false) }}> + {days === 0 ? t('trips.reminderNone') : `${days} ${t(days === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`} + + ))} + { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}> + {t('trips.reminderCustom')} + +
+ {customReminder && ( +
+ setReminderDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))} + aria-label={t('trips.reminderDaysBefore')} + className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none" + /> + {t('trips.reminderDaysBefore')} +
+ )} + + )} +
+ )} + {canUploadCover && (
) { + return (key: string, params?: Record) => { + const value = table[key] ?? en[key] + if (typeof value !== 'string') return key + return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? '')) + } +} + +function planner(overrides: Partial = {}): TripPlanner { return { - t: (key: string) => { - const value = en[key] - return typeof value === 'string' ? value : key - }, + t: translate(en), tripId: 7, tours: [tour], + places: [{ id: 42, name: tour.name, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' }], selectedPlaceId: null, - selectedPlace: null, - selectedTour: null, - selectedDayId: null, - selectedAssignmentId: null, - assignments: {}, - days: [], - files: [], - canUploadFiles: false, - reloadTourPlaceIds, + days: [day], handlePlaceClick, + ...overrides, } as unknown as TripPlanner } -function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi { - return { - sheet, - openSheet, - } as unknown as MTripShellApi +function shell(): MTripShellApi { + return { sheet: null, openSheet } as unknown as MTripShellApi } beforeEach(() => { @@ -73,84 +71,72 @@ beforeEach(() => { }) describe('MToursSelectionList', () => { - it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => { - const germanPlanner = { ...planner(), t: (key: string) => { - const value = de[key] ?? en[key] - return typeof value === 'string' ? value : key - } } - const { rerender } = render() + it('lists a tour like a place: its grade tile, its name, its figures and a plus to put it on a day', () => { + render() - expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument() - expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern') - expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument() - rerender() - expect(screen.getByText('Noch keine Touren')).toBeInTheDocument() - expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument() - expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/) - }) + expect(screen.getByRole('img', { name: en['tours.planner.difficulty.t2'] as string })).toHaveTextContent('T2') + expect(screen.getByText(tour.name)).toHaveClass('truncate') + expect(screen.getByTestId('mobile-tour-metrics')).toHaveTextContent('12.5 km') + expect(screen.getByTestId('mobile-tour-metrics')).toHaveTextContent('420 m') + expect(screen.getByText('1 tour')).toBeInTheDocument() - it('gives a tour name the full row and up to two lines', () => { - render() - - const name = screen.getByText(tour.name) - expect(name).toHaveClass('line-clamp-2', 'break-words') - expect(name).toHaveAttribute('title', tour.name) - expect(name).not.toHaveClass('truncate') - }) - - it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => { - const { container } = render() - expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true') - const metrics = screen.getByTestId('mobile-tour-metrics') - expect(metrics).toHaveTextContent('12.5 km') - expect(metrics).toHaveTextContent('420 m') - expect(metrics).toHaveTextContent('390 m') - for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap') - expect(metrics.children[0].getAttribute('aria-label')).toContain('12.5 km') - expect(metrics.children[1].getAttribute('aria-label')).toContain('420 m') - expect(metrics.children[2].getAttribute('aria-label')).toContain('390 m') - expect(screen.getByText('TREK')).toBeInTheDocument() - expect(screen.getByTitle(en['tours.planner.difficulty.t2'] as string)).toHaveAttribute('aria-label', en['tours.planner.difficulty.t2']) - const add = screen.getByRole('button', { name: 'Add to day' }) - expect(add).toHaveClass('w-full') - add.click() + fireEvent.click(screen.getByRole('button', { name: 'Add to day' })) expect(openSheet).toHaveBeenCalledWith('bract', { placeId: tour.place_id, dayPicker: true }) - expect(container.querySelector('[data-testid="mobile-tour-metrics"]')).toBeTruthy() - expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() }) - it('keeps a long German name and source/difficulty labels intact', () => { - const germanPlanner = { - ...planner(), - tours: [{ ...tour, name: 'Eine sehr lange deutsche Wanderung durch das abgelegene Gebirge', has_waypoints: false }], - t: (key: string) => { - const value = de[key] ?? en[key] - return typeof value === 'string' ? value : key - }, - } - render() - expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument() - expect(screen.getByText('GPX-Import')).toBeInTheDocument() - expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toHaveClass('w-full') + it('opens the tour on a tap and marks the selected one', () => { + render() + + const open = screen.getByRole('button', { name: new RegExp(tour.name) }) + expect(open).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(open) + expect(handlePlaceClick).toHaveBeenCalledWith(tour.place_id) }) - it('shows no T-badge for an imported tour that carries no difficulty', () => { - const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null } - render() + it('names the day a planned tour is on, and how many more, instead of the plus', () => { + seedStore(useTripStore, { assignments: { '11': [{ id: 1, place_id: 42 }], '12': [{ id: 2, place_id: 42 }] } }) + const days = [day, { id: 12, day_number: 2, date: '2026-07-09', title: null }] as never + render() - const row = screen.getByRole('option', { name: new RegExp(tour.name) }) - expect(row).not.toHaveTextContent(/T\d/) - expect(row).not.toHaveTextContent('Tnull') - expect(screen.queryByTitle(/^T\d/)).not.toBeInTheDocument() - expect(screen.getByText(en['tours.badge.gpx'] as string)).toBeInTheDocument() + expect(screen.getByText(/Day 1/)).toHaveTextContent('Day 1 +1') + expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument() }) - it('does not refresh Tours merely because the mobile assignment sheet closes', () => { - const { rerender } = render() + it('offers no plus to someone who may not plan days', () => { + render() + expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument() + }) - rerender() + it('shows the type of an ungraded tour and where an import came from', () => { + const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null, tour_type: 'kayak' as const } + render() - expect(reloadTourPlaceIds).not.toHaveBeenCalled() + expect(screen.getByRole('img', { name: en['tourTypes.kayak'] as string })).not.toHaveTextContent(/T\d/) + expect(screen.getByLabelText(en['tours.badge.gpx'] as string)).toHaveTextContent('GPX') + }) + + it('says once that tours are planned on a bigger screen, in German too', () => { + render() + expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument() + expect(screen.getByText('1 Tour')).toBeInTheDocument() + expect(document.body.textContent).not.toMatch(/\b(tours|mobileTrip)\.[a-zA-Z]/) + }) + + it('without tours shows the empty state with the same badge', () => { + render() + expect(screen.getByText('Noch keine Touren')).toBeInTheDocument() + expect(screen.getByText(de['mobileTrip.toursEmptyBody'] as string)).toBeInTheDocument() + expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument() + }) + + it('narrows the list by a facet and widens it again', () => { + const other = { ...tour, place_id: 43, name: 'Other', max_hiking_difficulty: 4 } + render() + + fireEvent.click(screen.getByRole('button', { name: /^T4/ })) + expect(screen.getByText('Other')).toBeInTheDocument() + expect(screen.queryByText(tour.name)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /^T4/ })) + expect(screen.getByText(tour.name)).toBeInTheDocument() }) }) diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx index 6db2c0fdd..728288a79 100644 --- a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx @@ -1,9 +1,15 @@ -import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucide-react' +import { createElement } from 'react' +import { Plus } from 'lucide-react' import type { TourListItem } from '@trek/shared' -import { useSettingsStore } from '../../../../store/settingsStore' -import { formatDistance, formatElevation } from '../../../../utils/units' -import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY } from '../../../../components/Tours/tourPresentation' +import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY, tourSource } from '../../../../components/Tours/tourPresentation' +import { tourDayRowIcon } from '../../../../components/Tours/tourDayRowIcon' +import { useTourDays } from '../../../../components/Tours/tourDays' +import { resolveTrackColor } from '../../../../components/Map/trackColors' import type { TourListFilters } from '../../../../components/Tours/useTourListFilters' +import MDancingTrek from '../../../components/MDancingTrek' +import TourDayRowFacts from '../../../../components/Tours/TourDayRowFacts' +import { TourCautionBadge } from '../../../../components/Tours/tourParts' +import MPlanOnDesktopBadge from '../../../components/MPlanOnDesktopBadge' import type { TripPlanner, MTripShellApi } from '../MTripShell' import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions' @@ -32,20 +38,105 @@ function FacetChip({ active, label, count, onClick }: { active: boolean; label: } /** - * Tours mode of the mobile places browser: a selection list that mirrors - * the desktop ToursSidebar's data, with viewing and day assignment only. - * This list does not provide GPX import. + * The tour's tile, the size of a place's avatar: its grade where it has one, + * its type otherwise, and a small mark under it when it came in from outside. + */ +function TourTile({ tour, t }: { tour: TourListItem; t: TripPlanner['t'] }) { + const level = tour.max_hiking_difficulty + const source = tourSource(tour) + const label = level != null ? t(`tours.planner.difficulty.t${level}`) : t(`tourTypes.${tour.tour_type}`) + return ( + + + {level != null + ? `T${level}` + : createElement(tourDayRowIcon(tour.tour_type), { size: 16, strokeWidth: 1.9, 'aria-hidden': true })} + + {source !== 'trek' && ( + + {t(`tours.badgeShort.${source}`)} + + )} + + ) +} + +/** A tour as a row of the browser, laid out like a place: tap opens it, the right side is its day. */ +function TourRow({ tour, planner, shell, canAssign }: { + tour: TourListItem + planner: TripPlanner + shell: MTripShellApi + canAssign: boolean +}) { + const { t } = planner + const tourDays = useTourDays(tour.place_id, planner.days) + const place = planner.places.find(p => p.id === tour.place_id) + const selected = planner.selectedPlaceId === tour.place_id + return ( +
+ + {tourDays.length > 0 ? ( + + {t('planner.dayN', { n: tourDays[0].index + 1 })} + {tourDays.length > 1 && ` +${tourDays.length - 1}`} + + ) : canAssign && ( + + )} +
+ ) +} + +/** + * Tours mode of the mobile places browser: the trip's tours in the same rows + * as its places, to open and to put on a day. Tours are planned on a bigger + * screen, which the badge under the list says once; the phone never creates or + * imports one. * - * "Add to day" reuses the existing 'bract' place-actions sheet's day picker - * (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a - * Place under the hood, so no new linking code is needed here either. + * "Add to day" reuses the 'bract' place-actions sheet's day picker, the same + * one an unplanned place's plus opens: a tour is a Place under the hood. */ export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) { const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission }) const { t } = planner - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const filtered: TourListItem[] = filters.filtered - const { facets, selection } = filters + const { facets, selection, filtered } = filters const facetRow = filters.hasFacets && (
{facets.origin.map(option => ( @@ -63,105 +154,41 @@ export default function MToursSelectionList({ planner, shell, filters, canAssign
) - if (filters.emptyByFilter) { + if (planner.tours.length === 0) { return ( - <> - {facetRow} -
-

{t('tours.filter.empty')}

- +
+ +

{t('tours.empty.title')}

+

{t('mobileTrip.toursEmptyBody')}

+
- - ) - } - - if (filtered.length === 0) { - return ( - <> -

- {t('tours.planner.mobileHint')} -

-
- -

{t('tours.empty.title')}

-

{t('tours.empty.body')}

-
- ) } return ( <> -

- {t('tours.planner.mobileHint')} -

- {facetRow} -
    - {filtered.map(tour => { - const isSelected = planner.selectedPlaceId === tour.place_id - return ( -
  • planner.handlePlaceClick(tour.place_id)} - onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); planner.handlePlaceClick(tour.place_id) } }} - className="rounded-2xl border border-[color:var(--m-rowbr)] p-3" - style={{ background: isSelected ? 'var(--border-faint)' : 'var(--m-ic)' }} - > -
    - {tour.name} -
    - - {hikeSourceBadgeLabel(tour, t)} - - {tour.max_hiking_difficulty != null && ( - T{tour.max_hiking_difficulty} - )} - {tour.caution && ( - - - )} -
    -
    - - {formatDistance(tour.distance ?? 0, distanceUnit)} - - {tour.elevation_gain != null && ( - - - )} - {tour.elevation_loss != null && ( - - - )} -
    - -
    -
  • - ) - })} -
+ {facetRow} +
+ + + {t('mobileTrip.toursCount', { count: filtered.length })} + + +
+ {filters.emptyByFilter ? ( +
+ +

{t('tours.filter.empty')}

+ +
+ ) : ( + filtered.map(tour => ) + )} +
+ +
) } diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index 0c9f2477e..e4b869aa4 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -9,6 +9,7 @@ import MRtAlternativesBar from './MRtAlternativesBar' import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' import MBadge from '../../../components/MBadge' import MDancingTrek from '../../../components/MDancingTrek' +import MPlanOnDesktopBadge from '../../../components/MPlanOnDesktopBadge' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { navigationTargetLabel, getNavigationTargets } from '../../../../components/Planner/placeNavigation' import { useSettingsStore } from '../../../../store/settingsStore' @@ -485,9 +486,12 @@ function EmptyStage({ planner, loading }: { planner: MTripTabPanelProps['planner {loading ? t('roadtrip.summary.partial') : t('roadtrip.empty.title')}

{!loading && ( -

- {t('mobileTrip.rtPlanOnDesktop')} -

+ <> +

+ {t('mobileTrip.rtDriveOnTwoPlaces')} +

+ + )}
) diff --git a/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx new file mode 100644 index 000000000..d270f88fd --- /dev/null +++ b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx @@ -0,0 +1,462 @@ +import { + Check, + Copy, + Crown, + Link2, + LogOut, + Pencil, + Plus, + RefreshCw, + Settings2, + Trash2, + UserMinus, + UserPlus, + UserRound, +} from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import CustomSelect from '../../../../components/shared/CustomSelect'; +import { TripJournalLinkSection } from '../../../../components/Trips/TripJournalLinkSection'; +import { TripMemberAvatar } from '../../../../components/Trips/TripMemberAvatar'; +import { + SHARE_OPTIONS, + SHARE_SECTIONS, + useShareLink, + useTripInviteLink, + useTripMembers, + type ListedMember, +} from '../../../../components/Trips/useTripShare'; +import { useTranslation } from '../../../../i18n'; +import MChip from '../../../components/MChip'; +import MSheet from '../../../components/MSheet'; +import MConfirmSheet from '../../settings/MConfirmSheet'; +import { Eyebrow, INNER_CLS, TileHeader } from './MTripSheetUi'; + +interface MTripSettingsSheetProps { + open: boolean; + onClose: () => void; + tripId: number; + tripTitle: string; + onMembersChanged?: () => void; +} + +type Question = + | { kind: 'transfer'; id: number; name: string } + | { kind: 'remove'; id: number; self: boolean } + | { kind: 'guest'; id: number }; + +const FIELD = + 'h-[42px] min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-m-ink outline-none placeholder:text-m-faint'; +const PRIMARY = + 'flex h-[42px] flex-none items-center gap-1.5 rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40'; +const ROUND = + 'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40'; +const PILL = + 'flex h-[22px] flex-none items-center gap-[3px] rounded-full px-[8px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.06em]'; + +/** A titled block of the sheet: the eyebrow, an optional line under it and the content on the inner surface. */ +function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) { + return ( +
+ {title} + {hint && ( +

+ {hint} +

+ )} +
{children}
+
+ ); +} + +/** A person of the trip: the avatar, the name with its badges, and the round actions. */ +function PersonRow({ + name, + avatarUrl, + badges, + children, +}: { + name: ReactNode; + avatarUrl: string | null; + badges?: ReactNode; + children?: ReactNode; +}) { + return ( +
+ +
+ + {name} + + {badges} +
+ {children} +
+ ); +} + +/** A link with its copy button, read-only, the way the phone shows one to hand on. */ +function LinkField({ + url, + copied, + onCopy, + t, +}: { + url: string; + copied: boolean; + onCopy: () => void; + t: (key: string) => string; +}) { + return ( +
+ + +
+ ); +} + +function ShareLinkSection({ tripId }: { tripId: number }) { + const { t } = useTranslation(); + const link = useShareLink(tripId); + if (link.loading) return null; + return ( +
+
+ {[...SHARE_SECTIONS, ...SHARE_OPTIONS].map((option) => { + const always = 'always' in option && !!option.always; + const on = !!link.perms[option.key]; + return ( + { + if (!always) void link.setPerm(option.key, !on); + }} + > + {on && } + {t(option.label)} + + ); + })} +
+ {link.url ? ( + <> + void link.copy()} t={t} /> + + + ) : ( + + )} +
+ ); +} + +function InviteLinkSection({ tripId }: { tripId: number }) { + const { t } = useTranslation(); + const invite = useTripInviteLink(tripId); + if (invite.loading) return null; + return ( +
+ {invite.url ? ( + <> + void invite.copy()} t={t} /> +
+ + +
+ + ) : ( + + )} +
+ ); +} + +/** + * The trip's settings on the phone (the Mehr sheet's "Trip settings"): who has + * access, the guests without an account, the public link, the invite link and + * the linked journal. The state is useTripShare's, the same the desktop share + * dialog runs on; only the markup and the questions are the phone's own. + */ +export default function MTripSettingsSheet({ + open, + onClose, + tripId, + tripTitle, + onMembersChanged, +}: MTripSettingsSheetProps) { + const { t } = useTranslation(); + const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged }); + const [question, setQuestion] = useState(null); + + const answer = () => { + if (!question) return; + if (question.kind === 'transfer') void m.transfer(question.id); + else if (question.kind === 'remove') void m.remove(question.id, question.self); + else void m.removeGuest(question.id); + setQuestion(null); + }; + const questionText = !question + ? '' + : question.kind === 'transfer' + ? t('members.confirmTransfer', { name: question.name }) + : question.kind === 'guest' + ? t('members.confirmRemoveGuest') + : question.self + ? t('members.confirmLeave') + : t('members.confirmRemove'); + const questionAction = + question?.kind === 'transfer' + ? t('members.makeOwner') + : question?.kind === 'remove' && question.self + ? t('members.leaveTrip') + : t('members.removeAccess'); + + const memberActions = (member: ListedMember) => { + const self = member.id === m.user?.id; + const canRemove = self || (m.canManageMembers && member.role !== 'owner'); + return ( + <> + {m.isCurrentOwner && member.role !== 'owner' && ( + + )} + {canRemove && ( + + )} + + ); + }; + + return ( + <> + +
+ } + title={t('trip.settings.title')} + sub={tripTitle} + onClose={onClose} + closeLabel={t('common.close')} + /> +
+ +
+
+ {m.canManageMembers && ( +
+ m.setSelectedUserId(String(value))} + placeholder={t('members.selectUser')} + options={[ + { value: '', label: t('members.selectUser') }, + ...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })), + ]} + searchable + size="sm" + style={{ flex: 1, minWidth: 0 }} + /> + +
+ )} + {m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && ( +

+ {t('members.allHaveAccess')} +

+ )} + {m.loading && m.realMembers.length === 0 ? ( +
+ ) : ( + m.realMembers.map((member) => ( + + {member.id === m.user?.id && ( + {t('members.you')} + )} + {member.role === 'owner' && ( + + + {t('members.owner')} + + )} + + } + > + {memberActions(member)} + + )) + )} +
+ + {(m.isCurrentOwner || m.guests.length > 0) && ( +
0 ? ` ${m.guests.length}` : ''}`} + hint={t('members.guestsHint')} + > + {m.guests.map((guest) => + m.renamingGuestId === guest.id ? ( + m.setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void m.commitRename(guest.id); + if (e.key === 'Escape') m.cancelRename(); + }} + onBlur={() => void m.commitRename(guest.id)} + className={FIELD} + /> + ) : ( + + + {t('members.guest')} + + } + > + {m.isCurrentOwner && ( + <> + + + + )} + + ) + )} + {m.isCurrentOwner && ( +
+ m.setNewGuestName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void m.addGuest(); + }} + className={FIELD} + /> + +
+ )} +
+ )} + + {m.canManageShare && } + {m.canManageShare && } + +
+
+ + setQuestion(null)} + title={questionAction} + message={questionText} + confirmLabel={questionAction} + cancelLabel={t('common.cancel')} + danger={question?.kind !== 'transfer'} + onConfirm={answer} + /> + + ); +} diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index 425b139cc..8cc0c02f3 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -3,8 +3,6 @@ import { createPortal } from 'react-dom' import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal' import BookingImportModal from '../../../../components/Planner/BookingImportModal' import AirTrailImportModal from '../../../../components/Planner/AirTrailImportModal' -import TripFormModal from '../../../../components/Trips/TripFormModal' -import TripMembersModal from '../../../../components/Trips/TripMembersModal' import TourDetailDialog from '../../../../components/Tours/TourDetailDialog' import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor' import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover' @@ -26,6 +24,8 @@ import MNoteSheet, { type MNoteSheetPayload } from './MNoteSheet' import MImportSheet from './MImportSheet' import MExportSheet from './MExportSheet' import MMehrSheet from './MMehrSheet' +import MTripSettingsSheet from './MTripSettingsSheet' +import MNewTripSheet from '../../dashboard/MNewTripSheet' import MPlacesFilterSheet from '../places/MPlacesFilterSheet' import MRtStopSheet from '../roadtrip/MRtStopSheet' import MRtStaySheet from '../roadtrip/MRtStaySheet' @@ -97,9 +97,10 @@ export function MSelectedTourDetail({ planner }: Pick planner.setShowAirTrailImport(false)} tripId={tripId} pushUndo={planner.pushUndo} /> {/* Trip edit + share/members, opened from the Mehr sheet. */} - { await tripActions.updateTrip(tripId, data) toast.success(t('trip.toast.tripUpdated')) }} - trip={trip} onCoverUpdate={applyTripCoverUpdate} /> - diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index c577a1e8c..47414e6be 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -511,7 +511,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO dayImport, dayImportDates, } = useJourneyDetailLayers({ journey: current, loaded: journeyLoaded, canEditEntries, loadJourney, dawarich, dawarichByDate, dawarichTrail, - tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, + tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, allLayers: isMobile, }) /** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */ diff --git a/client/src/pages/journeyDetail/useJourneyDetailLayers.ts b/client/src/pages/journeyDetail/useJourneyDetailLayers.ts index 2de527799..a925f7335 100644 --- a/client/src/pages/journeyDetail/useJourneyDetailLayers.ts +++ b/client/src/pages/journeyDetail/useJourneyDetailLayers.ts @@ -30,6 +30,7 @@ export function useJourneyDetailLayers({ sidebarMapItems, photoCount, showTripTracks, + allLayers = false, }: { journey: JourneyDetail | null; /** Whether `journey` is the one the route names yet: the store keeps the last one while the next loads. */ @@ -43,6 +44,8 @@ export function useJourneyDetailLayers({ sidebarMapItems: ReadonlyArray<{ id: string; entry_date: string; dayColor: string }>; photoCount: number; showTripTracks: boolean; + /** The phone: every map layer on, as it offers no switches (see useJourneyMapLayers). */ + allLayers?: boolean; }) { // The editor's own status, the owner's switch, and where each day of the trips stands. const isAddonEnabled = useAddonStore((s) => s.isEnabled); @@ -111,6 +114,7 @@ export function useJourneyDetailLayers({ recordedRead, colorOfDay, dateOfEntry, + allLayers, }); // The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list. diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index f889e88bf..6aefb6252 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -136,6 +136,7 @@ export function useJourneyPublic() { photoCount: mapPhotos.length, colorOfDay, dateOfEntry, + allLayers: isMobile, }) // A journey shared while the trip is still running reads like a blog, so the owner diff --git a/client/tests/unit/mobile/admin/MAdmin.test.tsx b/client/tests/unit/mobile/admin/MAdmin.test.tsx index 6b2a19d78..05596fea4 100644 --- a/client/tests/unit/mobile/admin/MAdmin.test.tsx +++ b/client/tests/unit/mobile/admin/MAdmin.test.tsx @@ -60,7 +60,7 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({ })); // The addon manager is reduced to the two controls MAdmin wires itself. -vi.mock('../../../../src/components/Admin/paddle/PaddleRegionsPanel', () => ({ +vi.mock('../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel', () => ({ default: () =>
, })); vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({ diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index cab6a32df..43340656f 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -118,9 +118,10 @@ describe('MAdminAddonManager', () => { render(); await screen.findByText('Lists'); - expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument(); + // The band names the type and says what it means on a line of its own, no dash between them. + expect(screen.getByText('Available as a tab within each trip')).toBeInTheDocument(); expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument(); - expect(screen.getByText('Trip')).toBeInTheDocument(); + expect(screen.getAllByText('Trip')).toHaveLength(2); expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true'); }); @@ -275,7 +276,7 @@ describe('MAdminAddonManager', () => { render(); await screen.findByText('Immich'); - expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument(); + expect(screen.getByText('Available as a standalone section in the main navigation')).toBeInTheDocument(); expect(screen.getByText('Synology Photos')).toBeInTheDocument(); expect(screen.getByText('Unsplash')).toBeInTheDocument(); expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true'); @@ -499,7 +500,7 @@ describe('MAdminAddonManager', () => { render(); await screen.findByText('AI Parsing'); - expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument(); + expect(screen.getByText('Backend services and API integrations with no dedicated page')).toBeInTheDocument(); expect(screen.queryByText('Connection')).not.toBeInTheDocument(); }); diff --git a/client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx new file mode 100644 index 000000000..2186635ac --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx @@ -0,0 +1,167 @@ +// FE-MOB-APAD-001 to FE-MOB-APAD-010: the paddle regions card of the phone admin, drawn from a +// controlled usePaddleRegionsAdmin (the hook has its own test). +import type { PaddleRegion, PaddleRegionPending } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import type { usePaddleRegionsAdmin } from '../../../../src/components/Admin/paddle/usePaddleRegionsAdmin'; +import MAdminPaddleRegionsPanel from '../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel'; +import { fireEvent, render, screen, within } from '../../../helpers/render'; + +type Admin = ReturnType; + +const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() })); +vi.mock('../../../../src/components/Admin/paddle/usePaddleRegionsAdmin', () => ({ + usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin, +})); + +function region(overrides: Partial = {}): PaddleRegion { + return { + id: 'oberpfalz', + name: 'Oberpfalz', + status: 'ready', + origin: 'admin', + source_timestamp: '2026-10-03T12:00:00Z', + imported_at: '2026-10-04T08:00:00Z', + feature_count: 1234, + error: null, + progress: null, + network: null, + ...overrides, + }; +} + +const REQUEST: PaddleRegionPending = { + id: 'germany/bayern', + name: 'Bayern', + reason: 'too_large', + bytes: 700 * 1024 * 1024, + requested_by: 'alice', + requested_at: '2026-10-05T12:00:00Z', + declined: false, +} as PaddleRegionPending; + +function show(overrides: Partial = {}, search: Partial = {}) { + const state: Admin = { + toursOn: true, + offline: false, + regions: [], + requests: [], + autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) }, + loading: false, + error: null, + retry: vi.fn(), + added: new Set(), + busyId: null, + add: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + approve: vi.fn(async () => {}), + decline: vi.fn(async () => {}), + removing: null, + askRemove: vi.fn(), + cancelRemove: vi.fn(), + confirmRemove: vi.fn(), + ...overrides, + search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search }, + }; + hook.usePaddleRegionsAdmin.mockReturnValue(state); + render(); + return state; +} + +describe('MAdminPaddleRegionsPanel', () => { + it('FE-MOB-APAD-001: shows nothing while the Tours addon is off', () => { + show({ toursOn: false }); + expect(screen.queryByText('Paddle water data')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APAD-002: a region shows its status, its objects and its actions', () => { + const state = show({ regions: [region({ network: { water_km: 812 } as PaddleRegion['network'] })] }); + const row = screen.getByTestId('paddle-region-row'); + expect(within(row).getByText('Oberpfalz')).toBeInTheDocument(); + expect(within(row).getByText('Ready')).toBeInTheDocument(); + expect(within(row).getByText('1234 objects')).toBeInTheDocument(); + expect(within(row).getByText('812 km of water')).toBeInTheDocument(); + + fireEvent.click(within(row).getByRole('button', { name: 'Read again Oberpfalz' })); + expect(state.refresh).toHaveBeenCalledWith('oberpfalz'); + fireEvent.click(within(row).getByRole('button', { name: 'Remove region Oberpfalz' })); + expect(state.askRemove).toHaveBeenCalledWith(expect.objectContaining({ id: 'oberpfalz' })); + }); + + it('FE-MOB-APAD-003: a failed read says why and that the old data stays', () => { + show({ regions: [region({ status: 'failed', error: 'Download broke off.' })] }); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText(/Download broke off\. The data read before stays in use\./)).toBeInTheDocument(); + }); + + it('FE-MOB-APAD-004: the mode and the size are switched in place', () => { + const save = vi.fn(async () => {}); + show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } as Admin['autoLoad'] }); + const modes = screen.getByRole('radiogroup', { name: 'Load regions automatically' }); + expect(within(modes).getByRole('radio', { name: /Automatic/ })).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(within(modes).getByRole('radio', { name: /On request/ })); + expect(save).toHaveBeenCalledWith({ mode: 'ask' }); + + const sizes = screen.getByRole('group', { name: 'Largest region loaded automatically' }); + fireEvent.click(within(sizes).getByRole('button', { name: /1 GB/ })); + expect(save).toHaveBeenCalledWith({ max_mb: 1000 }); + }); + + it('FE-MOB-APAD-005: no size choice outside the automatic mode', () => { + show({ + autoLoad: { + settings: { mode: 'off', max_mb: 500 }, + saving: false, + save: vi.fn(), + } as unknown as Admin['autoLoad'], + }); + expect(screen.queryByRole('group', { name: 'Largest region loaded automatically' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-APAD-006: a requested region is loaded or declined', () => { + const state = show({ requests: [REQUEST] }); + const row = screen.getByTestId('paddle-region-request'); + expect(within(row).getByText('Bayern')).toBeInTheDocument(); + fireEvent.click(within(row).getByRole('button', { name: /Load/ })); + expect(state.approve).toHaveBeenCalledWith('germany/bayern'); + fireEvent.click(within(row).getByRole('button', { name: 'Decline' })); + expect(state.decline).toHaveBeenCalledWith('germany/bayern'); + }); + + it('FE-MOB-APAD-007: a search hit is added, an added one says so', () => { + const state = show( + { added: new Set(['netherlands/limburg']) }, + { + query: 'Limburg', + hits: [ + { id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] }, + { id: 'netherlands/limburg', name: 'Limburg NL', path: ['Europe', 'Netherlands'] }, + ], + } + ); + const hits = screen.getAllByTestId('paddle-region-hit'); + expect(within(hits[0]).getByText('Belgium')).toBeInTheDocument(); + fireEvent.click(within(hits[0]).getByRole('button', { name: /Limburg/ })); + expect(state.add).toHaveBeenCalledWith('belgium/limburg'); + expect(within(hits[1]).getByText('Added')).toBeInTheDocument(); + }); + + it('FE-MOB-APAD-008: typing searches, and a search without hits says so', () => { + const state = show({}, { hits: [] }); + fireEvent.change(screen.getByRole('searchbox', { name: 'Add a region' }), { target: { value: 'Atlantis' } }); + expect(state.search.setQuery).toHaveBeenCalledWith('Atlantis'); + expect(screen.getByText('No region by that name')).toBeInTheDocument(); + }); + + it('FE-MOB-APAD-009: a failed read of the list says so and can be retried', () => { + const state = show({ error: 'Server down' }); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(state.retry).toHaveBeenCalled(); + }); + + it('FE-MOB-APAD-010: removing asks in a phone sheet first', () => { + const state = show({ regions: [region()], removing: region() }); + const question = screen.getByRole('dialog', { name: 'Remove region' }); + fireEvent.click(within(question).getByRole('button', { name: 'Remove' })); + expect(state.confirmRemove).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx b/client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx new file mode 100644 index 000000000..78d767389 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx @@ -0,0 +1,124 @@ +// FE-MOB-ASCH-001 to FE-MOB-ASCH-008: the school holiday catalog of the phone admin, drawn from a +// controlled useSchoolHolidayCatalog (the hook has its own test). +import type { SchoolHolidayRegionDetail } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import type { useSchoolHolidayCatalog } from '../../../../src/components/Admin/useSchoolHolidayCatalog'; +import MAdminSchoolHolidayCatalog from '../../../../src/mobile/screens/admin/MAdminSchoolHolidayCatalog'; +import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'; + +type Catalog = ReturnType; + +const hook = vi.hoisted(() => ({ useSchoolHolidayCatalog: vi.fn<() => Catalog>() })); +vi.mock('../../../../src/components/Admin/useSchoolHolidayCatalog', () => ({ + useSchoolHolidayCatalog: hook.useSchoolHolidayCatalog, +})); + +const REGION: SchoolHolidayRegionDetail = { + id: 5, + country: 'DE', + name: 'Bayern', + code: 'BY', + revision: 2, + holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }], +}; + +function show(overrides: Partial = {}) { + const state = { + catalog: { + countries: [{ code: 'DE', name: 'Deutschland' }], + regions: [{ id: 5, country: 'DE', name: 'Bayern', code: 'BY', revision: 2 }], + }, + loading: false, + busy: false, + error: '', + offline: false, + editor: null, + setEditor: vi.fn(), + refresh: vi.fn(async () => true), + createCountry: vi.fn(async () => true), + deleteCountry: vi.fn(async () => true), + openRegion: vi.fn(async () => true), + saveRegion: vi.fn(async () => true), + deleteRegion: vi.fn(async () => true), + ...overrides, + } as unknown as Catalog; + hook.useSchoolHolidayCatalog.mockReturnValue(state); + render(); + return state; +} + +describe('MAdminSchoolHolidayCatalog', () => { + it('FE-MOB-ASCH-001: lists the regions of the chosen country and opens one', () => { + const state = show(); + expect(screen.getByText('School holidays')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Bayern' })); + expect(state.openRegion).toHaveBeenCalledWith(5); + }); + + it('FE-MOB-ASCH-002: a country with regions cannot be deleted, and says why', () => { + show(); + expect(screen.getByRole('button', { name: 'Delete country' })).toBeDisabled(); + expect(screen.getByText('Remove regions first. Regions used by calendars cannot be deleted.')).toBeInTheDocument(); + }); + + it('FE-MOB-ASCH-003: a region is deleted after the phone question', () => { + const state = show(); + fireEvent.click(screen.getByRole('button', { name: 'Delete Bayern' })); + const question = screen.getByRole('dialog', { name: 'Delete' }); + fireEvent.click(within(question).getByRole('button', { name: 'Delete' })); + expect(state.deleteRegion).toHaveBeenCalledWith(5, 2); + }); + + it('FE-MOB-ASCH-004: an empty country says so and offers a new region', () => { + const state = show({ + catalog: { countries: [{ code: 'AT', name: 'Österreich' }], regions: [] } as Catalog['catalog'], + }); + expect(screen.getByText('No regions yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add region' })); + expect(state.setEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 0, country: 'AT', holidays: [] })); + }); + + it('FE-MOB-ASCH-005: a country is added in its own sheet, with a two-letter code', async () => { + const state = show({ catalog: { countries: [], regions: [] } as Catalog['catalog'] }); + fireEvent.click(screen.getByRole('button', { name: 'Add country' })); + const sheet = screen.getByRole('dialog', { name: 'Add country' }); + const save = within(sheet).getByRole('button', { name: 'Save' }); + expect(save).toBeDisabled(); + const [name, code] = within(sheet).getAllByRole('textbox'); + fireEvent.change(name, { target: { value: 'France' } }); + fireEvent.change(code, { target: { value: 'fr' } }); + expect(code).toHaveValue('FR'); + fireEvent.click(save); + await waitFor(() => expect(state.createCountry).toHaveBeenCalledWith({ code: 'FR', name: 'France' })); + }); + + it('FE-MOB-ASCH-006: the region sheet saves its periods as the request the server takes', () => { + const state = show({ editor: REGION }); + const sheet = screen.getByRole('dialog', { name: 'Bayern' }); + fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' })); + expect(within(sheet).getAllByPlaceholderText('Holiday name')).toHaveLength(2); + fireEvent.click(within(sheet).getByRole('button', { name: 'Delete 2' })); + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })); + expect(state.saveRegion).toHaveBeenCalledWith('DE', 5, { + name: 'Bayern', + revision: 2, + holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }], + }); + }); + + it('FE-MOB-ASCH-007: an incomplete period is refused before it is sent', () => { + const state = show({ editor: REGION }); + const sheet = screen.getByRole('dialog', { name: 'Bayern' }); + fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' })); + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })); + expect(within(sheet).getByRole('alert')).toHaveTextContent('Check the names and dates.'); + expect(state.saveRegion).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ASCH-008: a failed read says so and can be tried again', () => { + const state = show({ error: 'Server down' }); + expect(screen.getByRole('alert')).toHaveTextContent('Server down'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(state.refresh).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx index d4846cab4..71d6750bc 100644 --- a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx +++ b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx @@ -178,6 +178,7 @@ describe('MNewTripSheet', () => { start_date: null, end_date: null, currency: 'USD', + reminder_days: 0, day_count: 7, })); expect(onClose).toHaveBeenCalled(); @@ -664,3 +665,66 @@ describe('MNewTripSheet', () => { spy.mockRestore(); }); }); + +describe('MNewTripSheet day count and reminder', () => { + it('FE-MOB-NTSH-090: a dateless trip sends a changed day count, an unchanged one not', async () => { + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + const trip = buildDashTrip({ start_date: null, end_date: null, day_count: 5 } as Partial); + const { unmount } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count'); + unmount(); + + render(); + fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '9' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2)); + expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 9 }); + }); + + it('FE-MOB-NTSH-091: an empty day count is refused', async () => { + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + render()} onClose={vi.fn()} onSave={onSave} />); + + fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + expect(await screen.findByText('Number of days is required')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-092: the owner picks a reminder, a preset or a number of their own', async () => { + useAuthStore.setState({ tripRemindersEnabled: true }); + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + render()} onClose={vi.fn()} onSave={onSave} />); + + expect(screen.getByRole('button', { name: '3 days' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + const days = screen.getByRole('spinbutton', { name: 'days before departure' }); + expect(days).toHaveValue(7); + fireEvent.change(days, { target: { value: '12' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reminder_days: 12 }))); + + fireEvent.click(screen.getByRole('button', { name: 'None' })); + expect(screen.queryByRole('spinbutton', { name: 'days before departure' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-093: with reminders off the owner is told where to switch them on', () => { + useAuthStore.setState({ tripRemindersEnabled: false }); + render(); + expect(screen.getByText(/Trip reminders are disabled/)).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-094: someone else\'s trip offers no reminder and sends none', async () => { + useAuthStore.setState({ tripRemindersEnabled: true, user: buildUser({ id: 9, role: 'user' }) }); + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + render(); + + expect(screen.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).not.toHaveProperty('reminder_days'); + }); +}); diff --git a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx index f623c887d..e410a77bc 100644 --- a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx @@ -272,7 +272,7 @@ describe('MRoadtripTab', () => { renderTab(planner({ roadtripRoutes: routes({ days: [] }) })) expect(screen.getByText('roadtrip.empty.title')).toBeInTheDocument() - expect(screen.getByText('mobileTrip.rtPlanOnDesktop')).toBeInTheDocument() + expect(screen.getByText('mobileTrip.rtDriveOnTwoPlaces')).toBeInTheDocument() expect(screen.queryByText('mobileTrip.rtStart')).toBeNull() expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull() }) @@ -282,7 +282,7 @@ describe('MRoadtripTab', () => { expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument() expect(screen.queryByText('roadtrip.empty.title')).toBeNull() - expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull() + expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull() }) it('FE-MOB-RTTAB-053: with the drive routed and no day picked it asks for a day, not for a route', () => { @@ -294,7 +294,7 @@ describe('MRoadtripTab', () => { expect(screen.getByText('mobileTrip.rtNoDay')).toBeInTheDocument() expect(screen.getByText('mobileTrip.rtNoDayHint')).toBeInTheDocument() expect(screen.queryByText('roadtrip.empty.title')).toBeNull() - expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull() + expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull() expect(screen.queryByText('mobileTrip.rtStart')).toBeNull() // A picked day the routing has nothing for is still the empty stage: that one is diff --git a/client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx b/client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx new file mode 100644 index 000000000..930faba83 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx @@ -0,0 +1,228 @@ +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import MTripSettingsSheet from '../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { usePermissionsStore } from '../../../../src/store/permissionsStore'; +import { useTripStore } from '../../../../src/store/tripStore'; +import { buildTrip, buildUser } from '../../../helpers/factories'; +import { server } from '../../../helpers/msw/server'; +import { render, screen, waitFor, within } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; + +// FE-MOB-TSET-001 to FE-MOB-TSET-008: the phone's trip settings, on useTripShare. + +const owner = buildUser({ id: 1, username: 'owner' }); +const alice = buildUser({ id: 2, username: 'alice' }); + +interface Row { + id: number; + username: string; + avatar_url?: string | null; + is_guest?: boolean; +} + +function roster(members: Row[]) { + server.use( + http.get('/api/trips/1/members', () => + HttpResponse.json({ + owner: { id: owner.id, username: owner.username, avatar_url: null }, + members, + current_user_id: owner.id, + }) + ) + ); +} + +function renderSheet(props: Partial[0]> = {}) { + const onClose = vi.fn(); + const onMembersChanged = vi.fn(); + render( + + ); + return { onClose, onMembersChanged }; +} + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: owner, isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Japan 2026', user_id: owner.id }) }); + roster([]); + server.use( + http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })), + http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })), + http.get('/api/auth/users', () => HttpResponse.json({ users: [alice] })) + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('MTripSettingsSheet', () => { + it('FE-MOB-TSET-001: names the trip in its header and the owner with their badges', async () => { + renderSheet(); + const sheet = screen.getByRole('dialog', { name: 'Trip settings' }); + expect(within(sheet).getByText('Japan 2026')).toBeInTheDocument(); + expect(await within(sheet).findByText('owner')).toBeInTheDocument(); + expect(within(sheet).getByText('you')).toBeInTheDocument(); + expect(within(sheet).getByText('Owner')).toBeInTheDocument(); + }); + + it('FE-MOB-TSET-002: asks in a sheet of its own before a member goes, and only then removes', async () => { + const user = userEvent.setup(); + roster([{ id: alice.id, username: 'alice', avatar_url: null }]); + let deletes = 0; + server.use( + http.delete('/api/trips/1/members/:userId', () => { + deletes++; + return HttpResponse.json({ success: true }); + }) + ); + const { onMembersChanged } = renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Remove access' })); + const question = await screen.findByRole('dialog', { name: 'Remove access' }); + expect(within(question).getByText('Remove access for this user?')).toBeInTheDocument(); + expect(deletes).toBe(0); + + await user.click(within(question).getByRole('button', { name: 'Remove access' })); + await waitFor(() => expect(deletes).toBe(1)); + await waitFor(() => expect(onMembersChanged).toHaveBeenCalled()); + }); + + it('FE-MOB-TSET-003: a question that is cancelled changes nothing', async () => { + const user = userEvent.setup(); + roster([{ id: alice.id, username: 'alice', avatar_url: null }]); + let transfers = 0; + server.use( + http.post('/api/trips/1/transfer', () => { + transfers++; + return HttpResponse.json({ success: true }); + }) + ); + renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Make owner' })); + const question = await screen.findByRole('dialog', { name: 'Make owner' }); + await user.click(within(question).getByRole('button', { name: 'Cancel' })); + expect(transfers).toBe(0); + }); + + it('FE-MOB-TSET-004: a guest is added, renamed and removed', async () => { + const user = userEvent.setup(); + roster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]); + let created: string | null = null; + let renamed: string | null = null; + let deleted = false; + server.use( + http.post('/api/trips/1/guests', async ({ request }) => { + created = ((await request.json()) as { name: string }).name; + return HttpResponse.json({ success: true }); + }), + http.put('/api/trips/1/guests/:userId', async ({ request }) => { + renamed = ((await request.json()) as { name: string }).name; + return HttpResponse.json({ success: true }); + }), + http.delete('/api/trips/1/guests/:userId', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }) + ); + renderSheet(); + + expect(await screen.findByText('Grandma')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Guest name'), 'Grandpa'); + await user.click(screen.getByRole('button', { name: 'Add guest' })); + await waitFor(() => expect(created).toBe('Grandpa')); + + await user.click(screen.getByRole('button', { name: 'Rename' })); + const input = screen.getByDisplayValue('Grandma'); + await user.clear(input); + await user.type(input, 'Granny{Enter}'); + await waitFor(() => expect(renamed).toBe('Granny')); + + const removeGuest = screen.getAllByRole('button', { name: 'Remove access' }); + await user.click(removeGuest[removeGuest.length - 1]); + await user.click( + within(await screen.findByRole('dialog', { name: 'Remove access' })).getByRole('button', { + name: 'Remove access', + }) + ); + await waitFor(() => expect(deleted).toBe(true)); + }); + + it('FE-MOB-TSET-005: the public link toggles say whether they are on (#2604)', async () => { + seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); + server.use( + http.get('/api/trips/1/share-link', () => + HttpResponse.json({ + token: 'abc123', + share_map: true, + share_bookings: true, + share_packing: false, + share_budget: false, + share_collab: false, + }) + ) + ); + renderSheet(); + + expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByDisplayValue(/\/shared\/abc123$/)).toBeInTheDocument(); + }); + + it('FE-MOB-TSET-006: a public link is created with what it should show', async () => { + const user = userEvent.setup(); + seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); + let body: Record | null = null; + server.use( + http.post('/api/trips/1/share-link', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ token: 'fresh' }); + }) + ); + renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Packing' })); + await user.click(screen.getByRole('button', { name: 'Create link' })); + await waitFor(() => expect(body).toMatchObject({ share_map: true, share_packing: true })); + expect(await screen.findByDisplayValue(/\/shared\/fresh$/)).toBeInTheDocument(); + }); + + it('FE-MOB-TSET-007: the invite link is created and switched off again', async () => { + const user = userEvent.setup(); + seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); + let removed = false; + server.use( + http.post('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'join1' })), + http.delete('/api/trips/1/invite-link', () => { + removed = true; + return HttpResponse.json({ success: true }); + }) + ); + renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Create invite link' })); + expect(await screen.findByDisplayValue(/\/join\/join1$/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Disable' })); + await waitFor(() => expect(removed).toBe(true)); + }); + + it('FE-MOB-TSET-008: closes through its close button', async () => { + const user = userEvent.setup(); + const { onClose } = renderSheet(); + await user.click( + within(screen.getByRole('dialog', { name: 'Trip settings' })).getByRole('button', { name: 'Close' }) + ); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx index dcb605665..28a8a46b5 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx @@ -11,9 +11,8 @@ import { render, screen, waitFor } from '../../../helpers/render' // FE-MOB-SHOST-030 to FE-MOB-SHOST-031 (001 to 029 and 032 live in MTripSheets.test.tsx) // -// The phone has no share sheet of its own: the Mehr sheet opens the desktop -// TripMembersModal through this host. Every other sheet is stubbed away so the real -// modal is what the phone user gets (#2478). +// The Mehr sheet opens the phone's own MTripSettingsSheet through this host. Every +// other sheet is stubbed away so the real sheet is what the phone user gets (#2478). vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: () => null })) vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: () => null })) @@ -39,7 +38,7 @@ vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({ defaul vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({ default: () => null })) vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ default: () => null })) vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ default: () => null })) -vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ default: () => null })) +vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({ default: () => null })) import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets' diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx index 6bb779f50..2c939d709 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx @@ -133,9 +133,9 @@ vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ ), })) -vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ - default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: { - isOpen: boolean; onClose: () => void +vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({ + default: ({ open: isOpen, onClose, onSave, trip, onCoverUpdate }: { + open: boolean; onClose: () => void onSave: (data: Record) => Promise trip?: Trip | null onCoverUpdate: (id: number, coverUrl: string) => void @@ -148,9 +148,9 @@ vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ ), })) -vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({ - default: ({ isOpen, onClose, tripTitle, onMembersChanged }: { - isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void +vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet', () => ({ + default: ({ open: isOpen, onClose, tripTitle, onMembersChanged }: { + open: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void }) => (
diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index a5f7c2b18..f94c80501 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -672,6 +672,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'رحلة برية', 'admin.addons.catalog.roadtrip.description': 'خطّط رحلات القيادة مع محطات توقف، وتُحدَّث أوقات القيادة والوصول تلقائيًا', + 'admin.addons.catalog.tours.name': 'الجولات', + 'admin.addons.catalog.tours.description': 'مشي وركوب دراجات وجولات أخرى تتبع مسارًا', 'admin.addons.catalog.memories.name': 'صور (Immich)', 'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich', 'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي', diff --git a/shared/src/i18n/ar/mobileTrip.ts b/shared/src/i18n/ar/mobileTrip.ts index 3ce667d0e..2597340d6 100644 --- a/shared/src/i18n/ar/mobileTrip.ts +++ b/shared/src/i18n/ar/mobileTrip.ts @@ -68,7 +68,15 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'أرقام القيادة', 'mobileTrip.rtDesktopNote': 'تُضبط هذه الأرقام على سطح المكتب، حيث يمكنك أيضًا مقارنة المسارات البديلة، وجعل يوم يتبع مسارًا مستوردًا.', - 'mobileTrip.rtPlanOnDesktop': 'يتم التخطيط على سطح المكتب. يحسب TREK القيادة بمجرد أن يضم اليوم مكانين.', + 'mobileTrip.planOnDesktop': 'التخطيط على الحاسوب', + 'mobileTrip.rtDriveOnTwoPlaces': 'يحسب TREK القيادة بمجرد أن يضم اليوم مكانين.', + 'mobileTrip.toursEmptyBody': 'تظهر الجولات المحفوظة هنا ويمكن إضافتها إلى يوم.', + 'mobileTrip.toursCount': '{count} جولة', + 'mobileTrip.toursCount.zero': 'لا توجد جولات', + 'mobileTrip.toursCount.one': 'جولة واحدة', + 'mobileTrip.toursCount.two': 'جولتان', + 'mobileTrip.toursCount.few': '{count} جولات', + 'mobileTrip.toursCount.many': '{count} جولة', 'mobileTrip.rtSearchOffline': 'يلزم اتصال بالإنترنت: يقرأ البحث المسار الذي أمامك.', 'mobileTrip.rtBehind': '{time} تأخرًا عن الخطة', 'mobileTrip.rtStart': 'البداية', diff --git a/shared/src/i18n/ar/nav.ts b/shared/src/i18n/ar/nav.ts index 9cb899219..cfc71af96 100644 --- a/shared/src/i18n/ar/nav.ts +++ b/shared/src/i18n/ar/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'الرحلة', 'nav.settings': 'الإعدادات', 'nav.admin': 'الإدارة', 'nav.logout': 'تسجيل الخروج', diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 2640685f0..c19a454ce 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -211,8 +211,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'عند {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'يظهر المخطط عند توفّر بيانات ارتفاع المسار.', 'tours.planner.gpxNoGeometry': 'بيانات الشكل الهندسي للمسار غير متاحة لملف GPX المستورد هذا.', - 'tours.planner.mobileHint': - 'تخطيط الجولات متاح على الأجهزة اللوحية والحواسيب. يمكنك هنا عرض الجولات المحفوظة وجدولتها.', 'tourTypes.hike': 'مشي طويل', 'tourTypes.bike': 'جولة بالدراجة', 'tourTypes.run': 'جري', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index d2010d3c7..2c8d2efda 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -709,6 +709,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Avtomobil səyahəti', 'admin.addons.catalog.roadtrip.description': 'Marşrut üzərindəki dayanacaqlar, sürmə müddətləri və avtomatik yenilənən çatma vaxtları ilə avtomobil səfərlərini planlaşdırın', + 'admin.addons.catalog.tours.name': 'Turlar', + 'admin.addons.catalog.tours.description': 'Marşrut üzrə gəzintilər, velosiped və digər turlar', 'admin.addons.catalog.memories.name': 'Fotolar (Immich)', 'admin.addons.catalog.memories.description': 'Immich sisteminiz vasitəsilə səyahət fotolarını paylaşın', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/az/mobileTrip.ts b/shared/src/i18n/az/mobileTrip.ts index 86d81c379..c10e45e0a 100644 --- a/shared/src/i18n/az/mobileTrip.ts +++ b/shared/src/i18n/az/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Avtomobil marşrutu göstəriciləri', 'mobileTrip.rtDesktopNote': 'Bu göstəricilər kompüterdə təyin edilir. Orada alternativ marşrutları müqayisə edə və günü idxal edilmiş iz üzrə hərəkət edəcək şəkildə qura bilərsiniz.', - 'mobileTrip.rtPlanOnDesktop': - 'Planlaşdırma kompüterdə aparılır. Gündə iki məkan olduqda TREK avtomobil marşrutunu hesablayır.', + 'mobileTrip.planOnDesktop': 'Planlama kompüterdə', + 'mobileTrip.rtDriveOnTwoPlaces': 'Gündə iki məkan olduqda TREK avtomobil marşrutunu hesablayır.', + 'mobileTrip.toursEmptyBody': 'Saxlanmış turlar burada görünür və günə əlavə edilə bilər.', + 'mobileTrip.toursCount': '{count} tur', + 'mobileTrip.toursCount.one': '{count} tur', 'mobileTrip.rtSearchOffline': 'İnternet bağlantısı tələb olunur: axtarış qarşıdakı marşrutu yoxlayır.', 'mobileTrip.rtBehind': 'Plandan {time} geri', 'mobileTrip.rtStart': 'Başlanğıc', diff --git a/shared/src/i18n/az/nav.ts b/shared/src/i18n/az/nav.ts index ac7a287a7..c0b74b4f5 100644 --- a/shared/src/i18n/az/nav.ts +++ b/shared/src/i18n/az/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Səyahət', 'nav.settings': 'Tənzimləmələr', 'nav.admin': 'Admin', 'nav.logout': 'Çıxış et', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 13b42cc6a..dbf289421 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -195,8 +195,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '{distance} məsafədə: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Marşrutun yüksəklik məlumatları olduqda profil göstərilir.', 'tours.planner.gpxNoGeometry': 'Bu GPX idxalı üçün marşrut həndəsəsi əlçatan deyil.', - 'tours.planner.mobileHint': - 'Tur planlaşdırmaq planşet və kompüterdə mümkündür. Burada saxlanılmış turlara baxmaq və onları günlərə təyin etmək olar.', 'tourTypes.hike': 'Yürüş', 'tourTypes.bike': 'Velosiped turu', 'tourTypes.run': 'Qaçış', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 8296226a2..cc02f6374 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -621,6 +621,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viagem de carro', 'admin.addons.catalog.roadtrip.description': 'Planeje trajetos com paradas; tempos de direção e horários de chegada se recalculam sozinhos', + 'admin.addons.catalog.tours.name': 'Passeios', + 'admin.addons.catalog.tours.description': 'Trilhas, pedaladas e outros passeios que seguem uma rota', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA', 'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ', diff --git a/shared/src/i18n/br/mobileTrip.ts b/shared/src/i18n/br/mobileTrip.ts index 3055ea577..914b652de 100644 --- a/shared/src/i18n/br/mobileTrip.ts +++ b/shared/src/i18n/br/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dados de condução', 'mobileTrip.rtDesktopNote': 'Esses dados são definidos no computador, onde você também pode comparar rotas alternativas e fazer um dia seguir uma trilha importada.', - 'mobileTrip.rtPlanOnDesktop': - 'O planejamento acontece no computador. O TREK calcula o trajeto assim que um dia tem dois locais.', + 'mobileTrip.planOnDesktop': 'Planejado no computador', + 'mobileTrip.rtDriveOnTwoPlaces': 'O TREK calcula o trajeto assim que um dia tem dois locais.', + 'mobileTrip.toursEmptyBody': 'Os passeios salvos aparecem aqui, prontos para entrar em um dia.', + 'mobileTrip.toursCount': '{count} passeios', + 'mobileTrip.toursCount.one': '{count} passeio', 'mobileTrip.rtSearchOffline': 'Precisa de conexão: a busca lê a rota à frente.', 'mobileTrip.rtBehind': '{time} de atraso em relação ao plano', 'mobileTrip.rtStart': 'Início', diff --git a/shared/src/i18n/br/nav.ts b/shared/src/i18n/br/nav.ts index 476264f0c..13f2db5f0 100644 --- a/shared/src/i18n/br/nav.ts +++ b/shared/src/i18n/br/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viagem', 'nav.settings': 'Configurações', 'nav.admin': 'Admin', 'nav.logout': 'Sair', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 1f08059d4..637435aae 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -200,8 +200,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'O perfil é exibido quando os dados de elevação da rota estão disponíveis.', 'tours.planner.gpxNoGeometry': 'A geometria da rota não está disponível para esta importação GPX.', - 'tours.planner.mobileHint': - 'O planejamento de passeios está disponível em tablets e computadores. Aqui você pode visualizar os passeios salvos e atribuí-los a um dia.', 'tourTypes.hike': 'Trilha', 'tourTypes.bike': 'Passeio de bicicleta', 'tourTypes.run': 'Corrida', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 5f404e7a4..69f4693f2 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -383,6 +383,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viatge per carretera', 'admin.addons.catalog.roadtrip.description': 'Planifica trajectes amb parades: els temps de conducció i les hores d’arribada es recalculen sols', + 'admin.addons.catalog.tours.name': 'Excursions', + 'admin.addons.catalog.tours.description': 'Caminades, sortides en bici i altres excursions que segueixen una ruta', 'admin.addons.catalog.collections.name': 'Col·leccions', 'admin.addons.catalog.collections.description': 'Recull llocs de qualsevol viatge en llistes amb nom i reutilitza’ls', 'admin.oidcOnlyMode': 'Desactivar autenticació per contrasenya', diff --git a/shared/src/i18n/ca/mobileTrip.ts b/shared/src/i18n/ca/mobileTrip.ts index 7b9b465c8..59ac634cd 100644 --- a/shared/src/i18n/ca/mobileTrip.ts +++ b/shared/src/i18n/ca/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dades de conducció', 'mobileTrip.rtDesktopNote': 'Aquestes dades es configuren a l’escriptori, on també pots comparar rutes alternatives i fer que un dia segueixi una traça importada.', - 'mobileTrip.rtPlanOnDesktop': - 'La planificació es fa a l’escriptori. TREK calcula el trajecte quan un dia té dos llocs.', + 'mobileTrip.planOnDesktop': 'Es planifica a l’ordinador', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcula el trajecte quan un dia té dos llocs.', + 'mobileTrip.toursEmptyBody': 'Les excursions desades apareixen aquí, a punt per afegir-les a un dia.', + 'mobileTrip.toursCount': '{count} excursions', + 'mobileTrip.toursCount.one': '{count} excursió', 'mobileTrip.rtSearchOffline': 'Cal connexió: la cerca llegeix la ruta que tens per endavant.', 'mobileTrip.rtBehind': '{time} de retard sobre el pla', 'mobileTrip.rtStart': 'Inici', diff --git a/shared/src/i18n/ca/nav.ts b/shared/src/i18n/ca/nav.ts index 6309341f5..1fa2316d2 100644 --- a/shared/src/i18n/ca/nav.ts +++ b/shared/src/i18n/ca/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viatge', 'nav.settings': 'Configuració', 'nav.admin': 'Administració', 'nav.logout': 'Tanca la sessió', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index f76ecfb40..e5e679504 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -200,8 +200,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': "El perfil es mostra quan hi ha dades d'altitud de la ruta disponibles.", 'tours.planner.gpxNoGeometry': 'La geometria de la ruta no està disponible per a aquesta importació GPX.', - 'tours.planner.mobileHint': - 'La planificació d’excursions està disponible en tauletes i ordinadors. Aquí pots consultar les excursions desades i assignar-les a un dia.', 'tourTypes.hike': 'Senderisme', 'tourTypes.bike': 'Ruta en bicicleta', 'tourTypes.run': 'Córrer', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 1a1b2e538..d89a214d1 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -641,6 +641,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Cesta autem', 'admin.addons.catalog.roadtrip.description': 'Plánujte jízdy se zastávkami – doby jízdy a časy příjezdu se přepočítají samy', + 'admin.addons.catalog.tours.name': 'Výlety', + 'admin.addons.catalog.tours.description': 'Pěší, cyklo a další výlety, které vedou po trase', 'admin.addons.enabled': 'Povoleno', 'admin.addons.disabled': 'Zakázáno', 'admin.addons.type.trip': 'Cesta', diff --git a/shared/src/i18n/cs/mobileTrip.ts b/shared/src/i18n/cs/mobileTrip.ts index bc2041a0e..a4d60b911 100644 --- a/shared/src/i18n/cs/mobileTrip.ts +++ b/shared/src/i18n/cs/mobileTrip.ts @@ -59,7 +59,12 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Údaje o jízdě', 'mobileTrip.rtDesktopNote': 'Tyto údaje se nastavují na počítači, kde také můžete porovnávat jiné trasy a nechat den sledovat importovanou stopu.', - 'mobileTrip.rtPlanOnDesktop': 'Plánování probíhá na počítači. Jakmile má den dvě místa, TREK jízdu spočítá.', + 'mobileTrip.planOnDesktop': 'Plánuje se na počítači', + 'mobileTrip.rtDriveOnTwoPlaces': 'Jakmile má den dvě místa, TREK jízdu spočítá.', + 'mobileTrip.toursEmptyBody': 'Uložené výlety se zobrazí tady a můžeš je přidat ke dni.', + 'mobileTrip.toursCount': '{count} výletů', + 'mobileTrip.toursCount.one': '{count} výlet', + 'mobileTrip.toursCount.few': '{count} výlety', 'mobileTrip.rtSearchOffline': 'Vyžaduje připojení: hledání čte trasu před vámi.', 'mobileTrip.rtBehind': 'o {time} za plánem', 'mobileTrip.rtStart': 'Začátek', diff --git a/shared/src/i18n/cs/nav.ts b/shared/src/i18n/cs/nav.ts index 0afe0a518..4be41d2dd 100644 --- a/shared/src/i18n/cs/nav.ts +++ b/shared/src/i18n/cs/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Cesta', 'nav.settings': 'Nastavení', 'nav.admin': 'Administrace', 'nav.logout': 'Odhlásit se', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index f6f410887..65cb9bcf1 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -198,8 +198,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Ve vzdálenosti {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil se zobrazí, až budou k dispozici výškové údaje trasy.', 'tours.planner.gpxNoGeometry': 'Pro tento import GPX není dostupná geometrie trasy.', - 'tours.planner.mobileHint': - 'Plánování výletů je dostupné na tabletu a počítači. Zde lze uložené výlety prohlížet a přiřazovat ke dnům.', 'tourTypes.hike': 'Pěší túra', 'tourTypes.bike': 'Cyklovýlet', 'tourTypes.run': 'Běh', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index d4243856f..8fb296f2d 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -626,6 +626,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Roadtrip', 'admin.addons.catalog.roadtrip.description': 'Fahrten mit Zwischenstopps planen — Fahrzeiten und Ankunftszeiten rechnen sich selbst neu', + 'admin.addons.catalog.tours.name': 'Touren', + 'admin.addons.catalog.tours.description': 'Wanderungen, Radtouren und andere Touren entlang einer Route', 'admin.addons.catalog.memories.name': 'Fotos (Immich)', 'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/de/mobileTrip.ts b/shared/src/i18n/de/mobileTrip.ts index 5e70ecce4..bcdf44b11 100644 --- a/shared/src/i18n/de/mobileTrip.ts +++ b/shared/src/i18n/de/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Fahrdaten', 'mobileTrip.rtDesktopNote': 'Diese Werte werden am Desktop gesetzt. Dort kannst du auch Routenvarianten vergleichen und einen Tag einer importierten Spur folgen lassen.', - 'mobileTrip.rtPlanOnDesktop': 'Geplant wird am Desktop. TREK berechnet die Fahrt, sobald ein Tag zwei Orte hat.', + 'mobileTrip.planOnDesktop': 'Geplant wird am Desktop', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK berechnet die Fahrt, sobald ein Tag zwei Orte hat.', + 'mobileTrip.toursEmptyBody': 'Gespeicherte Touren erscheinen hier und lassen sich einem Tag zuordnen.', + 'mobileTrip.toursCount': '{count} Touren', + 'mobileTrip.toursCount.one': '{count} Tour', 'mobileTrip.rtSearchOffline': 'Braucht eine Verbindung: Die Suche liest die Strecke, die vor dir liegt.', 'mobileTrip.rtBehind': '{time} hinter dem Plan', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/de/nav.ts b/shared/src/i18n/de/nav.ts index c3f0bc6d9..f35a4cb0a 100644 --- a/shared/src/i18n/de/nav.ts +++ b/shared/src/i18n/de/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Reise', 'nav.settings': 'Einstellungen', 'nav.admin': 'Admin', 'nav.logout': 'Abmelden', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index dc880cb4a..2dadbee81 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -201,8 +201,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Das Höhenprofil erscheint, sobald Höhendaten für die Route verfügbar sind.', 'tours.planner.gpxNoGeometry': 'Für diesen GPX-Import sind keine Routendaten verfügbar.', - 'tours.planner.mobileHint': - 'Touren planen kannst du auf einem Tablet oder am Computer. Hier kannst du gespeicherte Touren ansehen und zuordnen.', 'tourTypes.hike': 'Wanderung', 'tourTypes.bike': 'Radtour', 'tourTypes.run': 'Lauf', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 075fdd762..63d6bbc17 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -672,6 +672,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Road trip', 'admin.addons.catalog.roadtrip.description': 'Plan drives with stops along the route, driving times, and arrival times that update themselves', + 'admin.addons.catalog.tours.name': 'Tours', + 'admin.addons.catalog.tours.description': 'Hikes, rides and other tours that follow a route', 'admin.addons.catalog.memories.name': 'Photos (Immich)', 'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/en/mobileTrip.ts b/shared/src/i18n/en/mobileTrip.ts index 5b52a1934..c72a9a579 100644 --- a/shared/src/i18n/en/mobileTrip.ts +++ b/shared/src/i18n/en/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Driving figures', 'mobileTrip.rtDesktopNote': 'These figures are set on the desktop, where you can also compare route alternatives and make a day follow an imported track.', - 'mobileTrip.rtPlanOnDesktop': 'Planning happens on the desktop. TREK works out the drive once a day has two places.', + 'mobileTrip.planOnDesktop': 'Planned on desktop', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK works out the drive once a day has two places.', + 'mobileTrip.toursEmptyBody': 'Saved tours show up here, ready to put on a day.', + 'mobileTrip.toursCount': '{count} tours', + 'mobileTrip.toursCount.one': '{count} tour', 'mobileTrip.rtSearchOffline': 'Needs a connection: the search reads the route ahead.', 'mobileTrip.rtBehind': '{time} behind plan', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/en/nav.ts b/shared/src/i18n/en/nav.ts index 6855368e2..c702206fc 100644 --- a/shared/src/i18n/en/nav.ts +++ b/shared/src/i18n/en/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Trip', 'nav.settings': 'Settings', 'nav.admin': 'Admin', 'nav.logout': 'Log out', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 8fc40dc1f..d95682966 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -194,8 +194,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'At {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'The profile is shown when route elevation data is available.', 'tours.planner.gpxNoGeometry': 'Route geometry is unavailable for this GPX import.', - 'tours.planner.mobileHint': - 'Tour planning is available on tablet and desktop. Saved tours can be viewed and scheduled here.', 'tourTypes.hike': 'Hike', 'tourTypes.bike': 'Bike tour', 'tourTypes.run': 'Run', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index d112f41e5..e279af1f1 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -766,6 +766,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viaje por carretera', 'admin.addons.catalog.roadtrip.description': 'Planifica trayectos con paradas: los tiempos de conducción y las horas de llegada se recalculan solos', + 'admin.addons.catalog.tours.name': 'Excursiones', + 'admin.addons.catalog.tours.description': 'Senderismo, rutas en bici y otras excursiones que siguen un recorrido', 'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña', 'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.', diff --git a/shared/src/i18n/es/mobileTrip.ts b/shared/src/i18n/es/mobileTrip.ts index ae9949e55..fe8849604 100644 --- a/shared/src/i18n/es/mobileTrip.ts +++ b/shared/src/i18n/es/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Cifras del trayecto', 'mobileTrip.rtDesktopNote': 'Estas cifras se ajustan en el escritorio, donde también puedes comparar rutas alternativas y hacer que un día siga una traza importada.', - 'mobileTrip.rtPlanOnDesktop': - 'La planificación se hace en el escritorio. TREK calcula el trayecto en cuanto un día tiene dos lugares.', + 'mobileTrip.planOnDesktop': 'Se planifica en el ordenador', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcula el trayecto en cuanto un día tiene dos lugares.', + 'mobileTrip.toursEmptyBody': 'Las excursiones guardadas aparecen aquí, listas para añadirlas a un día.', + 'mobileTrip.toursCount': '{count} excursiones', + 'mobileTrip.toursCount.one': '{count} excursión', 'mobileTrip.rtSearchOffline': 'Hace falta conexión: la búsqueda lee la ruta que queda por delante.', 'mobileTrip.rtBehind': '{time} de retraso sobre lo previsto', 'mobileTrip.rtStart': 'Inicio', diff --git a/shared/src/i18n/es/nav.ts b/shared/src/i18n/es/nav.ts index ef14bec40..ce7f11d50 100644 --- a/shared/src/i18n/es/nav.ts +++ b/shared/src/i18n/es/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viaje', 'nav.settings': 'Ajustes', 'nav.admin': 'Administración', 'nav.logout': 'Cerrar sesión', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 1fe79b827..49ed4c10d 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -200,8 +200,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'A {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'El perfil se muestra cuando hay datos de altitud de la ruta.', 'tours.planner.gpxNoGeometry': 'La geometría de la ruta no está disponible para esta importación GPX.', - 'tours.planner.mobileHint': - 'La planificación de excursiones está disponible en tabletas y ordenadores. Aquí puedes consultar las excursiones guardadas y asignarlas a un día.', 'tourTypes.hike': 'Senderismo', 'tourTypes.bike': 'Ruta en bicicleta', 'tourTypes.run': 'Carrera a pie', diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index b6ff1a3af..98f6799b4 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -690,6 +690,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Autoreis', 'admin.addons.catalog.roadtrip.description': 'Planeeri sõite koos marsruudipeatuste, sõiduaegade ja automaatselt uuenevate saabumisaegadega', + 'admin.addons.catalog.tours.name': 'Retked', + 'admin.addons.catalog.tours.description': 'Matkad, rattasõidud ja muud retked, mis järgivad marsruuti', 'admin.addons.catalog.memories.name': 'Fotod (Immich)', 'admin.addons.catalog.memories.description': 'Jaga reisifotosid oma Immichi paigalduse kaudu', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/et/mobileTrip.ts b/shared/src/i18n/et/mobileTrip.ts index bf1ea4df3..8e2d92ec5 100644 --- a/shared/src/i18n/et/mobileTrip.ts +++ b/shared/src/i18n/et/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Sõiduandmed', 'mobileTrip.rtDesktopNote': 'Neid andmeid saab määrata töölauavaates, kus saad võrrelda ka alternatiivseid marsruute ja määrata päeva järgima imporditud rada.', - 'mobileTrip.rtPlanOnDesktop': - 'Planeerimine toimub töölauavaates. TREK arvutab sõidu, kui päeval on vähemalt kaks kohta.', + 'mobileTrip.planOnDesktop': 'Planeeritakse arvutis', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK arvutab sõidu, kui päeval on vähemalt kaks kohta.', + 'mobileTrip.toursEmptyBody': 'Salvestatud retked ilmuvad siia ja neid saab päevale lisada.', + 'mobileTrip.toursCount': '{count} retke', + 'mobileTrip.toursCount.one': '{count} retk', 'mobileTrip.rtSearchOffline': 'Vajab ühendust: otsing kasutab eesolevat marsruuti.', 'mobileTrip.rtBehind': '{time} plaanist maas', 'mobileTrip.rtStart': 'Algus', diff --git a/shared/src/i18n/et/nav.ts b/shared/src/i18n/et/nav.ts index b3de48c0b..2e1664b3d 100644 --- a/shared/src/i18n/et/nav.ts +++ b/shared/src/i18n/et/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Reis', 'nav.settings': 'Seaded', 'nav.admin': 'Haldus', 'nav.logout': 'Logi välja', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index fde277867..d8f048617 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -197,8 +197,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Kaugusel {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profiil kuvatakse, kui marsruudi kõrgusandmed on saadaval.', 'tours.planner.gpxNoGeometry': 'Selle GPX-impordi marsruudi geomeetria pole saadaval.', - 'tours.planner.mobileHint': - 'Retki saab planeerida tahvel- ja lauaarvutis. Siin saab salvestatud retki vaadata ja päevadele lisada.', 'tourTypes.hike': 'Matk', 'tourTypes.bike': 'Rattaretk', 'tourTypes.run': 'Jooks', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 38d16af29..c5f0e0ed7 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -635,6 +635,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Road trip', 'admin.addons.catalog.roadtrip.description': 'Planifiez des trajets avec étapes : temps de conduite et heures d’arrivée se recalculent seuls', + 'admin.addons.catalog.tours.name': 'Sorties', + 'admin.addons.catalog.tours.description': 'Randonnées, balades à vélo et autres sorties qui suivent un itinéraire', 'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ', 'admin.addons.subtitleAfter': '.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/fr/mobileTrip.ts b/shared/src/i18n/fr/mobileTrip.ts index 3d2301e77..b21c066de 100644 --- a/shared/src/i18n/fr/mobileTrip.ts +++ b/shared/src/i18n/fr/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Chiffres du trajet', 'mobileTrip.rtDesktopNote': 'Ces chiffres se règlent sur ordinateur, où vous pouvez aussi comparer d’autres itinéraires et faire suivre à un jour une trace importée.', - 'mobileTrip.rtPlanOnDesktop': - 'La planification se fait sur ordinateur. TREK calcule le trajet dès qu’un jour compte deux lieux.', + 'mobileTrip.planOnDesktop': 'Planifié sur ordinateur', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcule le trajet dès qu’un jour compte deux lieux.', + 'mobileTrip.toursEmptyBody': 'Les sorties enregistrées apparaissent ici, prêtes à être ajoutées à un jour.', + 'mobileTrip.toursCount': '{count} sorties', + 'mobileTrip.toursCount.one': '{count} sortie', 'mobileTrip.rtSearchOffline': 'Connexion nécessaire : la recherche lit l’itinéraire à venir.', 'mobileTrip.rtBehind': '{time} de retard sur le programme', 'mobileTrip.rtStart': 'Début', diff --git a/shared/src/i18n/fr/nav.ts b/shared/src/i18n/fr/nav.ts index 9f96508f6..4bb3d278a 100644 --- a/shared/src/i18n/fr/nav.ts +++ b/shared/src/i18n/fr/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Voyage', 'nav.settings': 'Paramètres', 'nav.admin': 'Admin', 'nav.logout': 'Déconnexion', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index f475fe637..86f4931f3 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -203,8 +203,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': "Le profil s'affiche lorsque les données d'altitude de l'itinéraire sont disponibles.", 'tours.planner.gpxNoGeometry': "La géométrie de l'itinéraire est indisponible pour cet import GPX.", - 'tours.planner.mobileHint': - 'La planification des sorties est disponible sur tablette et ordinateur. Vous pouvez consulter les sorties enregistrées et les affecter à une journée ici.', 'tourTypes.hike': 'Randonnée', 'tourTypes.bike': 'Sortie à vélo', 'tourTypes.run': 'Course à pied', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 68e9682fa..33be714cc 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -691,6 +691,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Οδικό ταξίδι', 'admin.addons.catalog.roadtrip.description': 'Σχεδιάστε διαδρομές με στάσεις — οι χρόνοι οδήγησης και άφιξης υπολογίζονται ξανά αυτόματα', + 'admin.addons.catalog.tours.name': 'Εκδρομές', + 'admin.addons.catalog.tours.description': 'Πεζοπορίες, ποδηλατάδες και άλλες εκδρομές που ακολουθούν μια διαδρομή', 'admin.addons.catalog.memories.name': 'Φωτογραφίες (Immich)', 'admin.addons.catalog.memories.description': 'Μοιραστείτε φωτογραφίες ταξιδιού μέσω της δικής σας εγκατάστασης Immich', diff --git a/shared/src/i18n/gr/mobileTrip.ts b/shared/src/i18n/gr/mobileTrip.ts index c7db3f862..cccdde91b 100644 --- a/shared/src/i18n/gr/mobileTrip.ts +++ b/shared/src/i18n/gr/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Στοιχεία οδήγησης', 'mobileTrip.rtDesktopNote': 'Αυτά τα στοιχεία ορίζονται στον υπολογιστή, όπου μπορείτε επίσης να συγκρίνετε άλλες διαδρομές και να κάνετε μια ημέρα να ακολουθεί ένα εισαγόμενο ίχνος.', - 'mobileTrip.rtPlanOnDesktop': - 'Ο σχεδιασμός γίνεται στον υπολογιστή. Το TREK υπολογίζει τη διαδρομή μόλις μια ημέρα έχει δύο μέρη.', + 'mobileTrip.planOnDesktop': 'Σχεδιάζεται στον υπολογιστή', + 'mobileTrip.rtDriveOnTwoPlaces': 'Το TREK υπολογίζει τη διαδρομή μόλις μια ημέρα έχει δύο μέρη.', + 'mobileTrip.toursEmptyBody': 'Οι αποθηκευμένες εκδρομές εμφανίζονται εδώ, έτοιμες να μπουν σε μια ημέρα.', + 'mobileTrip.toursCount': '{count} εκδρομές', + 'mobileTrip.toursCount.one': '{count} εκδρομή', 'mobileTrip.rtSearchOffline': 'Χρειάζεται σύνδεση: η αναζήτηση διαβάζει τη διαδρομή που ακολουθεί.', 'mobileTrip.rtBehind': '{time} πίσω από το πρόγραμμα', 'mobileTrip.rtStart': 'Έναρξη', diff --git a/shared/src/i18n/gr/nav.ts b/shared/src/i18n/gr/nav.ts index 5a146eaf1..dd0aedf8a 100644 --- a/shared/src/i18n/gr/nav.ts +++ b/shared/src/i18n/gr/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Ταξίδι', 'nav.settings': 'Ρυθμίσεις', 'nav.admin': 'Διαχείριση', 'nav.logout': 'Αποσύνδεση', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index a9af03eac..f72574456 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -204,8 +204,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Το προφίλ εμφανίζεται όταν είναι διαθέσιμα τα υψομετρικά δεδομένα της διαδρομής.', 'tours.planner.gpxNoGeometry': 'Η γεωμετρία της διαδρομής δεν είναι διαθέσιμη για αυτήν την εισαγωγή GPX.', - 'tours.planner.mobileHint': - 'Ο σχεδιασμός εκδρομών είναι διαθέσιμος σε τάμπλετ και υπολογιστή. Εδώ μπορείτε να βλέπετε τις αποθηκευμένες εκδρομές και να τις αντιστοιχίζετε σε ημέρες.', 'tourTypes.hike': 'Πεζοπορία', 'tourTypes.bike': 'Ποδηλατική εκδρομή', 'tourTypes.run': 'Τρέξιμο', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 7369726dc..b66dea2e0 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -631,6 +631,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Autós út', 'admin.addons.catalog.roadtrip.description': 'Tervezz megállókkal tarkított utakat – a vezetési és érkezési idők maguktól újraszámolódnak', + 'admin.addons.catalog.tours.name': 'Túrák', + 'admin.addons.catalog.tours.description': 'Gyalog-, kerékpár- és más túrák, amelyek útvonalat követnek', 'admin.addons.catalog.memories.name': 'Fotók (Immich)', 'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/hu/mobileTrip.ts b/shared/src/i18n/hu/mobileTrip.ts index a214235ca..0061ff623 100644 --- a/shared/src/i18n/hu/mobileTrip.ts +++ b/shared/src/i18n/hu/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Vezetési adatok', 'mobileTrip.rtDesktopNote': 'Ezeket az adatokat asztali gépen adod meg, ott hasonlíthatod össze az útvonalváltozatokat, és állíthatod be, hogy egy nap importált nyomvonalat kövessen.', - 'mobileTrip.rtPlanOnDesktop': - 'A tervezés asztali gépen történik. Amint egy napon két hely szerepel, a TREK kiszámolja az útvonalat.', + 'mobileTrip.planOnDesktop': 'Tervezés számítógépen', + 'mobileTrip.rtDriveOnTwoPlaces': 'Amint egy napon két hely szerepel, a TREK kiszámolja az útvonalat.', + 'mobileTrip.toursEmptyBody': 'A mentett túrák itt jelennek meg, és egy naphoz rendelhetők.', + 'mobileTrip.toursCount': '{count} túra', + 'mobileTrip.toursCount.one': '{count} túra', 'mobileTrip.rtSearchOffline': 'Internetkapcsolat kell hozzá: a keresés az előtted lévő útvonalat olvassa be.', 'mobileTrip.rtBehind': '{time} késés a tervhez képest', 'mobileTrip.rtStart': 'Kezdés', diff --git a/shared/src/i18n/hu/nav.ts b/shared/src/i18n/hu/nav.ts index a4df55c2d..8f4da6a12 100644 --- a/shared/src/i18n/hu/nav.ts +++ b/shared/src/i18n/hu/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Utazás', 'nav.settings': 'Beállítások', 'nav.admin': 'Admin', 'nav.logout': 'Kijelentkezés', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index fd0732290..b9214f575 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'A profil akkor jelenik meg, ha rendelkezésre állnak az útvonal magassági adatai.', 'tours.planner.gpxNoGeometry': 'Ehhez a GPX-importhoz nem érhető el útvonalgeometria.', - 'tours.planner.mobileHint': - 'A túratervezés táblagépen és számítógépen érhető el. Itt megtekintheted és napokhoz rendelheted a mentett túrákat.', 'tourTypes.hike': 'Gyalogtúra', 'tourTypes.bike': 'Kerékpártúra', 'tourTypes.run': 'Futás', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index ece9c4b93..b00d1214c 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -672,6 +672,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Perjalanan darat', 'admin.addons.catalog.roadtrip.description': 'Rencanakan perjalanan dengan perhentian; waktu berkendara dan tiba dihitung ulang otomatis', + 'admin.addons.catalog.tours.name': 'Tur', + 'admin.addons.catalog.tours.description': 'Pendakian, bersepeda, dan tur lain yang mengikuti rute', 'admin.addons.catalog.memories.name': 'Foto (Immich)', 'admin.addons.catalog.memories.description': 'Bagikan foto perjalanan melalui instans Immich kamu', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/id/mobileTrip.ts b/shared/src/i18n/id/mobileTrip.ts index 7a0404732..71ddb4239 100644 --- a/shared/src/i18n/id/mobileTrip.ts +++ b/shared/src/i18n/id/mobileTrip.ts @@ -53,8 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Data berkendara', 'mobileTrip.rtDesktopNote': 'Data ini diatur di desktop, di sana kamu juga bisa membandingkan rute alternatif dan membuat satu hari mengikuti jalur yang diimpor.', - 'mobileTrip.rtPlanOnDesktop': - 'Perencanaan dilakukan di desktop. TREK menghitung perjalanannya begitu satu hari punya dua tempat.', + 'mobileTrip.planOnDesktop': 'Direncanakan di komputer', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK menghitung perjalanannya begitu satu hari punya dua tempat.', + 'mobileTrip.toursEmptyBody': 'Tur yang tersimpan muncul di sini, siap ditambahkan ke sebuah hari.', + 'mobileTrip.toursCount': '{count} tur', 'mobileTrip.rtSearchOffline': 'Perlu koneksi: pencarian membaca rute di depan.', 'mobileTrip.rtBehind': 'terlambat {time} dari rencana', 'mobileTrip.rtStart': 'Mulai', diff --git a/shared/src/i18n/id/nav.ts b/shared/src/i18n/id/nav.ts index 6ab97f1e3..11628ae89 100644 --- a/shared/src/i18n/id/nav.ts +++ b/shared/src/i18n/id/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Perjalanan', 'nav.settings': 'Pengaturan', 'nav.admin': 'Admin', 'nav.logout': 'Keluar', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index c25e01c51..ba3b37002 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -193,8 +193,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Pada jarak {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil ditampilkan saat data elevasi rute tersedia.', 'tours.planner.gpxNoGeometry': 'Geometri rute tidak tersedia untuk impor GPX ini.', - 'tours.planner.mobileHint': - 'Perencanaan tur tersedia di tablet dan komputer. Tur tersimpan dapat dilihat dan dijadwalkan di sini.', 'tourTypes.hike': 'Pendakian', 'tourTypes.bike': 'Tur sepeda', 'tourTypes.run': 'Lari', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index ce09fe2bf..1ef068188 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -626,6 +626,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viaggio su strada', 'admin.addons.catalog.roadtrip.description': 'Pianifica viaggi con soste: tempi di guida e orari di arrivo si ricalcolano da soli', + 'admin.addons.catalog.tours.name': 'Escursioni', + 'admin.addons.catalog.tours.description': 'Escursioni a piedi, in bici e altre uscite che seguono un percorso', 'admin.addons.catalog.memories.name': 'Foto (Immich)', 'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/it/mobileTrip.ts b/shared/src/i18n/it/mobileTrip.ts index afdc9b350..acb6e8a9a 100644 --- a/shared/src/i18n/it/mobileTrip.ts +++ b/shared/src/i18n/it/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dati del tragitto', 'mobileTrip.rtDesktopNote': 'Questi dati si impostano da computer, dove puoi anche confrontare percorsi alternativi e far seguire a un giorno una traccia importata.', - 'mobileTrip.rtPlanOnDesktop': - 'La pianificazione si fa da computer. TREK calcola il tragitto appena un giorno ha due luoghi.', + 'mobileTrip.planOnDesktop': 'Si pianifica dal computer', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcola il tragitto appena un giorno ha due luoghi.', + 'mobileTrip.toursEmptyBody': 'Le escursioni salvate compaiono qui, pronte da aggiungere a un giorno.', + 'mobileTrip.toursCount': '{count} escursioni', + 'mobileTrip.toursCount.one': '{count} escursione', 'mobileTrip.rtSearchOffline': 'Serve una connessione: la ricerca legge il percorso che hai davanti.', 'mobileTrip.rtBehind': '{time} di ritardo sul programma', 'mobileTrip.rtStart': 'Inizio', diff --git a/shared/src/i18n/it/nav.ts b/shared/src/i18n/it/nav.ts index 6a4230351..089232e2a 100644 --- a/shared/src/i18n/it/nav.ts +++ b/shared/src/i18n/it/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viaggio', 'nav.settings': 'Impostazioni', 'nav.admin': 'Amministrazione', 'nav.logout': 'Esci', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 2e5544c69..189d11bba 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Il profilo viene mostrato quando sono disponibili i dati altimetrici del percorso.', 'tours.planner.gpxNoGeometry': 'La geometria del percorso non è disponibile per questa importazione GPX.', - 'tours.planner.mobileHint': - 'La pianificazione delle escursioni è disponibile su tablet e computer. Qui puoi visualizzare le escursioni salvate e assegnarle a un giorno.', 'tourTypes.hike': 'Escursione a piedi', 'tourTypes.bike': 'Escursione in bici', 'tourTypes.run': 'Corsa', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index a2ec1970c..58e9a60bf 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -636,6 +636,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'ロードトリップ', 'admin.addons.catalog.roadtrip.description': '立ち寄り先付きのドライブを計画。運転時間と到着時刻は自動で再計算されます', + 'admin.addons.catalog.tours.name': 'ツアー', + 'admin.addons.catalog.tours.description': 'ルートに沿ったハイキング、サイクリングなどのツアー', 'admin.addons.catalog.memories.name': '写真(Immich)', 'admin.addons.catalog.memories.description': 'Immichで旅行写真を共有', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/ja/mobileTrip.ts b/shared/src/i18n/ja/mobileTrip.ts index df955107e..dc3ad08f2 100644 --- a/shared/src/i18n/ja/mobileTrip.ts +++ b/shared/src/i18n/ja/mobileTrip.ts @@ -53,7 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': '走行データ', 'mobileTrip.rtDesktopNote': 'これらの数値はデスクトップで設定します。デスクトップでは、別のルートを比べたり、読み込んだトラックにその日を沿わせたりもできます。', - 'mobileTrip.rtPlanOnDesktop': '計画はデスクトップで行います。1日に場所が2か所そろうと、TREKが走行を計算します。', + 'mobileTrip.planOnDesktop': '計画はデスクトップで', + 'mobileTrip.rtDriveOnTwoPlaces': '1日に場所が2か所そろうと、TREKが走行を計算します。', + 'mobileTrip.toursEmptyBody': '保存したツアーがここに表示され、日に追加できます。', + 'mobileTrip.toursCount': '{count} 件のツアー', 'mobileTrip.rtSearchOffline': '接続が必要です:この検索は先のルートを読み取ります。', 'mobileTrip.rtBehind': '予定より {time} 遅れ', 'mobileTrip.rtStart': '開始', diff --git a/shared/src/i18n/ja/nav.ts b/shared/src/i18n/ja/nav.ts index 9845634eb..6903c7495 100644 --- a/shared/src/i18n/ja/nav.ts +++ b/shared/src/i18n/ja/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '旅行', 'nav.settings': '設定', 'nav.admin': '管理', 'nav.logout': 'ログアウト', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 65bdf10f4..b2f8cdc1d 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -190,8 +190,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '距離{distance}:標高{elevation}', 'tours.planner.inspector.elevationPlaceholder': 'ルートの標高データが取得できると、グラフが表示されます。', 'tours.planner.gpxNoGeometry': 'このGPXインポートにはルートの形状データがありません。', - 'tours.planner.mobileHint': - 'ツアーの計画はタブレットとパソコンで利用できます。ここでは保存済みのツアーを閲覧し、日程に割り当てられます。', 'tourTypes.hike': 'ハイキング', 'tourTypes.bike': 'サイクリング', 'tourTypes.run': 'ランニング', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index bc4db198a..f002bf217 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -635,6 +635,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': '로드트립', 'admin.addons.catalog.roadtrip.description': '경유지가 있는 주행을 계획하세요. 운전 시간과 도착 시간이 자동으로 다시 계산됩니다', + 'admin.addons.catalog.tours.name': '투어', + 'admin.addons.catalog.tours.description': '경로를 따라가는 하이킹, 라이딩 등 투어', 'admin.addons.catalog.memories.name': '사진 (Immich)', 'admin.addons.catalog.memories.description': 'Immich 인스턴스를 통해 여행 사진 공유', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/ko/mobileTrip.ts b/shared/src/i18n/ko/mobileTrip.ts index 300d87423..7b04a06f1 100644 --- a/shared/src/i18n/ko/mobileTrip.ts +++ b/shared/src/i18n/ko/mobileTrip.ts @@ -53,7 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': '주행 수치', 'mobileTrip.rtDesktopNote': '이 수치는 데스크톱에서 설정합니다. 데스크톱에서는 다른 경로를 비교하고, 가져온 트랙을 따라가도록 하루를 맞출 수도 있습니다.', - 'mobileTrip.rtPlanOnDesktop': '계획은 데스크톱에서 세웁니다. 하루에 장소가 두 곳 생기면 TREK이 주행을 계산합니다.', + 'mobileTrip.planOnDesktop': '계획은 데스크톱에서', + 'mobileTrip.rtDriveOnTwoPlaces': '하루에 장소가 두 곳 생기면 TREK이 주행을 계산합니다.', + 'mobileTrip.toursEmptyBody': '저장된 투어가 여기에 표시되며 날짜에 추가할 수 있습니다.', + 'mobileTrip.toursCount': '투어 {count}개', 'mobileTrip.rtSearchOffline': '연결이 필요합니다: 이 검색은 앞으로 갈 경로를 읽습니다.', 'mobileTrip.rtBehind': '계획보다 {time} 늦음', 'mobileTrip.rtStart': '시작', diff --git a/shared/src/i18n/ko/nav.ts b/shared/src/i18n/ko/nav.ts index a92ac2d36..b4afeac11 100644 --- a/shared/src/i18n/ko/nav.ts +++ b/shared/src/i18n/ko/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '여행', 'nav.settings': '설정', 'nav.admin': '관리자', 'nav.logout': '로그아웃', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index dde5c851d..754dbfc4c 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -188,8 +188,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '{distance} 지점: {elevation}', 'tours.planner.inspector.elevationPlaceholder': '경로의 고도 데이터가 있으면 그래프가 표시됩니다.', 'tours.planner.gpxNoGeometry': '이 GPX 가져오기에는 경로 형상 데이터가 없습니다.', - 'tours.planner.mobileHint': - '투어 계획은 태블릿과 컴퓨터에서 이용할 수 있습니다. 여기에서는 저장된 투어를 보고 일정에 배정할 수 있습니다.', 'tourTypes.hike': '하이킹', 'tourTypes.bike': '자전거 투어', 'tourTypes.run': '달리기', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index f296faf2e..6b80bfea0 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -629,6 +629,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Roadtrip', 'admin.addons.catalog.roadtrip.description': 'Plan ritten met tussenstops; rijtijden en aankomsttijden berekenen zichzelf opnieuw', + 'admin.addons.catalog.tours.name': 'Tochten', + 'admin.addons.catalog.tours.description': 'Wandelingen, fietstochten en andere tochten langs een route', 'admin.addons.subtitleBefore': 'Schakel functies in of uit om je ', 'admin.addons.subtitleAfter': '-ervaring aan te passen.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/nl/mobileTrip.ts b/shared/src/i18n/nl/mobileTrip.ts index b8eafba90..b0a4fd225 100644 --- a/shared/src/i18n/nl/mobileTrip.ts +++ b/shared/src/i18n/nl/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Rijgegevens', 'mobileTrip.rtDesktopNote': 'Deze waarden stel je in op de desktop. Daar kun je ook routevarianten vergelijken en een dag een geïmporteerde track laten volgen.', - 'mobileTrip.rtPlanOnDesktop': 'Plannen doe je op de desktop. TREK berekent de rit zodra een dag twee plaatsen heeft.', + 'mobileTrip.planOnDesktop': 'Plannen op de computer', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK berekent de rit zodra een dag twee plaatsen heeft.', + 'mobileTrip.toursEmptyBody': 'Opgeslagen tochten verschijnen hier en kun je aan een dag toevoegen.', + 'mobileTrip.toursCount': '{count} tochten', + 'mobileTrip.toursCount.one': '{count} tocht', 'mobileTrip.rtSearchOffline': 'Vereist een verbinding: de zoekopdracht leest de route die voor je ligt.', 'mobileTrip.rtBehind': '{time} achter op schema', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/nl/nav.ts b/shared/src/i18n/nl/nav.ts index 555d09580..304a92f7b 100644 --- a/shared/src/i18n/nl/nav.ts +++ b/shared/src/i18n/nl/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Reis', 'nav.settings': 'Instellingen', 'nav.admin': 'Admin', 'nav.logout': 'Uitloggen', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 60a875049..188e69c5c 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Het profiel wordt getoond zodra hoogtegegevens voor de route beschikbaar zijn.', 'tours.planner.gpxNoGeometry': 'De routegeometrie is niet beschikbaar voor deze GPX-import.', - 'tours.planner.mobileHint': - 'Tochten plannen kan op een tablet of computer. Hier kun je opgeslagen tochten bekijken en aan een dag toewijzen.', 'tourTypes.hike': 'Wandeling', 'tourTypes.bike': 'Fietstocht', 'tourTypes.run': 'Hardlooptocht', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 2b43ed06f..623cfeceb 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -641,6 +641,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Podróż samochodowa', 'admin.addons.catalog.roadtrip.description': 'Planuj przejazdy z przystankami — czasy jazdy i godziny przyjazdu przeliczają się same', + 'admin.addons.catalog.tours.name': 'Wycieczki', + 'admin.addons.catalog.tours.description': 'Wędrówki, wycieczki rowerowe i inne trasy', 'admin.addons.catalog.memories.name': 'Zdjęcia (Immich)', 'admin.addons.catalog.memories.description': 'Udostępniaj zdjęcia z podróży za pośrednictwem swojej instancji Immich', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/pl/mobileTrip.ts b/shared/src/i18n/pl/mobileTrip.ts index b5a724752..7f5905399 100644 --- a/shared/src/i18n/pl/mobileTrip.ts +++ b/shared/src/i18n/pl/mobileTrip.ts @@ -62,7 +62,13 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dane jazdy', 'mobileTrip.rtDesktopNote': 'Te dane ustawia się na komputerze, gdzie możesz też porównywać warianty trasy i sprawić, by dzień podążał za zaimportowanym śladem.', - 'mobileTrip.rtPlanOnDesktop': 'Planowanie odbywa się na komputerze. TREK wylicza przejazd, gdy dzień ma dwa miejsca.', + 'mobileTrip.planOnDesktop': 'Planowanie na komputerze', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK wylicza przejazd, gdy dzień ma dwa miejsca.', + 'mobileTrip.toursEmptyBody': 'Zapisane wycieczki pojawią się tutaj i można je dodać do dnia.', + 'mobileTrip.toursCount': '{count} wycieczki', + 'mobileTrip.toursCount.one': '{count} wycieczka', + 'mobileTrip.toursCount.few': '{count} wycieczki', + 'mobileTrip.toursCount.many': '{count} wycieczek', 'mobileTrip.rtSearchOffline': 'Wymaga połączenia: wyszukiwanie czyta trasę przed tobą.', 'mobileTrip.rtBehind': '{time} opóźnienia względem planu', 'mobileTrip.rtStart': 'Początek', diff --git a/shared/src/i18n/pl/nav.ts b/shared/src/i18n/pl/nav.ts index fcad104e1..b63783932 100644 --- a/shared/src/i18n/pl/nav.ts +++ b/shared/src/i18n/pl/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Podróż', 'nav.settings': 'Ustawienia', 'nav.admin': 'Admin', 'nav.logout': 'Wyloguj', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index fa086ff55..f615e6525 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -207,8 +207,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Na dystansie {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil pojawi się, gdy będą dostępne dane wysokościowe trasy.', 'tours.planner.gpxNoGeometry': 'Geometria trasy jest niedostępna dla tego importu GPX.', - 'tours.planner.mobileHint': - 'Planowanie wycieczek jest dostępne na tablecie i komputerze. Tutaj możesz przeglądać zapisane wycieczki i przypisywać je do dni.', 'tourTypes.hike': 'Wędrówka piesza', 'tourTypes.bike': 'Wycieczka rowerowa', 'tourTypes.run': 'Bieg', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 51f2ecbb5..b16c259c0 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -650,6 +650,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Автопутешествие', 'admin.addons.catalog.roadtrip.description': 'Планируйте поездки с остановками: время в пути и прибытия пересчитывается автоматически', + 'admin.addons.catalog.tours.name': 'Туры', + 'admin.addons.catalog.tours.description': 'Походы, велопрогулки и другие туры по маршруту', 'admin.addons.subtitleBefore': 'Включайте или отключайте функции для настройки ', 'admin.addons.subtitleAfter': ' под себя.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/ru/mobileTrip.ts b/shared/src/i18n/ru/mobileTrip.ts index 1f0c7e502..63cfc423a 100644 --- a/shared/src/i18n/ru/mobileTrip.ts +++ b/shared/src/i18n/ru/mobileTrip.ts @@ -62,8 +62,13 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Данные о вождении', 'mobileTrip.rtDesktopNote': 'Эти данные задаются на компьютере: там же можно сравнивать другие маршруты и провести день по импортированному треку.', - 'mobileTrip.rtPlanOnDesktop': - 'Планирование происходит на компьютере. TREK рассчитает маршрут, как только в дне окажется два места.', + 'mobileTrip.planOnDesktop': 'Планирование на компьютере', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK рассчитает маршрут, как только в дне окажется два места.', + 'mobileTrip.toursEmptyBody': 'Сохранённые туры появятся здесь, их можно добавить в день.', + 'mobileTrip.toursCount': '{count} тура', + 'mobileTrip.toursCount.one': '{count} тур', + 'mobileTrip.toursCount.few': '{count} тура', + 'mobileTrip.toursCount.many': '{count} туров', 'mobileTrip.rtSearchOffline': 'Нужна сеть: поиск считывает маршрут впереди.', 'mobileTrip.rtBehind': 'на {time} позже плана', 'mobileTrip.rtStart': 'Начало', diff --git a/shared/src/i18n/ru/nav.ts b/shared/src/i18n/ru/nav.ts index 4c95a043f..7fdac1ebc 100644 --- a/shared/src/i18n/ru/nav.ts +++ b/shared/src/i18n/ru/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Поездка', 'nav.settings': 'Настройки', 'nav.admin': 'Администрирование', 'nav.logout': 'Выйти', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index b5f7c9fc5..70ed4aeb4 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -209,8 +209,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'На расстоянии {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Профиль появится, когда будут доступны данные о высоте маршрута.', 'tours.planner.gpxNoGeometry': 'Геометрия маршрута недоступна для этого импорта GPX.', - 'tours.planner.mobileHint': - 'Планирование туров доступно на планшете и компьютере. Здесь можно просматривать сохранённые туры и назначать их на дни.', 'tourTypes.hike': 'Пеший поход', 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробежка', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index 67b6bc1fb..8f0d92774 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -695,6 +695,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Cesta autom', 'admin.addons.catalog.roadtrip.description': 'Plánujte jazdy so zastávkami na trase, časmi jazdy a časmi príchodu, ktoré sa aktualizujú samy', + 'admin.addons.catalog.tours.name': 'Túry', + 'admin.addons.catalog.tours.description': 'Pešie, cyklo a ďalšie túry, ktoré vedú po trase', 'admin.addons.catalog.memories.name': 'Fotky (Immich)', 'admin.addons.catalog.memories.description': 'Zdieľajte cestovné fotky cez svoju inštanciu Immich', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/sk/mobileTrip.ts b/shared/src/i18n/sk/mobileTrip.ts index f636247d1..870c70d1c 100644 --- a/shared/src/i18n/sk/mobileTrip.ts +++ b/shared/src/i18n/sk/mobileTrip.ts @@ -59,7 +59,12 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Údaje o jazde', 'mobileTrip.rtDesktopNote': 'Tieto údaje sa nastavujú na počítači, kde môžete tiež porovnávať alternatívne trasy a nechať deň sledovať importovanú stopu.', - 'mobileTrip.rtPlanOnDesktop': 'Plánovanie prebieha na počítači. Len čo má deň dve miesta, TREK jazdu vypočíta.', + 'mobileTrip.planOnDesktop': 'Plánuje sa na počítači', + 'mobileTrip.rtDriveOnTwoPlaces': 'Len čo má deň dve miesta, TREK jazdu vypočíta.', + 'mobileTrip.toursEmptyBody': 'Uložené túry sa zobrazia tu a môžeš ich pridať k dňu.', + 'mobileTrip.toursCount': '{count} túr', + 'mobileTrip.toursCount.one': '{count} túra', + 'mobileTrip.toursCount.few': '{count} túry', 'mobileTrip.rtSearchOffline': 'Vyžaduje pripojenie: hľadanie číta trasu pred vami.', 'mobileTrip.rtBehind': 'o {time} za plánom', 'mobileTrip.rtStart': 'Začiatok', diff --git a/shared/src/i18n/sk/nav.ts b/shared/src/i18n/sk/nav.ts index 479624e2c..a19751b33 100644 --- a/shared/src/i18n/sk/nav.ts +++ b/shared/src/i18n/sk/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Cesta', 'nav.settings': 'Nastavenia', 'nav.admin': 'Správa', 'nav.logout': 'Odhlásiť sa', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index a7c8abd5f..6ee838d13 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Vo vzdialenosti {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil sa zobrazí, keď budú dostupné výškové údaje trasy.', 'tours.planner.gpxNoGeometry': 'Geometria trasy nie je pre tento import GPX dostupná.', - 'tours.planner.mobileHint': - 'Plánovanie túr je dostupné na tablete a počítači. Tu si môžete uložené túry prezerať a priraďovať k dňom.', 'tourTypes.hike': 'Pešia túra', 'tourTypes.bike': 'Cyklotúra', 'tourTypes.run': 'Beh', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index f0c0699d7..8a96d9eee 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -694,6 +694,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Bilresa', 'admin.addons.catalog.roadtrip.description': 'Planera körningar med stopp – körtider och ankomsttider räknas om automatiskt', + 'admin.addons.catalog.tours.name': 'Turer', + 'admin.addons.catalog.tours.description': 'Vandringar, cykelturer och andra turer längs en rutt', 'admin.addons.catalog.memories.name': 'Foton (Immich)', 'admin.addons.catalog.memories.description': 'Dela resefoton via din Immich-instans', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/sv/mobileTrip.ts b/shared/src/i18n/sv/mobileTrip.ts index 9037eecc8..2043c9e91 100644 --- a/shared/src/i18n/sv/mobileTrip.ts +++ b/shared/src/i18n/sv/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Kördata', 'mobileTrip.rtDesktopNote': 'De här värdena ställer du in på datorn. Där kan du också jämföra alternativa vägar och låta en dag följa ett importerat spår.', - 'mobileTrip.rtPlanOnDesktop': 'Planeringen sker på datorn. TREK räknar ut körningen så snart en dag har två platser.', + 'mobileTrip.planOnDesktop': 'Planeras på datorn', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK räknar ut körningen så snart en dag har två platser.', + 'mobileTrip.toursEmptyBody': 'Sparade turer visas här och kan läggas till en dag.', + 'mobileTrip.toursCount': '{count} turer', + 'mobileTrip.toursCount.one': '{count} tur', 'mobileTrip.rtSearchOffline': 'Kräver anslutning: sökningen läser rutten som ligger framför dig.', 'mobileTrip.rtBehind': '{time} efter planen', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/sv/nav.ts b/shared/src/i18n/sv/nav.ts index 5d9dfa7e5..e10744b72 100644 --- a/shared/src/i18n/sv/nav.ts +++ b/shared/src/i18n/sv/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Resa', 'nav.settings': 'Inställningar', 'nav.admin': 'Admin', 'nav.logout': 'Logga ut', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 711185ece..cdc7b4252 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -194,8 +194,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Vid {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profilen visas när höjddata för rutten finns tillgängliga.', 'tours.planner.gpxNoGeometry': 'Ruttgeometrin är inte tillgänglig för den här GPX-importen.', - 'tours.planner.mobileHint': - 'Turplanering är tillgänglig på surfplatta och dator. Här kan du visa sparade turer och tilldela dem till en dag.', 'tourTypes.hike': 'Vandring', 'tourTypes.bike': 'Cykeltur', 'tourTypes.run': 'Löptur', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 09c8243db..7619134a3 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -816,6 +816,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'โรดทริป', 'admin.addons.catalog.roadtrip.description': 'วางแผนการขับรถด้วยจุดแวะตามเส้นทาง เวลาเดินทาง และเวลาไปถึงที่อัปเดตเอง', + 'admin.addons.catalog.tours.name': 'ทัวร์', + 'admin.addons.catalog.tours.description': 'เดินป่า ปั่นจักรยาน และทัวร์อื่นๆ ที่ไปตามเส้นทาง', 'admin.addons.catalog.dawarich.name': 'Dawarich', 'admin.addons.catalog.dawarich.description': 'อ่านการเยี่ยมชมและเส้นทางที่บันทึกจากอินสแตนซ์ Dawarich ที่ผู้อ่านแต่ละคนเชื่อมต่อเอง', diff --git a/shared/src/i18n/th/mobileTrip.ts b/shared/src/i18n/th/mobileTrip.ts index 9107082a1..3dfb43f2e 100644 --- a/shared/src/i18n/th/mobileTrip.ts +++ b/shared/src/i18n/th/mobileTrip.ts @@ -53,7 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'ข้อมูลการขับรถ', 'mobileTrip.rtDesktopNote': 'กำหนดข้อมูลเหล่านี้บนเดสก์ท็อป ซึ่งคุณยังเปรียบเทียบเส้นทางอื่นและให้วันนั้นตามเส้นทางที่นำเข้าได้', - 'mobileTrip.rtPlanOnDesktop': 'การวางแผนทำบนเดสก์ท็อป TREK จะคำนวณการขับรถเมื่อวันหนึ่งมีสถานที่สองแห่ง', + 'mobileTrip.planOnDesktop': 'วางแผนบนคอมพิวเตอร์', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK จะคำนวณการขับรถเมื่อวันหนึ่งมีสถานที่สองแห่ง', + 'mobileTrip.toursEmptyBody': 'ทัวร์ที่บันทึกไว้จะแสดงที่นี่ และเพิ่มลงในวันได้', + 'mobileTrip.toursCount': '{count} ทัวร์', 'mobileTrip.rtSearchOffline': 'ต้องเชื่อมต่ออินเทอร์เน็ต: การค้นหาจะอ่านเส้นทางข้างหน้า', 'mobileTrip.rtBehind': 'ช้ากว่าแผน {time}', 'mobileTrip.rtStart': 'เริ่มต้น', diff --git a/shared/src/i18n/th/nav.ts b/shared/src/i18n/th/nav.ts index 93d588a32..131f3a81b 100644 --- a/shared/src/i18n/th/nav.ts +++ b/shared/src/i18n/th/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'การเดินทาง', 'nav.settings': 'การตั้งค่า', 'nav.admin': 'ผู้ดูแลระบบ', 'nav.logout': 'ออกจากระบบ', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index d5ea1f71b..917b24f8e 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -187,7 +187,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'ที่ระยะ {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'กราฟจะแสดงเมื่อมีข้อมูลระดับความสูงของเส้นทาง', 'tours.planner.gpxNoGeometry': 'ไม่มีข้อมูลรูปทรงเส้นทางสำหรับทัวร์ที่นำเข้าจาก GPX นี้', - 'tours.planner.mobileHint': 'วางแผนทัวร์ได้บนแท็บเล็ตและคอมพิวเตอร์ ที่นี่สามารถดูทัวร์ที่บันทึกไว้และจัดลงวันได้', 'tourTypes.hike': 'เดินป่า', 'tourTypes.bike': 'ทัวร์จักรยาน', 'tourTypes.run': 'วิ่ง', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 9fb1b16b3..d765adc34 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [ { file: 'tours', - generalKeyCount: 1336, + generalKeyCount: 1335, modules: toursModules, }, { diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 9e989d012..ad727fa31 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -679,6 +679,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Yol gezisi', 'admin.addons.catalog.roadtrip.description': 'Duraklı sürüşler planlayın; sürüş ve varış saatleri kendiliğinden yeniden hesaplanır', + 'admin.addons.catalog.tours.name': 'Turlar', + 'admin.addons.catalog.tours.description': 'Bir rotayı izleyen yürüyüşler, bisiklet ve diğer turlar', 'admin.addons.catalog.memories.name': 'Fotoğraflar (Immich)', 'admin.addons.catalog.memories.description': 'Immich örneğiniz aracılığıyla gezi fotoğraflarını paylaşın', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/tr/mobileTrip.ts b/shared/src/i18n/tr/mobileTrip.ts index 1afc28f25..b366137d5 100644 --- a/shared/src/i18n/tr/mobileTrip.ts +++ b/shared/src/i18n/tr/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Sürüş değerleri', 'mobileTrip.rtDesktopNote': 'Bu değerler masaüstünde ayarlanır; orada başka yolları karşılaştırabilir ve bir günü içe aktarılmış bir izi takip ettirebilirsiniz.', - 'mobileTrip.rtPlanOnDesktop': 'Planlama masaüstünde yapılır. Bir günde iki yer olduğunda TREK sürüşü hesaplar.', + 'mobileTrip.planOnDesktop': 'Planlama bilgisayarda', + 'mobileTrip.rtDriveOnTwoPlaces': 'Bir günde iki yer olduğunda TREK sürüşü hesaplar.', + 'mobileTrip.toursEmptyBody': 'Kaydedilen turlar burada görünür ve bir güne eklenebilir.', + 'mobileTrip.toursCount': '{count} tur', + 'mobileTrip.toursCount.one': '{count} tur', 'mobileTrip.rtSearchOffline': 'Bağlantı gerekir: arama, ileride kalan rotayı okur.', 'mobileTrip.rtBehind': 'plandan {time} geride', 'mobileTrip.rtStart': 'Başlangıç', diff --git a/shared/src/i18n/tr/nav.ts b/shared/src/i18n/tr/nav.ts index 8c21d144d..a70c407bb 100644 --- a/shared/src/i18n/tr/nav.ts +++ b/shared/src/i18n/tr/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Seyahat', 'nav.settings': 'Ayarlar', 'nav.admin': 'Yönetici', 'nav.logout': 'Çıkış yap', diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 8a895bd18..5925d8162 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -195,8 +195,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Rotanın yükseklik verileri kullanılabilir olduğunda profil gösterilir.', 'tours.planner.gpxNoGeometry': 'Bu GPX içe aktarımı için rota geometrisi kullanılamıyor.', - 'tours.planner.mobileHint': - 'Tur planlama tablet ve bilgisayarda kullanılabilir. Burada kaydedilmiş turları görüntüleyebilir ve günlere atayabilirsiniz.', 'tourTypes.hike': 'Doğa yürüyüşü', 'tourTypes.bike': 'Bisiklet turu', 'tourTypes.run': 'Koşu', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 001042bd6..1f6bafccd 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -647,6 +647,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Автоподорож', 'admin.addons.catalog.roadtrip.description': 'Плануйте поїздки із зупинками — час у дорозі та прибуття перераховуються автоматично', + 'admin.addons.catalog.tours.name': 'Тури', + 'admin.addons.catalog.tours.description': 'Походи, велопрогулянки та інші тури за маршрутом', 'admin.addons.subtitleBefore': 'Увімкніть або вимкніть функції для налаштування ', 'admin.addons.subtitleAfter': ' під себе.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/uk/mobileTrip.ts b/shared/src/i18n/uk/mobileTrip.ts index ba125b199..c5e4a4d24 100644 --- a/shared/src/i18n/uk/mobileTrip.ts +++ b/shared/src/i18n/uk/mobileTrip.ts @@ -62,8 +62,13 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Дані про водіння', 'mobileTrip.rtDesktopNote': 'Ці дані задаються на комп’ютері: там само можна порівнювати інші маршрути та провести день за імпортованим треком.', - 'mobileTrip.rtPlanOnDesktop': - 'Планування відбувається на комп’ютері. TREK розрахує маршрут, щойно в дні буде два місця.', + 'mobileTrip.planOnDesktop': 'Планування на комп’ютері', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK розрахує маршрут, щойно в дні буде два місця.', + 'mobileTrip.toursEmptyBody': 'Збережені тури з’являться тут, їх можна додати до дня.', + 'mobileTrip.toursCount': '{count} тури', + 'mobileTrip.toursCount.one': '{count} тур', + 'mobileTrip.toursCount.few': '{count} тури', + 'mobileTrip.toursCount.many': '{count} турів', 'mobileTrip.rtSearchOffline': 'Потрібне з’єднання: пошук зчитує маршрут попереду.', 'mobileTrip.rtBehind': 'на {time} пізніше плану', 'mobileTrip.rtStart': 'Початок', diff --git a/shared/src/i18n/uk/nav.ts b/shared/src/i18n/uk/nav.ts index 60bfbae46..46e95409a 100644 --- a/shared/src/i18n/uk/nav.ts +++ b/shared/src/i18n/uk/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Поїздка', 'nav.settings': 'Налаштування', 'nav.admin': 'Адмін', 'nav.logout': 'Вийти', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index fcded912a..b10594b01 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -206,8 +206,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'На відстані {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Профіль з’явиться, коли будуть доступні дані про висоту маршруту.', 'tours.planner.gpxNoGeometry': 'Геометрія маршруту недоступна для цього імпорту GPX.', - 'tours.planner.mobileHint': - 'Планування турів доступне на планшеті й комп’ютері. Тут можна переглядати збережені тури та призначати їх на дні.', 'tourTypes.hike': 'Піший похід', 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробіжка', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 51a9e8c8e..bb39fafe1 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -685,6 +685,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.collab.description': 'Ghi chú, bình chọn, trò chuyện và gợi ý để cùng lên kế hoạch', 'admin.addons.catalog.roadtrip.name': 'Chuyến đi đường bộ', 'admin.addons.catalog.roadtrip.description': 'Lên kế hoạch lái xe có điểm dừng; thời gian lái và giờ đến tự tính lại', + 'admin.addons.catalog.tours.name': 'Chuyến tham quan', + 'admin.addons.catalog.tours.description': 'Đi bộ đường dài, đạp xe và các chuyến đi theo lộ trình', 'admin.addons.catalog.memories.name': 'Hình ảnh (Immich)', 'admin.addons.catalog.memories.description': 'Chia sẻ ảnh chuyến đi qua phiên bản Immich của bạn', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/vi/mobileTrip.ts b/shared/src/i18n/vi/mobileTrip.ts index f7aa42dbe..3801dd792 100644 --- a/shared/src/i18n/vi/mobileTrip.ts +++ b/shared/src/i18n/vi/mobileTrip.ts @@ -53,8 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Số liệu lái xe', 'mobileTrip.rtDesktopNote': 'Các số liệu này được đặt trên máy tính, nơi bạn cũng có thể so sánh các lộ trình khác và cho một ngày đi theo tuyến đã nhập.', - 'mobileTrip.rtPlanOnDesktop': - 'Việc lên kế hoạch diễn ra trên máy tính. TREK tính chặng lái xe ngay khi một ngày có hai địa điểm.', + 'mobileTrip.planOnDesktop': 'Lập kế hoạch trên máy tính', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK tính chặng lái xe ngay khi một ngày có hai địa điểm.', + 'mobileTrip.toursEmptyBody': 'Các chuyến tham quan đã lưu hiện ở đây, sẵn sàng thêm vào một ngày.', + 'mobileTrip.toursCount': '{count} chuyến tham quan', 'mobileTrip.rtSearchOffline': 'Cần có kết nối: việc tìm kiếm đọc lộ trình phía trước.', 'mobileTrip.rtBehind': 'chậm {time} so với kế hoạch', 'mobileTrip.rtStart': 'Bắt đầu', diff --git a/shared/src/i18n/vi/nav.ts b/shared/src/i18n/vi/nav.ts index a463c3c32..050f9c59b 100644 --- a/shared/src/i18n/vi/nav.ts +++ b/shared/src/i18n/vi/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Chuyến đi', 'nav.settings': 'Cài đặt', 'nav.admin': 'Quản trị viên', 'nav.logout': 'Đăng xuất', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index ff4d14d84..dd7cc8e62 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -195,8 +195,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Tại {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Biểu đồ sẽ hiển thị khi có dữ liệu độ cao của tuyến đường.', 'tours.planner.gpxNoGeometry': 'Không có dữ liệu hình dạng tuyến đường cho bản nhập GPX này.', - 'tours.planner.mobileHint': - 'Có thể lập kế hoạch tham quan trên máy tính bảng và máy tính. Tại đây, bạn có thể xem và xếp lịch cho các chuyến tham quan đã lưu.', 'tourTypes.hike': 'Đi bộ đường dài', 'tourTypes.bike': 'Chuyến đạp xe', 'tourTypes.run': 'Chạy bộ', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index d6038564f..52587c19a 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -615,6 +615,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.collab.description': '備註、投票、聊天與建議,方便一起規劃', 'admin.addons.catalog.roadtrip.name': '公路旅行', 'admin.addons.catalog.roadtrip.description': '規劃含停靠點的駕駛行程,駕駛時間與抵達時間會自動重算', + 'admin.addons.catalog.tours.name': '遊程', + 'admin.addons.catalog.tours.description': '沿路線進行的健行、騎行等遊程', 'admin.addons.subtitleBefore': '啟用或停用功能以自定義你的 ', 'admin.addons.subtitleAfter': ' 體驗。', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/zh-TW/mobileTrip.ts b/shared/src/i18n/zh-TW/mobileTrip.ts index 4fad7e3aa..2ce4419cf 100644 --- a/shared/src/i18n/zh-TW/mobileTrip.ts +++ b/shared/src/i18n/zh-TW/mobileTrip.ts @@ -52,7 +52,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.transportsEmpty': '暫無交通安排', 'mobileTrip.rtInfoTitle': '駕駛數據', 'mobileTrip.rtDesktopNote': '這些數據在電腦上設定,在那裡也可以比較其他路線,以及讓某一天沿匯入的軌跡行駛。', - 'mobileTrip.rtPlanOnDesktop': '規劃在電腦上進行。當一天有兩個地點時,TREK 就會算出車程。', + 'mobileTrip.planOnDesktop': '請在電腦上規劃', + 'mobileTrip.rtDriveOnTwoPlaces': '當一天有兩個地點時,TREK 就會算出車程。', + 'mobileTrip.toursEmptyBody': '已儲存的遊程會顯示在這裡,可加入某一天。', + 'mobileTrip.toursCount': '{count} 個遊程', 'mobileTrip.rtSearchOffline': '需要連線:搜尋會讀取前方的路線。', 'mobileTrip.rtBehind': '比計畫晚 {time}', 'mobileTrip.rtStart': '開始', diff --git a/shared/src/i18n/zh-TW/nav.ts b/shared/src/i18n/zh-TW/nav.ts index f42b98828..d6060bed1 100644 --- a/shared/src/i18n/zh-TW/nav.ts +++ b/shared/src/i18n/zh-TW/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '旅行', 'nav.settings': '設定', 'nav.admin': '管理', 'nav.logout': '退出登入', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 94c8dcb10..82f070f0b 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -180,7 +180,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '距離 {distance} 處:{elevation}', 'tours.planner.inspector.elevationPlaceholder': '取得路線的海拔資料後,會顯示剖面圖。', 'tours.planner.gpxNoGeometry': '這次 GPX 匯入沒有可用的路線幾何資料。', - 'tours.planner.mobileHint': '可在平板電腦與電腦上規劃遊程。在這裡可以檢視已儲存的遊程並安排日期。', 'tourTypes.hike': '健行', 'tourTypes.bike': '單車遊程', 'tourTypes.run': '跑步', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 886ec6e2d..b7dc974ff 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -565,6 +565,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.collab.description': '备忘、投票、聊天和建议,方便一起规划', 'admin.addons.catalog.roadtrip.name': '公路旅行', 'admin.addons.catalog.roadtrip.description': '规划带停靠点的驾驶行程,驾驶时间和到达时间会自动重算', + 'admin.addons.catalog.tours.name': '游览', + 'admin.addons.catalog.tours.description': '沿路线进行的徒步、骑行等游览', 'admin.addons.subtitleBefore': '启用或禁用功能以自定义你的 ', 'admin.addons.subtitleAfter': ' 体验。', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/zh/mobileTrip.ts b/shared/src/i18n/zh/mobileTrip.ts index 6793f57f7..a3df1ef46 100644 --- a/shared/src/i18n/zh/mobileTrip.ts +++ b/shared/src/i18n/zh/mobileTrip.ts @@ -52,7 +52,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.transportsEmpty': '暂无交通安排', 'mobileTrip.rtInfoTitle': '驾驶数据', 'mobileTrip.rtDesktopNote': '这些数据在桌面端设置。在桌面端还可以比较其他路线走法,并让某一天沿导入的轨迹行驶。', - 'mobileTrip.rtPlanOnDesktop': '规划在桌面端进行。一天里有了两个地点,TREK 就会算出这段车程。', + 'mobileTrip.planOnDesktop': '请在电脑上规划', + 'mobileTrip.rtDriveOnTwoPlaces': '一天里有了两个地点,TREK 就会算出这段车程。', + 'mobileTrip.toursEmptyBody': '已保存的游览会显示在这里,可添加到某一天。', + 'mobileTrip.toursCount': '{count} 个游览', 'mobileTrip.rtSearchOffline': '需要联网:搜索会读取前方的路线。', 'mobileTrip.rtBehind': '比计划晚 {time}', 'mobileTrip.rtStart': '开始', diff --git a/shared/src/i18n/zh/nav.ts b/shared/src/i18n/zh/nav.ts index b67fb1097..7c9e16264 100644 --- a/shared/src/i18n/zh/nav.ts +++ b/shared/src/i18n/zh/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '旅行', 'nav.settings': '设置', 'nav.admin': '管理', 'nav.logout': '退出登录', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 4f6b68cd3..2979e7bf6 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -181,7 +181,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '距离 {distance} 处:{elevation}', 'tours.planner.inspector.elevationPlaceholder': '获取到路线海拔数据后将显示剖面图。', 'tours.planner.gpxNoGeometry': '本次 GPX 导入没有可用的路线几何数据。', - 'tours.planner.mobileHint': '可在平板电脑和电脑上规划游览路线。在这里可以查看已保存的游览活动并安排日期。', 'tourTypes.hike': '徒步', 'tourTypes.bike': '骑行', 'tourTypes.run': '跑步',