mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
|
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
|
||||||
import { Routes, Route } from 'react-router';
|
import { Routes, Route } from 'react-router';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { shareApi } from '../api/client';
|
||||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||||
import { buildSettings } from '../../tests/helpers/factories';
|
import { buildSettings } from '../../tests/helpers/factories';
|
||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
@@ -1544,4 +1546,177 @@ describe('SharedTripPage', () => {
|
|||||||
expect(header?.style.overflow).toBe('hidden');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { resolveBasemap } from '../utils/tileUrl';
|
|||||||
import { useSharedTrip } from './sharedTrip/useSharedTrip';
|
import { useSharedTrip } from './sharedTrip/useSharedTrip';
|
||||||
import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails';
|
import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails';
|
||||||
import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails';
|
import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails';
|
||||||
|
import { SharedTripErrorScreen } from './sharedTrip/SharedTripErrorScreen';
|
||||||
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
|
||||||
|
|
||||||
@@ -98,6 +99,8 @@ export default function SharedTripPage() {
|
|||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
|
retry,
|
||||||
|
retrying,
|
||||||
base,
|
base,
|
||||||
convert,
|
convert,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
@@ -108,23 +111,7 @@ export default function SharedTripPage() {
|
|||||||
setShowLangPicker,
|
setShowLangPicker,
|
||||||
} = useSharedTrip();
|
} = useSharedTrip();
|
||||||
|
|
||||||
if (error)
|
if (error) return <SharedTripErrorScreen reason={error} retrying={retrying} onRetry={retry} />;
|
||||||
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 (!data)
|
if (!data)
|
||||||
return (
|
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 { 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)', () => {
|
describe('sharedTripModel (#2320)', () => {
|
||||||
it('accepts http and https and nothing else', () => {
|
it('accepts http and https and nothing else', () => {
|
||||||
@@ -35,3 +36,65 @@ describe('sharedTripModel (#2320)', () => {
|
|||||||
expect(linkHost('nonsense')).toBe('nonsense')
|
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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,28 +2,61 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams } from 'react-router'
|
import { useParams } from 'react-router'
|
||||||
import { shareApi } from '../../api/client'
|
import { shareApi } from '../../api/client'
|
||||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
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
|
* Shared-trip (public) data hook — owns the token lookup, the read-only share
|
||||||
* fetch and the view state (selected day, active tab, language picker).
|
* fetch with its retry, and the view state (selected day, active tab, language
|
||||||
* SharedTripPage is a pure wiring container; the post-load derivations
|
* 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.
|
* (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() {
|
export function useSharedTrip() {
|
||||||
const { token } = useParams<{ token: string }>()
|
const { token } = useParams<{ token: string }>()
|
||||||
// The shared payload is an open-ended snapshot (trip, days, assignments, …),
|
// The shared payload is an open-ended snapshot (trip, days, assignments, …),
|
||||||
// matched 1:1 from the public share endpoint — kept loosely typed as before.
|
// matched 1:1 from the public share endpoint — kept loosely typed as before.
|
||||||
const [data, setData] = useState<any>(null)
|
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 [selectedDay, setSelectedDay] = useState<number | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState('plan')
|
const [activeTab, setActiveTab] = useState('plan')
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
|
let cancelled = false
|
||||||
}, [token])
|
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
|
// 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
|
// (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 base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase()
|
||||||
const { convert } = useExchangeRates(base)
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ export const sharedHandlers = [
|
|||||||
http.get('/api/shared/:token', ({ params }) => {
|
http.get('/api/shared/:token', ({ params }) => {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
|
// What the share endpoint answers for an unknown, expired or revoked token.
|
||||||
if (token === 'invalid-token' || token === 'expired-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' };
|
const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' };
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'الرابط منتهي أو غير صالح',
|
'shared.expired': 'الرابط منتهي أو غير صالح',
|
||||||
'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.',
|
'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.',
|
||||||
|
'shared.loadFailed': 'تعذّر تحميل هذه الرحلة',
|
||||||
|
'shared.loadFailedHint': 'هذا لا يعني أن الرابط قد انتهت صلاحيته. تحقّق من اتصالك وحاول مرة أخرى بعد قليل.',
|
||||||
|
'shared.retry': 'إعادة المحاولة',
|
||||||
'shared.readOnly': 'عرض للقراءة فقط',
|
'shared.readOnly': 'عرض للقراءة فقط',
|
||||||
'shared.tabPlan': 'الخطة',
|
'shared.tabPlan': 'الخطة',
|
||||||
'shared.tabBookings': 'الحجوزات',
|
'shared.tabBookings': 'الحجوزات',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link expirado ou inválido',
|
'shared.expired': 'Link expirado ou inválido',
|
||||||
'shared.expiredHint': 'Este link de viagem compartilhado não está mais ativo.',
|
'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.readOnly': 'Visualização somente leitura',
|
||||||
'shared.tabPlan': 'Plano',
|
'shared.tabPlan': 'Plano',
|
||||||
'shared.tabBookings': 'Reservas',
|
'shared.tabBookings': 'Reservas',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Enllaç caducat o invàlid',
|
'shared.expired': 'Enllaç caducat o invàlid',
|
||||||
'shared.expiredHint': 'Aquest enllaç de viatge compartit ja no està actiu.',
|
'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.readOnly': 'Vista de només lectura',
|
||||||
'shared.tabPlan': 'Pla',
|
'shared.tabPlan': 'Pla',
|
||||||
'shared.tabBookings': 'Reserves',
|
'shared.tabBookings': 'Reserves',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Odkaz vypršel nebo je neplatný',
|
'shared.expired': 'Odkaz vypršel nebo je neplatný',
|
||||||
'shared.expiredHint': 'Tento sdílený odkaz na cestu již není aktivní.',
|
'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.readOnly': 'Sdílené zobrazení – pouze pro čtení',
|
||||||
'shared.tabPlan': 'Plán',
|
'shared.tabPlan': 'Plán',
|
||||||
'shared.tabBookings': 'Rezervace',
|
'shared.tabBookings': 'Rezervace',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link abgelaufen oder ungültig',
|
'shared.expired': 'Link abgelaufen oder ungültig',
|
||||||
'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
|
'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.readOnly': 'Nur-Lesen Ansicht',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Buchungen',
|
'shared.tabBookings': 'Buchungen',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link expired or invalid',
|
'shared.expired': 'Link expired or invalid',
|
||||||
'shared.expiredHint': 'This shared trip link is no longer active.',
|
'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.readOnly': 'Read-only shared view',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Bookings',
|
'shared.tabBookings': 'Bookings',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Enlace expirado o inválido',
|
'shared.expired': 'Enlace expirado o inválido',
|
||||||
'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.',
|
'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.readOnly': 'Vista de solo lectura',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Reservas',
|
'shared.tabBookings': 'Reservas',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Lien expiré ou invalide',
|
'shared.expired': 'Lien expiré ou invalide',
|
||||||
'shared.expiredHint': "Ce lien de partage n'est plus actif.",
|
'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.readOnly': 'Vue en lecture seule',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Réservations',
|
'shared.tabBookings': 'Réservations',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Ο σύνδεσμος έληξε ή δεν είναι έγκυρος',
|
'shared.expired': 'Ο σύνδεσμος έληξε ή δεν είναι έγκυρος',
|
||||||
'shared.expiredHint': 'Αυτός ο κοινόχρηστος σύνδεσμος ταξιδιού δεν είναι πλέον ενεργός.',
|
'shared.expiredHint': 'Αυτός ο κοινόχρηστος σύνδεσμος ταξιδιού δεν είναι πλέον ενεργός.',
|
||||||
|
'shared.loadFailed': 'Δεν ήταν δυνατή η φόρτωση αυτού του ταξιδιού',
|
||||||
|
'shared.loadFailedHint':
|
||||||
|
'Αυτό δεν σημαίνει ότι ο σύνδεσμος έχει λήξει. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά σε λίγο.',
|
||||||
|
'shared.retry': 'Δοκιμάστε ξανά',
|
||||||
'shared.readOnly': 'Κοινόχρηστη προβολή μόνο για ανάγνωση',
|
'shared.readOnly': 'Κοινόχρηστη προβολή μόνο για ανάγνωση',
|
||||||
'shared.tabPlan': 'Σχέδιο',
|
'shared.tabPlan': 'Σχέδιο',
|
||||||
'shared.tabBookings': 'Κρατήσεις',
|
'shared.tabBookings': 'Κρατήσεις',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link lejárt vagy érvénytelen',
|
'shared.expired': 'Link lejárt vagy érvénytelen',
|
||||||
'shared.expiredHint': 'Ez a megosztott utazási link már nem aktív.',
|
'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.readOnly': 'Csak olvasható megosztott nézet',
|
||||||
'shared.tabPlan': 'Terv',
|
'shared.tabPlan': 'Terv',
|
||||||
'shared.tabBookings': 'Foglalások',
|
'shared.tabBookings': 'Foglalások',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Tautan kedaluwarsa atau tidak valid',
|
'shared.expired': 'Tautan kedaluwarsa atau tidak valid',
|
||||||
'shared.expiredHint': 'Tautan perjalanan bersama ini tidak lagi aktif.',
|
'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.readOnly': 'Tampilan bersama — hanya baca',
|
||||||
'shared.tabPlan': 'Rencana',
|
'shared.tabPlan': 'Rencana',
|
||||||
'shared.tabBookings': 'Pemesanan',
|
'shared.tabBookings': 'Pemesanan',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link scaduto o non valido',
|
'shared.expired': 'Link scaduto o non valido',
|
||||||
'shared.expiredHint': 'Questo link di viaggio condiviso non è più attivo.',
|
'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.readOnly': 'Vista in sola lettura',
|
||||||
'shared.tabPlan': 'Programma',
|
'shared.tabPlan': 'Programma',
|
||||||
'shared.tabBookings': 'Prenotazioni',
|
'shared.tabBookings': 'Prenotazioni',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'リンクが無効',
|
'shared.expired': 'リンクが無効',
|
||||||
'shared.expiredHint': 'この共有リンクは無効です。',
|
'shared.expiredHint': 'この共有リンクは無効です。',
|
||||||
|
'shared.loadFailed': 'この旅行を読み込めませんでした',
|
||||||
|
'shared.loadFailedHint':
|
||||||
|
'リンクの有効期限が切れたわけではありません。接続を確認して、しばらくしてからもう一度お試しください。',
|
||||||
|
'shared.retry': '再試行',
|
||||||
'shared.readOnly': '読み取り専用',
|
'shared.readOnly': '読み取り専用',
|
||||||
'shared.tabPlan': 'プラン',
|
'shared.tabPlan': 'プラン',
|
||||||
'shared.tabBookings': '予約',
|
'shared.tabBookings': '予約',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': '링크가 만료되었거나 유효하지 않습니다',
|
'shared.expired': '링크가 만료되었거나 유효하지 않습니다',
|
||||||
'shared.expiredHint': '이 공유 여행 링크는 더 이상 유효하지 않습니다.',
|
'shared.expiredHint': '이 공유 여행 링크는 더 이상 유효하지 않습니다.',
|
||||||
|
'shared.loadFailed': '이 여행을 불러올 수 없습니다',
|
||||||
|
'shared.loadFailedHint': '링크가 만료되었다는 뜻은 아닙니다. 연결 상태를 확인한 뒤 잠시 후 다시 시도해 주세요.',
|
||||||
|
'shared.retry': '다시 시도',
|
||||||
'shared.readOnly': '읽기 전용 공유 보기',
|
'shared.readOnly': '읽기 전용 공유 보기',
|
||||||
'shared.tabPlan': '계획',
|
'shared.tabPlan': '계획',
|
||||||
'shared.tabBookings': '예약',
|
'shared.tabBookings': '예약',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link verlopen of ongeldig',
|
'shared.expired': 'Link verlopen of ongeldig',
|
||||||
'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.',
|
'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.readOnly': 'Alleen-lezen weergave',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Boekingen',
|
'shared.tabBookings': 'Boekingen',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Link wygasł lub jest nieprawidłowy',
|
'shared.expired': 'Link wygasł lub jest nieprawidłowy',
|
||||||
'shared.expiredHint': 'Ten link do podróży jest już nieaktywny.',
|
'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.readOnly': 'Widok udostępniony tylko do odczytu',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Rezerwacje',
|
'shared.tabBookings': 'Rezerwacje',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Ссылка устарела или недействительна',
|
'shared.expired': 'Ссылка устарела или недействительна',
|
||||||
'shared.expiredHint': 'Эта ссылка на поездку больше не активна.',
|
'shared.expiredHint': 'Эта ссылка на поездку больше не активна.',
|
||||||
|
'shared.loadFailed': 'Не удалось загрузить поездку',
|
||||||
|
'shared.loadFailedHint':
|
||||||
|
'Это не значит, что срок действия ссылки истёк. Проверьте подключение и повторите попытку чуть позже.',
|
||||||
|
'shared.retry': 'Повторить',
|
||||||
'shared.readOnly': 'Режим только для чтения',
|
'shared.readOnly': 'Режим только для чтения',
|
||||||
'shared.tabPlan': 'План',
|
'shared.tabPlan': 'План',
|
||||||
'shared.tabBookings': 'Бронирования',
|
'shared.tabBookings': 'Бронирования',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Länken har gått ut eller är ogiltig',
|
'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.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.readOnly': 'Delad vy i skrivskyddat läge',
|
||||||
'shared.tabPlan': 'Plan',
|
'shared.tabPlan': 'Plan',
|
||||||
'shared.tabBookings': 'Bokningar',
|
'shared.tabBookings': 'Bokningar',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Bağlantının süresi dolmuş veya geçersiz',
|
'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.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.readOnly': 'Salt okunur paylaşılan görünüm',
|
||||||
'shared.tabPlan': 'Planı',
|
'shared.tabPlan': 'Planı',
|
||||||
'shared.tabBookings': 'Rezervasyonlar',
|
'shared.tabBookings': 'Rezervasyonlar',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Посилання прострочено або недійсне',
|
'shared.expired': 'Посилання прострочено або недійсне',
|
||||||
'shared.expiredHint': 'Це посилання на поїздку більше не активне.',
|
'shared.expiredHint': 'Це посилання на поїздку більше не активне.',
|
||||||
|
'shared.loadFailed': 'Не вдалося завантажити поїздку',
|
||||||
|
'shared.loadFailedHint':
|
||||||
|
'Це не означає, що термін дії посилання минув. Перевірте з’єднання та спробуйте ще раз трохи згодом.',
|
||||||
|
'shared.retry': 'Спробувати ще раз',
|
||||||
'shared.readOnly': 'Тільки для читання',
|
'shared.readOnly': 'Тільки для читання',
|
||||||
'shared.tabPlan': 'План',
|
'shared.tabPlan': 'План',
|
||||||
'shared.tabBookings': 'Бронювання',
|
'shared.tabBookings': 'Бронювання',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': 'Liên kết đã hết hạn hoặc không hợp lệ',
|
'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.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.readOnly': 'Chế độ xem chia sẻ chỉ đọc',
|
||||||
'shared.tabPlan': 'Kế hoạch',
|
'shared.tabPlan': 'Kế hoạch',
|
||||||
'shared.tabBookings': 'Đặt chỗ',
|
'shared.tabBookings': 'Đặt chỗ',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': '連結已過期或無效',
|
'shared.expired': '連結已過期或無效',
|
||||||
'shared.expiredHint': '此共享旅行連結已失效。',
|
'shared.expiredHint': '此共享旅行連結已失效。',
|
||||||
|
'shared.loadFailed': '無法載入此旅行',
|
||||||
|
'shared.loadFailedHint': '這並不代表連結已過期。請檢查網路連線,稍後再試。',
|
||||||
|
'shared.retry': '重試',
|
||||||
'shared.readOnly': '只讀共享檢視',
|
'shared.readOnly': '只讀共享檢視',
|
||||||
'shared.tabPlan': '計劃',
|
'shared.tabPlan': '計劃',
|
||||||
'shared.tabBookings': '預訂',
|
'shared.tabBookings': '預訂',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types';
|
|||||||
const shared: TranslationStrings = {
|
const shared: TranslationStrings = {
|
||||||
'shared.expired': '链接已过期或无效',
|
'shared.expired': '链接已过期或无效',
|
||||||
'shared.expiredHint': '此共享旅行链接已失效。',
|
'shared.expiredHint': '此共享旅行链接已失效。',
|
||||||
|
'shared.loadFailed': '无法加载此旅行',
|
||||||
|
'shared.loadFailedHint': '这并不代表链接已过期。请检查网络连接,稍后再试。',
|
||||||
|
'shared.retry': '重试',
|
||||||
'shared.readOnly': '只读共享视图',
|
'shared.readOnly': '只读共享视图',
|
||||||
'shared.tabPlan': '计划',
|
'shared.tabPlan': '计划',
|
||||||
'shared.tabBookings': '预订',
|
'shared.tabBookings': '预订',
|
||||||
|
|||||||
Reference in New Issue
Block a user