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': '预订',