@@ -767,9 +774,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
{t('costs.payment')}
{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}
- {cur !== base && (
+ {line && (
- {formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)}
+ {formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
)}
@@ -799,9 +806,10 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
* with the balances; each line is signed by what it does to the final, so the
* column reads as a subtraction and the rows add up to the line above them.
*/
-function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
+function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }: {
row: BudgetParticipantFinal
items: BudgetItem[]
+ ctx: CostsCtx
base: string
locale: string
t: TFn
@@ -810,6 +818,12 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale)
const { fronted, moved, outstanding } = finalBudgetSources(row, items)
const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}`
+ // Beside a converted row's name, what was entered, as the list above names it (#2525).
+ const nameOf = (itemId: number, name: string, shown: number) => {
+ const e = items.find(i => i.id === itemId)
+ const entered = e ? lineOf(paidByUser(e, row.user_id), e, ctx, shown)?.entered : null
+ return entered ? `${name} · ${formatMoney(entered.amount, entered.currency, locale)}` : name
+ }
const line = (key: string, label: string, value: string) => (
{label}
@@ -828,7 +842,7 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
{line('expenses', t('costs.finalExpenses'), signed(row.expenses))}
{line('reimbursed', t('costs.finalReimbursed'), signed(-row.reimbursed))}
{line('pending', t('costs.finalPending'), signed(-row.pending))}
- {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, r.name, signed(r.amount))))}
+ {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, nameOf(r.item_id, r.name, r.amount), signed(r.amount))))}
{section(t('costs.finalReimbursed'), moved.map(r => line(`s${r.settlement_id}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
{section(t('costs.finalPending'), outstanding.map((r, i) => line(`f${i}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
@@ -871,12 +885,13 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
* (editable like an expense's, unlike the legacy created_at-only date, see
* `settlementDate` in CostsPanel.helpers.ts).
*/
-function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: {
+function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, people, me, toast, t, onSaved }: {
open: boolean
editing: CostsSettlement | null
onClose: () => void
tripId: number
base: string
+ tripCurrency: string
people: TripMember[]
me: number
toast: { error: (message: string) => void }
@@ -907,7 +922,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa
const save = async () => {
if (!valid || saving) return
setSaving(true)
- const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }
+ const data = withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }, tripCurrency)
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts
index aa8c67962..bd5fb6ef7 100644
--- a/client/src/mobile/screens/trip/tabs/costsModel.ts
+++ b/client/src/mobile/screens/trip/tabs/costsModel.ts
@@ -1,7 +1,7 @@
-import type { BudgetParticipantFinal, CostCategory } from '@trek/shared'
+import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared'
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
-import { convertBooked } from '../../../../hooks/useExchangeRates'
+import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates'
import { currencyDecimals } from '../../../../utils/formatters'
import type { BudgetItem } from '../../../../types'
@@ -19,6 +19,8 @@ export interface CostsCtx {
me: number
/** The trip's own currency — what a NULL `budget_items.currency` means. */
tripCurrency: string
+ /** The currency everything is shown in, the base `convert` converts to. */
+ displayCurrency: string
convert: (amount: number, currency: string | null | undefined) => number
}
@@ -32,6 +34,15 @@ export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert)
}
+/**
+ * The line under an amount of this expense the list shows converted, what was entered and
+ * where it went (#2525); null when there is nothing to explain. `shown` is the display
+ * value printed beside it.
+ */
+export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) {
+ return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown)
+}
+
/** Expense total converted to the display/base currency, at its booked rate. */
export function baseTotal(e: BudgetItem, ctx: CostsCtx): number {
return booked(e.total_price || 0, e, ctx)
@@ -105,6 +116,10 @@ export interface CostsSettlementResponse {
settlements: CostsSettlement[]
/** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */
finalBudgets: BudgetParticipantFinal[]
+ /** The currency the figures are in: the display currency, or the trip's own without a quote for it. */
+ currency?: string
+ /** Rows no rate could convert, left out of every figure above. */
+ unconverted?: BudgetUnconverted
}
// ── hero / tile totals (spec §3.1-§3.3) ────────────────────────────────────
@@ -315,12 +330,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
}
}
- const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', `Amount (${opts.base})`, 'Note']
+ // Read in another currency than the trip's, what each row counts as in the trip currency
+ // too, the figure every sum is built from (#2525). Same columns as the desktop export.
+ const trip = opts.ctx.tripCurrency.toUpperCase()
+ const tripCol = trip !== opts.base.toUpperCase()
+ const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? [`Amount (${trip})`] : []), `Amount (${opts.base})`, 'Note']
const rows = [header.join(sep)]
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
for (const e of sorted) {
const cur = currencyOf(e, opts.ctx)
const note = readUserNote(e)
+ const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert)
rows.push(
[
esc(fmtDate(e.expense_date || '')),
@@ -328,6 +348,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
esc(opts.t(catMeta(e.category).labelKey)),
(e.total_price || 0).toFixed(currencyDecimals(cur)),
cur,
+ ...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []),
baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)),
esc(note),
].join(sep),
diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx
index be4e8f070..a18dc9877 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';
@@ -595,6 +597,112 @@ describe('SharedTripPage', () => {
});
});
+ describe('FE-PAGE-SHARED-043: a foreign expense reads at the rate it was booked at, as in Costs (#2525)', () => {
+ it('shows the frozen-rate value in whole cents, not today\'s rate', async () => {
+ // CHF so no earlier test has cached rates for this base. Today 1 CHF buys 1.1395
+ // USD; the expense froze 1.17 when it was entered. Costs reads 801.76 / 1.17 =
+ // 685.26 CHF. Today's rate alone gives 703.61, printed as "703.607".
+ server.use(
+ http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'USD', rate: 1.1395 }])),
+ http.get('/api/shared/:token', ({ params }) => {
+ if (params.token !== 'booked-token') return;
+ return HttpResponse.json({
+ trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' },
+ baseCurrency: 'CHF',
+ days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
+ budget: [
+ { id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 },
+ { id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 },
+ ],
+ categories: [],
+ permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
+ collab: [],
+ });
+ }),
+ );
+
+ renderSharedTrip('booked-token');
+ await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: /costs/i }));
+
+ await waitFor(() => expect(screen.getByText('Aparthotel Silver')).toBeInTheDocument());
+ // Row and its category both read the booked value; the trip total adds the
+ // 100 CHF tram pass to it.
+ await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(2));
+ expect(screen.getByText('785.26 CHF')).toBeInTheDocument();
+ expect(screen.queryByText(/703\.6/)).toBeNull();
+ // What was entered stays beside the converted row, as the Costs list shows it.
+ expect(screen.getByText('· 801.76 USD')).toBeInTheDocument();
+ });
+
+ it('reads a same-day bill in the display currency exactly as typed, with the trip currency\'s quote', async () => {
+ // ZAR trip read in USD (ZAR so no earlier test has cached its rates). The bill froze
+ // 0.05911 USD per rand, today's ZAR quote. The USD quote, 16.918 rand per dollar, is
+ // rounded on its own and is not its inverse.
+ server.use(
+ http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => {
+ const base = new URL(request.url).searchParams.get('base');
+ return HttpResponse.json(base === 'ZAR' ? [{ quote: 'USD', rate: 0.05911 }] : [{ quote: 'ZAR', rate: 16.918 }]);
+ }),
+ http.get('/api/shared/:token', ({ params }) => {
+ if (params.token !== 'same-day-token') return;
+ return HttpResponse.json({
+ trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'ZAR' },
+ baseCurrency: 'USD',
+ days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
+ budget: [
+ { id: 1, name: 'Villa', total_price: 12345.67, category: 'Accommodation', currency: 'USD', exchange_rate: 0.05911 },
+ ],
+ categories: [],
+ permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
+ collab: [],
+ });
+ }),
+ );
+
+ renderSharedTrip('same-day-token');
+ await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: /costs/i }));
+
+ await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(3));
+ expect(screen.queryByText(/^· /)).toBeNull();
+ });
+
+ it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => {
+ // The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF,
+ // which today is 780.86 USD, the figure Costs and its balances use; the CHF one
+ // converts at today's rate (100 CHF = 113.95 USD).
+ server.use(
+ http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])),
+ http.get('/api/shared/:token', ({ params }) => {
+ if (params.token !== 'display-token') return;
+ return HttpResponse.json({
+ trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' },
+ baseCurrency: 'USD',
+ days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
+ budget: [
+ { id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 },
+ { id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 },
+ ],
+ categories: [],
+ permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
+ collab: [],
+ });
+ }),
+ );
+
+ renderSharedTrip('display-token');
+ await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: /costs/i }));
+
+ await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(2));
+ expect(screen.getAllByText('780.86 USD')).toHaveLength(2);
+ expect(screen.getByText('894.81 USD')).toBeInTheDocument();
+ expect(screen.getByText('· 801.76 USD')).toBeInTheDocument();
+ expect(screen.getByText('· 100.00 CHF')).toBeInTheDocument();
+ });
+ });
+
// FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of
// the page: header variants, the permission-driven tab strip, and every item kind
// the day timeline, bookings, packing, costs and chat sections can produce.
@@ -1544,4 +1652,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..2080dcab2 100644
--- a/client/src/pages/SharedTripPage.tsx
+++ b/client/src/pages/SharedTripPage.tsx
@@ -27,18 +27,20 @@ import { getCategoryIcon } from '../components/shared/categoryIcons';
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults';
import VectorBasemap from '../components/Map/VectorBasemap';
+import { convertBooked, convertedLine } from '../hooks/useExchangeRates';
import { useTranslation } from '../i18n';
import { avatarSrc } from '../utils/avatarSrc';
import { safeHexColor } from '../utils/safeColor';
import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
import { isDayInAccommodationRange } from '../utils/dayOrder';
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
-import { splitReservationDateTime } from '../utils/formatters';
+import { currencyDecimals, splitReservationDateTime } from '../utils/formatters';
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
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 +100,8 @@ export default function SharedTripPage() {
const {
data,
error,
+ retry,
+ retrying,
base,
convert,
selectedDay,
@@ -108,23 +112,7 @@ export default function SharedTripPage() {
setShowLangPicker,
} = useSharedTrip();
- if (error)
- return (
-
-
-
🔒
-
- {t('shared.expired')}
-
-
- {t('shared.expiredHint')}
-
-
-
- );
+ if (error) return ;
if (!data)
return (
@@ -994,16 +982,31 @@ export default function SharedTripPage() {
{activeTab === 'budget' &&
(budget || []).length > 0 &&
(() => {
- // Pre-rework rows store currency = NULL ("the trip's own currency"); convert
- // each expense into the owner's display base via live FX, mirroring CostsPanel.
- const curOf = (i: any) => i.currency || trip.currency || base;
+ // Pre-rework rows store currency = NULL ("the trip's own currency"). Each
+ // expense is read the way CostsPanel reads it (#2525): at the rate frozen when
+ // it was entered, into the trip currency, then into the owner's display base.
+ // Reading it at today's rate alone made the shared page disagree with the
+ // trip's own Costs tab.
+ type Expense = { total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null };
+ const tripCurrency = String(trip.currency || base).toUpperCase();
+ const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0;
+ const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert);
+ // Whole cents: a converted amount otherwise printed a third decimal.
+ const money = (v: number) =>
+ v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ // What was entered, beside a row shown converted, as the Costs list prints it.
+ const enteredOf = (i: Expense): string | null => {
+ const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered;
+ if (!entered) return null;
+ const d = currencyDecimals(entered.currency);
+ return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}`;
+ };
const grouped = (budget || []).reduce((g: any, i: any) => {
const c = i.category || t('shared.other');
(g[c] = g[c] || []).push(i);
return g;
}, {});
- const sumIn = (items: any[]) =>
- items.reduce((s: number, i: any) => s + convert(Number.parseFloat(i.total_price) || 0, curOf(i)), 0);
+ const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + valueOf(i), 0);
const total = sumIn(budget || []);
return (
@@ -1028,7 +1031,7 @@ export default function SharedTripPage() {
{t('shared.totalBudget')}
- {total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
+ {money(total)} {base}
{/* By category */}
@@ -1058,7 +1061,7 @@ export default function SharedTripPage() {
className="text-[#6b7280]"
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}
>
- {sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
+ {money(sumIn(items))} {base}