From 0a3a993dfc4c4795a549513c75a5c7f5f3df5151 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 22:00:19 +0200 Subject: [PATCH] fix(share): only call a share link expired when the server says so (#2505) The public share page turned every failed GET /api/shared/:token into "Link expired or invalid". A 5xx during a restart, a proxy 502, a rate limit, a timeout or a viewer who is briefly offline all told the viewer the link was dead and sent them back to the owner for a new one, while the same link loaded fine on the next try. The endpoint answers 404 with its JSON { error } body for an unknown, expired or revoked token, so that is now the only answer that shows the expired screen. A 404 without that body comes from a proxy in front of TREK (Traefik answers "404 page not found" while the container is stopped or still starting) and counts as a failed load, as does a 200 that is not the share payload, such as an auth wall's login page, which used to crash the page. Any failed load shows "This trip could not be loaded" with a retry button that refetches in place and stays busy while it runs. The load also ignores a response that lands after the page moved on to another token. Both screens use the theme tokens instead of hex literals. New keys are translated in all 23 locales. --- client/src/pages/SharedTripPage.test.tsx | 175 ++++++++++++++++++ client/src/pages/SharedTripPage.tsx | 21 +-- .../sharedTrip/SharedTripErrorScreen.tsx | 54 ++++++ .../pages/sharedTrip/sharedTripModel.test.ts | 65 ++++++- .../src/pages/sharedTrip/sharedTripModel.ts | 36 ++++ client/src/pages/sharedTrip/useSharedTrip.ts | 60 +++++- client/tests/helpers/msw/handlers/shared.ts | 3 +- shared/src/i18n/ar/shared.ts | 3 + shared/src/i18n/br/shared.ts | 4 + shared/src/i18n/ca/shared.ts | 4 + shared/src/i18n/cs/shared.ts | 3 + shared/src/i18n/de/shared.ts | 4 + shared/src/i18n/en/shared.ts | 3 + shared/src/i18n/es/shared.ts | 4 + shared/src/i18n/fr/shared.ts | 4 + shared/src/i18n/gr/shared.ts | 4 + shared/src/i18n/hu/shared.ts | 4 + shared/src/i18n/id/shared.ts | 4 + shared/src/i18n/it/shared.ts | 3 + shared/src/i18n/ja/shared.ts | 4 + shared/src/i18n/ko/shared.ts | 3 + shared/src/i18n/nl/shared.ts | 4 + shared/src/i18n/pl/shared.ts | 3 + shared/src/i18n/ru/shared.ts | 4 + shared/src/i18n/sv/shared.ts | 4 + shared/src/i18n/tr/shared.ts | 4 + shared/src/i18n/uk/shared.ts | 4 + shared/src/i18n/vi/shared.ts | 4 + shared/src/i18n/zh-TW/shared.ts | 3 + shared/src/i18n/zh/shared.ts | 3 + 30 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 client/src/pages/sharedTrip/SharedTripErrorScreen.tsx diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index be4e8f070..6a67e073d 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render'; import { Routes, Route } from 'react-router'; import { http, HttpResponse } from 'msw'; +import { AxiosError } from 'axios'; import { server } from '../../tests/helpers/msw/server'; +import { shareApi } from '../api/client'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; import { buildSettings } from '../../tests/helpers/factories'; import { useSettingsStore } from '../store/settingsStore'; @@ -1544,4 +1546,177 @@ describe('SharedTripPage', () => { expect(header?.style.overflow).toBe('hidden'); }); }); + + // ── #2505: only a 404 means the link is dead ──────────────────────────── + + describe('FE-PAGE-SHARED-043: a failed load is not an expired link (#2505)', () => { + const failWith = (status: number) => + server.use(http.get('/api/shared/:token', () => HttpResponse.json({ error: 'nope' }, { status }))); + + it.each([500, 502, 503, 429])('keeps the link when the server answers %i', async (status) => { + failWith(status); + renderSharedTrip('test-token'); + + await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument()); + expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + // A request with no response makes the api client probe /api/health for a + // proxy wall first; answer it the way a healthy server does. + const healthy = () => http.get('/api/health', () => HttpResponse.json({ status: 'ok' })); + + it('keeps the link when the request never reaches the server', async () => { + server.use(healthy(), http.get('/api/shared/:token', () => HttpResponse.error())); + renderSharedTrip('test-token'); + + await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument()); + expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument(); + }); + + it('keeps the link when the request times out', async () => { + // What axios rejects with once its 8s deadline passes: no response at all. + const timeout = new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED); + const spy = vi.spyOn(shareApi, 'getSharedTrip').mockRejectedValueOnce(timeout); + renderSharedTrip('test-token'); + + await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument()); + expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument(); + spy.mockRestore(); + }); + + it('still calls a 404 from the share endpoint an expired link, without a retry', async () => { + server.use( + http.get('/api/shared/:token', () => HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 })), + ); + renderSharedTrip('gone-token'); + + await waitFor(() => expect(screen.getByText(/link expired or invalid/i)).toBeInTheDocument()); + expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + // A reverse proxy that has no upstream for TREK (Traefik while the + // container is stopped or still in its start period, nginx with a missing + // location) answers 404 on its own. That is the restart window of the + // report, not a dead link. + it.each([ + ['a plain text 404 from Traefik', '404 page not found\n', 'text/plain; charset=utf-8'], + ['an HTML 404 from nginx', '404 Not Found', 'text/html'], + ])('keeps the link on %s', async (_label, body, contentType) => { + server.use( + http.get('/api/shared/:token', () => new HttpResponse(body, { status: 404, headers: { 'content-type': contentType } })), + ); + renderSharedTrip('test-token'); + + await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument()); + expect(screen.queryByText(/link expired or invalid/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('shows the failed screen instead of crashing when a 200 is not the share payload', async () => { + // An auth wall or a captive portal answering the API call with its own page. + let calls = 0; + server.use( + http.get('/api/shared/:token', () => { + calls += 1; + if (calls === 1) { + return new HttpResponse('Sign in', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + } + return HttpResponse.json({ + trip: { id: 1, title: 'Past The Wall', start_date: '2026-07-01', end_date: '2026-07-02' }, + days: [], + assignments: {}, + dayNotes: {}, + places: [], + reservations: [], + accommodations: [], + permissions: { share_map: true }, + }); + }), + ); + renderSharedTrip('test-token'); + + fireEvent.click(await screen.findByRole('button', { name: /try again/i })); + + await waitFor(() => expect(screen.getByText('Past The Wall')).toBeInTheDocument()); + expect(calls).toBe(2); + }); + + it('loads the trip when the retry gets through', async () => { + let calls = 0; + server.use( + http.get('/api/shared/:token', () => { + calls += 1; + if (calls === 1) return new HttpResponse(null, { status: 503 }); + return HttpResponse.json({ + trip: { id: 1, title: 'Back Online Trip', start_date: '2026-07-01', end_date: '2026-07-02' }, + days: [], + assignments: {}, + dayNotes: {}, + places: [], + reservations: [], + accommodations: [], + permissions: { share_map: true }, + }); + }), + ); + renderSharedTrip('test-token'); + + const retry = await screen.findByRole('button', { name: /try again/i }); + fireEvent.click(retry); + + await waitFor(() => expect(screen.getByText('Back Online Trip')).toBeInTheDocument()); + expect(calls).toBe(2); + }); + + it('keeps the failed screen up with a busy button while the retry runs, and sends it once', async () => { + let calls = 0; + let release: () => void = () => {}; + server.use( + http.get('/api/shared/:token', async () => { + calls += 1; + if (calls === 1) return new HttpResponse(null, { status: 500 }); + await new Promise((resolve) => { release = resolve; }); + return new HttpResponse(null, { status: 500 }); + }), + ); + renderSharedTrip('test-token'); + + const retry = await screen.findByRole('button', { name: /try again/i }); + fireEvent.click(retry); + await waitFor(() => expect(retry).toBeDisabled()); + expect(retry).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument(); + fireEvent.click(retry); + + await waitFor(() => expect(calls).toBe(2)); + release(); + await waitFor(() => expect(retry).not.toBeDisabled()); + expect(calls).toBe(2); + expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument(); + }); + + it('lands on the expired screen when the retry finds the link gone', async () => { + let calls = 0; + server.use( + healthy(), + http.get('/api/shared/:token', () => { + calls += 1; + return calls === 1 + ? HttpResponse.error() + : HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 }); + }), + ); + renderSharedTrip('test-token'); + + fireEvent.click(await screen.findByRole('button', { name: /try again/i })); + + await waitFor(() => expect(screen.getByText(/link expired or invalid/i)).toBeInTheDocument()); + expect(calls).toBe(2); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 94ca9ec93..26b1c569b 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -39,6 +39,7 @@ import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails'; import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails'; +import { SharedTripErrorScreen } from './sharedTrip/SharedTripErrorScreen'; const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }; @@ -98,6 +99,8 @@ export default function SharedTripPage() { const { data, error, + retry, + retrying, base, convert, selectedDay, @@ -108,23 +111,7 @@ export default function SharedTripPage() { setShowLangPicker, } = useSharedTrip(); - if (error) - return ( -
-
-
🔒
-

- {t('shared.expired')} -

-

- {t('shared.expiredHint')} -

-
-
- ); + if (error) return ; if (!data) return ( diff --git a/client/src/pages/sharedTrip/SharedTripErrorScreen.tsx b/client/src/pages/sharedTrip/SharedTripErrorScreen.tsx new file mode 100644 index 000000000..b660cd024 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedTripErrorScreen.tsx @@ -0,0 +1,54 @@ +import { CloudOff, Loader2, RotateCcw } from 'lucide-react' +import { useTranslation } from '../../i18n' +import type { SharedTripLoadError } from './sharedTripModel' + +interface SharedTripErrorScreenProps { + reason: SharedTripLoadError + retrying: boolean + onRetry: () => void +} + +/** + * The full-page state of a share link whose payload did not arrive. Only + * 'expired' tells the viewer the link is dead; 'unavailable' says the load + * failed and offers another go, since the same link usually works on the next + * request (#2505). + */ +export function SharedTripErrorScreen({ reason, retrying, onRetry }: SharedTripErrorScreenProps) { + const { t } = useTranslation() + const expired = reason === 'expired' + + return ( +
+
+ {expired ? ( +
+ 🔒 +
+ ) : ( + + )} +

{t(expired ? 'shared.expired' : 'shared.loadFailed')}

+

+ {t(expired ? 'shared.expiredHint' : 'shared.loadFailedHint')} +

+ {!expired && ( + + )} +
+
+ ) +} diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts index 7858e44f6..f71078c67 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.test.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' -import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel' +import { AxiosError, AxiosHeaders } from 'axios' +import { formatDurationMinutes, isHttpUrl, isSharedTripPayload, linkHost, sharedTripLoadError } from './sharedTripModel' describe('sharedTripModel (#2320)', () => { it('accepts http and https and nothing else', () => { @@ -35,3 +36,65 @@ describe('sharedTripModel (#2320)', () => { expect(linkHost('nonsense')).toBe('nonsense') }) }) + +describe('sharedTripLoadError (#2505)', () => { + // What the share endpoint sends for an unknown, expired or revoked token. + const ENDPOINT_404 = { error: 'Invalid or expired link' } + + const answered = (status: number, data: unknown = ENDPOINT_404) => + new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, { + status, + statusText: '', + data, + headers: {}, + config: { headers: new AxiosHeaders() }, + }) + + it('calls only the endpoint 404 an expired link', () => { + expect(sharedTripLoadError(answered(404))).toBe('expired') + }) + + it('treats every other answer as a failed load', () => { + for (const status of [400, 401, 403, 408, 429, 500, 502, 503, 504]) { + expect(sharedTripLoadError(answered(status))).toBe('unavailable') + } + }) + + it('does not take a 404 without the TREK error body for an expired link', () => { + // Traefik with the container stopped or still starting, nginx, an empty + // answer: a 404 from something in front of TREK says nothing about the token. + expect(sharedTripLoadError(answered(404, '404 page not found\n'))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, '

404 Not Found

'))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, ''))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, null))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, {}))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, [{ error: 'x' }]))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, { error: 404 }))).toBe('unavailable') + }) + + it('treats a request without any answer as a failed load', () => { + expect(sharedTripLoadError(new AxiosError('Network Error', AxiosError.ERR_NETWORK))).toBe('unavailable') + expect(sharedTripLoadError(new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED))).toBe('unavailable') + expect(sharedTripLoadError(new TypeError('Failed to fetch'))).toBe('unavailable') + expect(sharedTripLoadError(undefined)).toBe('unavailable') + expect(sharedTripLoadError(null)).toBe('unavailable') + }) +}) + +describe('isSharedTripPayload (#2505)', () => { + it('accepts what the share endpoint sends', () => { + expect(isSharedTripPayload({ trip: { id: 1, title: 'Lisbon' }, days: [], permissions: {} })).toBe(true) + }) + + it('rejects a 200 that is not the share payload', () => { + // An auth wall or a captive portal answering the API call with its own page. + expect(isSharedTripPayload('Sign in')).toBe(false) + expect(isSharedTripPayload('')).toBe(false) + expect(isSharedTripPayload(null)).toBe(false) + expect(isSharedTripPayload(undefined)).toBe(false) + expect(isSharedTripPayload([])).toBe(false) + expect(isSharedTripPayload({})).toBe(false) + expect(isSharedTripPayload({ trip: null })).toBe(false) + expect(isSharedTripPayload({ trip: 'Lisbon' })).toBe(false) + }) +}) diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts index 4eff44662..b6ae81a26 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -46,3 +46,39 @@ export function linkHost(url: string): string { return url } } + +/** + * Why the share payload did not arrive (#2505). + * + * The share endpoint answers 404 with its JSON `{ error }` body for a token + * that is unknown, expired or revoked, and that is the only answer that says + * anything about the link. A 5xx while the server restarts, a rate limit, a + * timeout or no response at all because the viewer is offline says the request + * failed, not the link, so the page offers a retry instead of sending the + * viewer back to the owner for a new link that would not have been needed. + * + * A 404 without that body is not TREK talking: a reverse proxy with no + * upstream answers one on its own (Traefik while the container is stopped or + * still starting, nginx with a missing location), so it counts as a failed + * load too. + */ +export type SharedTripLoadError = 'expired' | 'unavailable' + +export function sharedTripLoadError(err: unknown): SharedTripLoadError { + const response = (err as { response?: { status?: number; data?: unknown } } | null | undefined)?.response + if (response?.status !== 404) return 'unavailable' + return isPlainObject(response.data) && typeof response.data.error === 'string' ? 'expired' : 'unavailable' +} + +/** + * Whether a 200 carried the share payload. An auth wall or a captive portal + * can answer the API call with its own HTML page, which axios hands over as a + * string; the page would crash on it, so the hook treats it as a failed load. + */ +export function isSharedTripPayload(payload: unknown): boolean { + return isPlainObject(payload) && isPlainObject(payload.trip) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts index 49470123d..9eb76cab2 100644 --- a/client/src/pages/sharedTrip/useSharedTrip.ts +++ b/client/src/pages/sharedTrip/useSharedTrip.ts @@ -2,28 +2,61 @@ import { useState, useEffect } from 'react' import { useParams } from 'react-router' import { shareApi } from '../../api/client' import { useExchangeRates } from '../../hooks/useExchangeRates' +import { isSharedTripPayload, sharedTripLoadError, type SharedTripLoadError } from './sharedTripModel' /** * Shared-trip (public) data hook — owns the token lookup, the read-only share - * fetch and the view state (selected day, active tab, language picker). - * SharedTripPage is a pure wiring container; the post-load derivations + * fetch with its retry, and the view state (selected day, active tab, language + * picker). SharedTripPage is a pure wiring container; the post-load derivations * (sortedDays, map places, …) stay in the page next to the JSX that uses them. - * Behaviour is identical to the previous in-component logic. */ export function useSharedTrip() { const { token } = useParams<{ token: string }>() // The shared payload is an open-ended snapshot (trip, days, assignments, …), // matched 1:1 from the public share endpoint — kept loosely typed as before. const [data, setData] = useState(null) - const [error, setError] = useState(false) + // 'expired' only for the endpoint's own 404; any other failure, a proxy's + // 404 or a 200 that is not the payload included, is 'unavailable' and keeps + // the viewer on a retry (#2505). + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + const [retrying, setRetrying] = useState(false) const [selectedDay, setSelectedDay] = useState(null) const [activeTab, setActiveTab] = useState('plan') const [showLangPicker, setShowLangPicker] = useState(false) useEffect(() => { if (!token) return - shareApi.getSharedTrip(token).then(setData).catch(() => setError(true)) - }, [token]) + let cancelled = false + shareApi + .getSharedTrip(token) + .then((payload) => { + if (cancelled) return + if (!isSharedTripPayload(payload)) { + setError('unavailable') + return + } + setData(payload) + setError(null) + }) + .catch((err: unknown) => { + if (!cancelled) setError(sharedTripLoadError(err)) + }) + .finally(() => { + if (!cancelled) setRetrying(false) + }) + return () => { + cancelled = true + } + }, [token, attempt]) + + // The failed screen stays up while the next attempt runs, so a request that + // fails again straight away does not flash the spinner. + const retry = () => { + if (retrying) return + setRetrying(true) + setAttempt((n) => n + 1) + } // The server now withholds the whole itinerary when the owner disabled the map // (share_map=false), so the Plan tab has nothing to show — land on the first @@ -46,5 +79,18 @@ export function useSharedTrip() { const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase() const { convert } = useExchangeRates(base) - return { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } + return { + data, + error, + retry, + retrying, + base, + convert, + selectedDay, + setSelectedDay, + activeTab, + setActiveTab, + showLangPicker, + setShowLangPicker, + } } diff --git a/client/tests/helpers/msw/handlers/shared.ts b/client/tests/helpers/msw/handlers/shared.ts index 891f6ebbf..13ed0e279 100644 --- a/client/tests/helpers/msw/handlers/shared.ts +++ b/client/tests/helpers/msw/handlers/shared.ts @@ -5,8 +5,9 @@ export const sharedHandlers = [ http.get('/api/shared/:token', ({ params }) => { const { token } = params; + // What the share endpoint answers for an unknown, expired or revoked token. if (token === 'invalid-token' || token === 'expired-token') { - return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 }); } const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' }; diff --git a/shared/src/i18n/ar/shared.ts b/shared/src/i18n/ar/shared.ts index 14e97cf9b..f246b203d 100644 --- a/shared/src/i18n/ar/shared.ts +++ b/shared/src/i18n/ar/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'الرابط منتهي أو غير صالح', 'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.', + 'shared.loadFailed': 'تعذّر تحميل هذه الرحلة', + 'shared.loadFailedHint': 'هذا لا يعني أن الرابط قد انتهت صلاحيته. تحقّق من اتصالك وحاول مرة أخرى بعد قليل.', + 'shared.retry': 'إعادة المحاولة', 'shared.readOnly': 'عرض للقراءة فقط', 'shared.tabPlan': 'الخطة', 'shared.tabBookings': 'الحجوزات', diff --git a/shared/src/i18n/br/shared.ts b/shared/src/i18n/br/shared.ts index 603e9a5eb..563c3e2a6 100644 --- a/shared/src/i18n/br/shared.ts +++ b/shared/src/i18n/br/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link expirado ou inválido', 'shared.expiredHint': 'Este link de viagem compartilhado não está mais ativo.', + 'shared.loadFailed': 'Não foi possível carregar esta viagem', + 'shared.loadFailedHint': + 'Isso não significa que o link expirou. Verifique sua conexão e tente novamente em instantes.', + 'shared.retry': 'Tentar novamente', 'shared.readOnly': 'Visualização somente leitura', 'shared.tabPlan': 'Plano', 'shared.tabBookings': 'Reservas', diff --git a/shared/src/i18n/ca/shared.ts b/shared/src/i18n/ca/shared.ts index d0310a047..44010f57b 100644 --- a/shared/src/i18n/ca/shared.ts +++ b/shared/src/i18n/ca/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Enllaç caducat o invàlid', 'shared.expiredHint': 'Aquest enllaç de viatge compartit ja no està actiu.', + 'shared.loadFailed': "No s'ha pogut carregar aquest viatge", + 'shared.loadFailedHint': + "Això no vol dir que l'enllaç hagi caducat. Comprova la connexió i torna-ho a provar d'aquí a una estona.", + 'shared.retry': 'Torna-ho a provar', 'shared.readOnly': 'Vista de només lectura', 'shared.tabPlan': 'Pla', 'shared.tabBookings': 'Reserves', diff --git a/shared/src/i18n/cs/shared.ts b/shared/src/i18n/cs/shared.ts index 644c49e94..b4c425e7d 100644 --- a/shared/src/i18n/cs/shared.ts +++ b/shared/src/i18n/cs/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Odkaz vypršel nebo je neplatný', 'shared.expiredHint': 'Tento sdílený odkaz na cestu již není aktivní.', + 'shared.loadFailed': 'Cestu se nepodařilo načíst', + 'shared.loadFailedHint': 'Neznamená to, že odkaz vypršel. Zkontrolujte připojení a za chvíli to zkuste znovu.', + 'shared.retry': 'Zkusit znovu', 'shared.readOnly': 'Sdílené zobrazení – pouze pro čtení', 'shared.tabPlan': 'Plán', 'shared.tabBookings': 'Rezervace', diff --git a/shared/src/i18n/de/shared.ts b/shared/src/i18n/de/shared.ts index f1289f90e..02839504c 100644 --- a/shared/src/i18n/de/shared.ts +++ b/shared/src/i18n/de/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link abgelaufen oder ungültig', 'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.', + 'shared.loadFailed': 'Die Reise konnte nicht geladen werden', + 'shared.loadFailedHint': + 'Das heißt nicht, dass der Link abgelaufen ist. Prüfe deine Verbindung und versuche es gleich noch einmal.', + 'shared.retry': 'Erneut versuchen', 'shared.readOnly': 'Nur-Lesen Ansicht', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Buchungen', diff --git a/shared/src/i18n/en/shared.ts b/shared/src/i18n/en/shared.ts index f784b1181..5f568858e 100644 --- a/shared/src/i18n/en/shared.ts +++ b/shared/src/i18n/en/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link expired or invalid', 'shared.expiredHint': 'This shared trip link is no longer active.', + 'shared.loadFailed': 'This trip could not be loaded', + 'shared.loadFailedHint': 'That does not mean the link has expired. Check your connection and try again in a moment.', + 'shared.retry': 'Try again', 'shared.readOnly': 'Read-only shared view', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Bookings', diff --git a/shared/src/i18n/es/shared.ts b/shared/src/i18n/es/shared.ts index cb8fb0d6a..0a5980d04 100644 --- a/shared/src/i18n/es/shared.ts +++ b/shared/src/i18n/es/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Enlace expirado o inválido', 'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.', + 'shared.loadFailed': 'No se pudo cargar este viaje', + 'shared.loadFailedHint': + 'Eso no significa que el enlace haya caducado. Comprueba tu conexión y vuelve a intentarlo en un momento.', + 'shared.retry': 'Reintentar', 'shared.readOnly': 'Vista de solo lectura', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Reservas', diff --git a/shared/src/i18n/fr/shared.ts b/shared/src/i18n/fr/shared.ts index 58bf39e02..5617203e6 100644 --- a/shared/src/i18n/fr/shared.ts +++ b/shared/src/i18n/fr/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Lien expiré ou invalide', 'shared.expiredHint': "Ce lien de partage n'est plus actif.", + 'shared.loadFailed': 'Impossible de charger ce voyage', + 'shared.loadFailedHint': + 'Cela ne veut pas dire que le lien a expiré. Vérifiez votre connexion et réessayez dans un instant.', + 'shared.retry': 'Réessayer', 'shared.readOnly': 'Vue en lecture seule', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Réservations', diff --git a/shared/src/i18n/gr/shared.ts b/shared/src/i18n/gr/shared.ts index cf5c0fe00..5fd1b1a04 100644 --- a/shared/src/i18n/gr/shared.ts +++ b/shared/src/i18n/gr/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Ο σύνδεσμος έληξε ή δεν είναι έγκυρος', 'shared.expiredHint': 'Αυτός ο κοινόχρηστος σύνδεσμος ταξιδιού δεν είναι πλέον ενεργός.', + 'shared.loadFailed': 'Δεν ήταν δυνατή η φόρτωση αυτού του ταξιδιού', + 'shared.loadFailedHint': + 'Αυτό δεν σημαίνει ότι ο σύνδεσμος έχει λήξει. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά σε λίγο.', + 'shared.retry': 'Δοκιμάστε ξανά', 'shared.readOnly': 'Κοινόχρηστη προβολή μόνο για ανάγνωση', 'shared.tabPlan': 'Σχέδιο', 'shared.tabBookings': 'Κρατήσεις', diff --git a/shared/src/i18n/hu/shared.ts b/shared/src/i18n/hu/shared.ts index d9e30cc4d..a5ffaa131 100644 --- a/shared/src/i18n/hu/shared.ts +++ b/shared/src/i18n/hu/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link lejárt vagy érvénytelen', 'shared.expiredHint': 'Ez a megosztott utazási link már nem aktív.', + 'shared.loadFailed': 'Az utazást nem sikerült betölteni', + 'shared.loadFailedHint': + 'Ez nem jelenti azt, hogy a link lejárt. Ellenőrizd a kapcsolatot, és próbáld újra egy kicsit később.', + 'shared.retry': 'Újrapróbálás', 'shared.readOnly': 'Csak olvasható megosztott nézet', 'shared.tabPlan': 'Terv', 'shared.tabBookings': 'Foglalások', diff --git a/shared/src/i18n/id/shared.ts b/shared/src/i18n/id/shared.ts index f635e7976..164c26392 100644 --- a/shared/src/i18n/id/shared.ts +++ b/shared/src/i18n/id/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Tautan kedaluwarsa atau tidak valid', 'shared.expiredHint': 'Tautan perjalanan bersama ini tidak lagi aktif.', + 'shared.loadFailed': 'Perjalanan ini tidak dapat dimuat', + 'shared.loadFailedHint': + 'Ini tidak berarti tautannya sudah kedaluwarsa. Periksa koneksi Anda dan coba lagi sebentar lagi.', + 'shared.retry': 'Coba lagi', 'shared.readOnly': 'Tampilan bersama — hanya baca', 'shared.tabPlan': 'Rencana', 'shared.tabBookings': 'Pemesanan', diff --git a/shared/src/i18n/it/shared.ts b/shared/src/i18n/it/shared.ts index ba0fd4a93..bf52af3a9 100644 --- a/shared/src/i18n/it/shared.ts +++ b/shared/src/i18n/it/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link scaduto o non valido', 'shared.expiredHint': 'Questo link di viaggio condiviso non è più attivo.', + 'shared.loadFailed': 'Impossibile caricare questo viaggio', + 'shared.loadFailedHint': 'Non significa che il link sia scaduto. Controlla la connessione e riprova tra un momento.', + 'shared.retry': 'Riprova', 'shared.readOnly': 'Vista in sola lettura', 'shared.tabPlan': 'Programma', 'shared.tabBookings': 'Prenotazioni', diff --git a/shared/src/i18n/ja/shared.ts b/shared/src/i18n/ja/shared.ts index 9eee1b6b8..152f2a58c 100644 --- a/shared/src/i18n/ja/shared.ts +++ b/shared/src/i18n/ja/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'リンクが無効', 'shared.expiredHint': 'この共有リンクは無効です。', + 'shared.loadFailed': 'この旅行を読み込めませんでした', + 'shared.loadFailedHint': + 'リンクの有効期限が切れたわけではありません。接続を確認して、しばらくしてからもう一度お試しください。', + 'shared.retry': '再試行', 'shared.readOnly': '読み取り専用', 'shared.tabPlan': 'プラン', 'shared.tabBookings': '予約', diff --git a/shared/src/i18n/ko/shared.ts b/shared/src/i18n/ko/shared.ts index 105dd11e3..db2f61f94 100644 --- a/shared/src/i18n/ko/shared.ts +++ b/shared/src/i18n/ko/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': '링크가 만료되었거나 유효하지 않습니다', 'shared.expiredHint': '이 공유 여행 링크는 더 이상 유효하지 않습니다.', + 'shared.loadFailed': '이 여행을 불러올 수 없습니다', + 'shared.loadFailedHint': '링크가 만료되었다는 뜻은 아닙니다. 연결 상태를 확인한 뒤 잠시 후 다시 시도해 주세요.', + 'shared.retry': '다시 시도', 'shared.readOnly': '읽기 전용 공유 보기', 'shared.tabPlan': '계획', 'shared.tabBookings': '예약', diff --git a/shared/src/i18n/nl/shared.ts b/shared/src/i18n/nl/shared.ts index 325b1ae80..25898db7c 100644 --- a/shared/src/i18n/nl/shared.ts +++ b/shared/src/i18n/nl/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link verlopen of ongeldig', 'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.', + 'shared.loadFailed': 'Deze reis kon niet worden geladen', + 'shared.loadFailedHint': + 'Dat betekent niet dat de link is verlopen. Controleer je verbinding en probeer het zo nog eens.', + 'shared.retry': 'Opnieuw proberen', 'shared.readOnly': 'Alleen-lezen weergave', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Boekingen', diff --git a/shared/src/i18n/pl/shared.ts b/shared/src/i18n/pl/shared.ts index 30402fdc3..999a349ff 100644 --- a/shared/src/i18n/pl/shared.ts +++ b/shared/src/i18n/pl/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link wygasł lub jest nieprawidłowy', 'shared.expiredHint': 'Ten link do podróży jest już nieaktywny.', + 'shared.loadFailed': 'Nie udało się wczytać tej podróży', + 'shared.loadFailedHint': 'Nie oznacza to, że link wygasł. Sprawdź połączenie i spróbuj ponownie za chwilę.', + 'shared.retry': 'Spróbuj ponownie', 'shared.readOnly': 'Widok udostępniony tylko do odczytu', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Rezerwacje', diff --git a/shared/src/i18n/ru/shared.ts b/shared/src/i18n/ru/shared.ts index 727b58f97..9e4709de4 100644 --- a/shared/src/i18n/ru/shared.ts +++ b/shared/src/i18n/ru/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Ссылка устарела или недействительна', 'shared.expiredHint': 'Эта ссылка на поездку больше не активна.', + 'shared.loadFailed': 'Не удалось загрузить поездку', + 'shared.loadFailedHint': + 'Это не значит, что срок действия ссылки истёк. Проверьте подключение и повторите попытку чуть позже.', + 'shared.retry': 'Повторить', 'shared.readOnly': 'Режим только для чтения', 'shared.tabPlan': 'План', 'shared.tabBookings': 'Бронирования', diff --git a/shared/src/i18n/sv/shared.ts b/shared/src/i18n/sv/shared.ts index 07b4a94ec..6e199fde4 100644 --- a/shared/src/i18n/sv/shared.ts +++ b/shared/src/i18n/sv/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Länken har gått ut eller är ogiltig', 'shared.expiredHint': 'Den här länken till den delade resan är inte längre aktiv.', + 'shared.loadFailed': 'Resan kunde inte laddas', + 'shared.loadFailedHint': + 'Det betyder inte att länken har gått ut. Kontrollera din anslutning och försök igen om en liten stund.', + 'shared.retry': 'Försök igen', 'shared.readOnly': 'Delad vy i skrivskyddat läge', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Bokningar', diff --git a/shared/src/i18n/tr/shared.ts b/shared/src/i18n/tr/shared.ts index a69bdcbc6..7da7bd8a6 100644 --- a/shared/src/i18n/tr/shared.ts +++ b/shared/src/i18n/tr/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Bağlantının süresi dolmuş veya geçersiz', 'shared.expiredHint': 'Bu paylaşılan gezi bağlantısı artık etkin değil.', + 'shared.loadFailed': 'Bu gezi yüklenemedi', + 'shared.loadFailedHint': + 'Bu, paylaşım bağlantısının süresinin dolduğu anlamına gelmez. İnternet bağlantınızı kontrol edip biraz sonra tekrar deneyin.', + 'shared.retry': 'Yeniden dene', 'shared.readOnly': 'Salt okunur paylaşılan görünüm', 'shared.tabPlan': 'Planı', 'shared.tabBookings': 'Rezervasyonlar', diff --git a/shared/src/i18n/uk/shared.ts b/shared/src/i18n/uk/shared.ts index 53ca18229..a679a6807 100644 --- a/shared/src/i18n/uk/shared.ts +++ b/shared/src/i18n/uk/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Посилання прострочено або недійсне', 'shared.expiredHint': 'Це посилання на поїздку більше не активне.', + 'shared.loadFailed': 'Не вдалося завантажити поїздку', + 'shared.loadFailedHint': + 'Це не означає, що термін дії посилання минув. Перевірте з’єднання та спробуйте ще раз трохи згодом.', + 'shared.retry': 'Спробувати ще раз', 'shared.readOnly': 'Тільки для читання', 'shared.tabPlan': 'План', 'shared.tabBookings': 'Бронювання', diff --git a/shared/src/i18n/vi/shared.ts b/shared/src/i18n/vi/shared.ts index 505d0ec92..95ecf8b5a 100644 --- a/shared/src/i18n/vi/shared.ts +++ b/shared/src/i18n/vi/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Liên kết đã hết hạn hoặc không hợp lệ', 'shared.expiredHint': 'Liên kết chuyến đi được chia sẻ này không còn hoạt động.', + 'shared.loadFailed': 'Không thể tải chuyến đi này', + 'shared.loadFailedHint': + 'Điều này không có nghĩa là liên kết đã hết hạn. Hãy kiểm tra kết nối và thử lại sau ít phút.', + 'shared.retry': 'Thử lại', 'shared.readOnly': 'Chế độ xem chia sẻ chỉ đọc', 'shared.tabPlan': 'Kế hoạch', 'shared.tabBookings': 'Đặt chỗ', diff --git a/shared/src/i18n/zh-TW/shared.ts b/shared/src/i18n/zh-TW/shared.ts index 59f01e27c..8ece885d0 100644 --- a/shared/src/i18n/zh-TW/shared.ts +++ b/shared/src/i18n/zh-TW/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': '連結已過期或無效', 'shared.expiredHint': '此共享旅行連結已失效。', + 'shared.loadFailed': '無法載入此旅行', + 'shared.loadFailedHint': '這並不代表連結已過期。請檢查網路連線,稍後再試。', + 'shared.retry': '重試', 'shared.readOnly': '只讀共享檢視', 'shared.tabPlan': '計劃', 'shared.tabBookings': '預訂', diff --git a/shared/src/i18n/zh/shared.ts b/shared/src/i18n/zh/shared.ts index 149da482c..663aed7f2 100644 --- a/shared/src/i18n/zh/shared.ts +++ b/shared/src/i18n/zh/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': '链接已过期或无效', 'shared.expiredHint': '此共享旅行链接已失效。', + 'shared.loadFailed': '无法加载此旅行', + 'shared.loadFailedHint': '这并不代表链接已过期。请检查网络连接,稍后再试。', + 'shared.retry': '重试', 'shared.readOnly': '只读共享视图', 'shared.tabPlan': '计划', 'shared.tabBookings': '预订',