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.
This commit is contained in:
Maurice
2026-09-25 22:00:19 +02:00
parent bea3af4b45
commit 0a3a993dfc
30 changed files with 472 additions and 26 deletions
+175
View File
@@ -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', '<html><head><title>404 Not Found</title></head><body></body></html>', '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('<!doctype html><html><body>Sign in</body></html>', {
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<void>((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);
});
});
});
+4 -17
View File
@@ -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 (
<div
className="bg-[#f3f4f6]"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}
>
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 'calc(48px * var(--fs-scale-title, 1))', marginBottom: 16 }}>🔒</div>
<h1 className="text-[#111827]" style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700 }}>
{t('shared.expired')}
</h1>
<p className="text-[#6b7280]" style={{ marginTop: 8 }}>
{t('shared.expiredHint')}
</p>
</div>
</div>
);
if (error) return <SharedTripErrorScreen reason={error} retrying={retrying} onRetry={retry} />;
if (!data)
return (
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-surface-secondary">
<div className="flex max-w-md flex-col items-center p-10 text-center" role={expired ? undefined : 'alert'}>
{expired ? (
<div aria-hidden style={{ fontSize: 'calc(48px * var(--fs-scale-title, 1))', marginBottom: 16 }}>
🔒
</div>
) : (
<CloudOff size={44} strokeWidth={1.75} className="mb-4 text-content-muted" aria-hidden />
)}
<h1 className="text-balance text-subtitle font-bold text-content">{t(expired ? 'shared.expired' : 'shared.loadFailed')}</h1>
<p className="mt-2 text-balance text-body text-content-muted">
{t(expired ? 'shared.expiredHint' : 'shared.loadFailedHint')}
</p>
{!expired && (
<button
type="button"
onClick={onRetry}
disabled={retrying}
aria-busy={retrying}
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-wait disabled:opacity-70"
>
{retrying ? (
<Loader2 size={15} className="animate-spin" aria-hidden />
) : (
<RotateCcw size={15} aria-hidden />
)}
{t('shared.retry')}
</button>
)}
</div>
</div>
)
}
@@ -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, '<html><body><h1>404 Not Found</h1></body></html>'))).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('<!doctype html><html><body>Sign in</body></html>')).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)
})
})
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+53 -7
View File
@@ -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<any>(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<SharedTripLoadError | null>(null)
const [attempt, setAttempt] = useState(0)
const [retrying, setRetrying] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(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,
}
}
+2 -1
View File
@@ -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' };
+3
View File
@@ -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': 'الحجوزات',
+4
View File
@@ -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',
+4
View File
@@ -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',
+3
View File
@@ -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',
+4
View File
@@ -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',
+3
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'Κρατήσεις',
+4
View File
@@ -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',
+4
View File
@@ -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',
+3
View File
@@ -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',
+4
View File
@@ -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': '予約',
+3
View File
@@ -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': '예약',
+4
View File
@@ -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',
+3
View File
@@ -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',
+4
View File
@@ -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': 'Бронирования',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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': 'Бронювання',
+4
View File
@@ -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ỗ',
+3
View File
@@ -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': '預訂',
+3
View File
@@ -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': '预订',