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 (
+
+ )
+}
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': '预订',