diff --git a/client/src/components/Planner/bookings/bookingParts.test.tsx b/client/src/components/Planner/bookings/bookingParts.test.tsx
index 48972ef90..8037b2bcf 100644
--- a/client/src/components/Planner/bookings/bookingParts.test.tsx
+++ b/client/src/components/Planner/bookings/bookingParts.test.tsx
@@ -1,8 +1,16 @@
-// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-002
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-004
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { buildTripFile } from '../../../../tests/helpers/factories';
+import { downloadFile, openFile } from '../../../utils/fileDownload';
import type { BookingFacts } from './bookingFacts';
-import { WhenFields } from './bookingParts';
+import { FileRows, WhenFields } from './bookingParts';
+
+vi.mock('../../../utils/fileDownload', () => ({
+ openFile: vi.fn(() => Promise.resolve()),
+ downloadFile: vi.fn(() => Promise.resolve()),
+}));
function facts(day: BookingFacts['day'], time: string | null = '15:35 → 09:55'): BookingFacts {
return {
@@ -47,3 +55,37 @@ describe('WhenFields', () => {
expect(screen.getByText('Day 3')).toBeInTheDocument();
});
});
+
+describe('FileRows', () => {
+ const file = buildTripFile({ id: 5, url: '/uploads/f/5', original_name: 'ticket.pdf' });
+
+ it('FE-PLANNER-BKPARTS-003: the name opens the file, the icon downloads it, and neither reaches the card (#2585)', () => {
+ vi.mocked(openFile).mockClear();
+ vi.mocked(downloadFile).mockClear();
+ const onCard = vi.fn();
+ render(
+
+
+
+ );
+
+ const open = screen.getByRole('button', { name: 'ticket.pdf' });
+ const download = screen.getByRole('button', { name: /: ticket\.pdf$/ });
+ // Two sibling buttons, never one inside the other.
+ expect(open.contains(download)).toBe(false);
+ expect(download.contains(open)).toBe(false);
+
+ fireEvent.click(open);
+ expect(openFile).toHaveBeenCalledWith('/uploads/f/5', 'ticket.pdf');
+ expect(downloadFile).not.toHaveBeenCalled();
+ fireEvent.click(download);
+ expect(downloadFile).toHaveBeenCalledWith('/uploads/f/5', 'ticket.pdf');
+ expect(openFile).toHaveBeenCalledOnce();
+ expect(onCard).not.toHaveBeenCalled();
+ });
+
+ it('FE-PLANNER-BKPARTS-004: no files render nothing', () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/client/src/components/Planner/bookings/bookingParts.tsx b/client/src/components/Planner/bookings/bookingParts.tsx
index 54bd1fa6b..58c287a9e 100644
--- a/client/src/components/Planner/bookings/bookingParts.tsx
+++ b/client/src/components/Planner/bookings/bookingParts.tsx
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react'
-import { AlertCircle, ChevronDown, ChevronUp, FileText, Plane } from 'lucide-react'
+import { AlertCircle, ChevronDown, ChevronUp, Download, FileText, Plane } from 'lucide-react'
import type { Reservation, TripFile } from '../../../types'
import type { ReservationTraveler } from '@trek/shared'
import { useTranslation } from '../../../i18n'
@@ -10,7 +10,7 @@ import { PILL } from '../../shared/DialogShell'
import { useToast } from '../../shared/Toast'
import { parseMeta, typeInfo } from './bookingsModel'
import type { BookingFacts } from './bookingFacts'
-import { openAttachment } from './openAttachment'
+import { downloadAttachment, openAttachment } from './openAttachment'
// The phone's card language on the desktop: small Geist eyebrows, framed value
// boxes, count pills. Sizes scale with the user's text size setting.
@@ -289,22 +289,46 @@ export function useOpenFile() {
return (f: TripFile) => openAttachment(f, () => toast.error(t('files.openError')))
}
-/** The attached files, one framed row each, opening the file. */
+/** Saves an attached file, and says so when it cannot. */
+function useDownloadFile() {
+ const { t } = useTranslation()
+ const toast = useToast()
+ return (f: TripFile) => downloadAttachment(f, () => toast.error(t('files.downloadError')))
+}
+
+/**
+ * The attached files, one framed row each: the name opens the file, the icon at
+ * the end saves it. Two sibling buttons rather than one row button, since
+ * buttons cannot nest; both stop the click so the card around them stays put.
+ */
export function FileRows({ files }: { files: TripFile[] }) {
+ const { t } = useTranslation()
const open = useOpenFile()
+ const download = useDownloadFile()
if (files.length === 0) return null
return (
{files.map(f => (
-
+
+
+
+
+
+
))}
)
diff --git a/client/src/components/Planner/bookings/openAttachment.test.ts b/client/src/components/Planner/bookings/openAttachment.test.ts
index 847d3dfbb..dda4b16b5 100644
--- a/client/src/components/Planner/bookings/openAttachment.test.ts
+++ b/client/src/components/Planner/bookings/openAttachment.test.ts
@@ -1,10 +1,13 @@
-// FE-PLANNER-OPENATTACH-001 to -003: opening a booking's attached file.
+// FE-PLANNER-OPENATTACH-001 to -006: opening and downloading a booking's attached file.
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { openFile } from '../../../utils/fileDownload';
-import { openAttachment } from './openAttachment';
+import { downloadFile, openFile } from '../../../utils/fileDownload';
+import { downloadAttachment, openAttachment } from './openAttachment';
-vi.mock('../../../utils/fileDownload', () => ({ openFile: vi.fn(() => Promise.resolve()) }));
+vi.mock('../../../utils/fileDownload', () => ({
+ openFile: vi.fn(() => Promise.resolve()),
+ downloadFile: vi.fn(() => Promise.resolve()),
+}));
const file = { url: '/uploads/f/1', original_name: 'voucher.pdf' };
@@ -12,6 +15,7 @@ describe('openAttachment', () => {
// A block body: a hook that returns the mock would have it run again as teardown.
beforeEach(() => {
vi.mocked(openFile).mockReset();
+ vi.mocked(downloadFile).mockReset();
});
it('FE-PLANNER-OPENATTACH-001: opens the file by its url and name', async () => {
@@ -36,4 +40,31 @@ describe('openAttachment', () => {
expect(openFile).toHaveBeenCalledTimes(1);
expect(openFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
});
+
+ it('FE-PLANNER-OPENATTACH-004: downloads the file under its original name, without opening it (#2585)', async () => {
+ vi.mocked(downloadFile).mockResolvedValue(undefined);
+ const onError = vi.fn();
+ downloadAttachment(file, onError);
+ expect(downloadFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
+ expect(openFile).not.toHaveBeenCalled();
+ await Promise.resolve();
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it('FE-PLANNER-OPENATTACH-005: a failed download reaches onError when one is given', async () => {
+ vi.mocked(downloadFile).mockRejectedValue(new Error('offline'));
+ const onError = vi.fn();
+ downloadAttachment(file, onError);
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+ });
+
+ it('FE-PLANNER-OPENATTACH-006: without onError a failure is swallowed, not left unhandled', async () => {
+ vi.mocked(openFile).mockRejectedValue(new Error('offline'));
+ vi.mocked(downloadFile).mockRejectedValue(new Error('offline'));
+ openAttachment(file);
+ downloadAttachment(file);
+ // A rejection nobody caught would fail the run; settling the queue proves both were caught.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(downloadFile).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/client/src/components/Planner/bookings/openAttachment.ts b/client/src/components/Planner/bookings/openAttachment.ts
index 708917ec2..d876180dd 100644
--- a/client/src/components/Planner/bookings/openAttachment.ts
+++ b/client/src/components/Planner/bookings/openAttachment.ts
@@ -1,11 +1,26 @@
-import { openFile } from '../../../utils/fileDownload';
+import { downloadFile, openFile } from '../../../utils/fileDownload';
+
+export type Attachment = { url: string; original_name: string };
/**
* Opens a file attached to a booking. The desktop panel passes `onError` to say
* so with a toast when the file cannot be opened; the phone's bookings and
* transports cards pass nothing and stay silent, as they always have.
*/
-export function openAttachment(f: { url: string; original_name: string }, onError?: () => void): void {
- const opening = openFile(f.url, f.original_name);
- void (onError ? opening.catch(onError) : opening);
+export function openAttachment(f: Attachment, onError?: () => void): void {
+ settle(openFile(f.url, f.original_name), onError);
+}
+
+/**
+ * Saves a file attached to a booking under its original name, where opening it
+ * would only show it in a viewer (a PDF ticket on a phone, say). Failures are
+ * reported the same way as for opening.
+ */
+export function downloadAttachment(f: Attachment, onError?: () => void): void {
+ settle(downloadFile(f.url, f.original_name), onError);
+}
+
+function settle(pending: Promise, onError?: () => void): void {
+ // Without a handler the rejection is still caught, so it never surfaces as unhandled.
+ void pending.catch(() => onError?.());
}
diff --git a/client/src/components/Planner/bookings/useReservationCard.test.ts b/client/src/components/Planner/bookings/useReservationCard.test.ts
index 6f3029bc1..004e14b2e 100644
--- a/client/src/components/Planner/bookings/useReservationCard.test.ts
+++ b/client/src/components/Planner/bookings/useReservationCard.test.ts
@@ -1,17 +1,19 @@
// FE-PLANNER-RESLISTFILTER-005 to -009: what one card of the phone's bookings
// and transports tabs does.
import { act, renderHook } from '@testing-library/react';
-import type { KeyboardEvent, MouseEvent } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildBudgetItem, buildReservation } from '../../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useTripStore } from '../../../store/tripStore';
import type { Reservation } from '../../../types';
-import { openFile } from '../../../utils/fileDownload';
+import { downloadFile, openFile } from '../../../utils/fileDownload';
import { useReservationCard } from './useReservationCard';
-vi.mock('../../../utils/fileDownload', () => ({ openFile: vi.fn(() => Promise.resolve()) }));
+vi.mock('../../../utils/fileDownload', () => ({
+ openFile: vi.fn(() => Promise.resolve()),
+ downloadFile: vi.fn(() => Promise.resolve()),
+}));
const traveler = (user_id: number) => ({ user_id, username: `u${user_id}` });
const ann = buildReservation({
@@ -36,7 +38,10 @@ describe('useReservationCard', () => {
t: (key: string) => key,
});
- beforeEach(() => vi.mocked(openFile).mockClear());
+ beforeEach(() => {
+ vi.mocked(openFile).mockClear();
+ vi.mocked(downloadFile).mockClear();
+ });
it('FE-PLANNER-RESLISTFILTER-005: with the blur preference on the code is blurred until toggled', () => {
const { result } = renderHook(() => useReservationCard(ann, host(true)));
@@ -70,25 +75,14 @@ describe('useReservationCard', () => {
await vi.waitFor(() => expect(opts.toast.error).toHaveBeenCalledWith('reservations.toast.deleteError'));
});
- it('FE-PLANNER-RESLISTFILTER-008: a file chip opens its file on click, Enter or Space and stops there', () => {
+ it('FE-PLANNER-RESLISTFILTER-008: a file opens or downloads under its own name (#2585)', () => {
const { result } = renderHook(() => useReservationCard(ann, host()));
- const chip = result.current.fileChip(file);
- const click = { stopPropagation: vi.fn() } as unknown as MouseEvent;
- chip.onClick(click);
- expect(click.stopPropagation).toHaveBeenCalled();
- const key = (k: string) =>
- ({ key: k, preventDefault: vi.fn(), stopPropagation: vi.fn() }) as unknown as KeyboardEvent;
- const enter = key('Enter');
- chip.onKeyDown(enter);
- chip.onKeyDown(key(' '));
- const tab = key('Tab');
- chip.onKeyDown(tab);
- expect(enter.preventDefault).toHaveBeenCalled();
- expect(enter.stopPropagation).toHaveBeenCalled();
- expect(tab.preventDefault).not.toHaveBeenCalled();
- expect(tab.stopPropagation).not.toHaveBeenCalled();
- expect(openFile).toHaveBeenCalledTimes(3);
+ result.current.openFile(file);
expect(openFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
+ expect(downloadFile).not.toHaveBeenCalled();
+ result.current.downloadFile(file);
+ expect(downloadFile).toHaveBeenCalledWith('/uploads/f/1', 'voucher.pdf');
+ expect(openFile).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-RESLISTFILTER-009: the card knows whether deleting it takes a linked expense along (#2604)', () => {
diff --git a/client/src/components/Planner/bookings/useReservationCard.ts b/client/src/components/Planner/bookings/useReservationCard.ts
index f432a2133..c617bdde3 100644
--- a/client/src/components/Planner/bookings/useReservationCard.ts
+++ b/client/src/components/Planner/bookings/useReservationCard.ts
@@ -1,9 +1,9 @@
-import { useState, type KeyboardEvent, type MouseEvent } from 'react';
+import { useState } from 'react';
import { useTripStore } from '../../../store/tripStore';
import type { Reservation } from '../../../types';
import { deleteBodyKey } from './bookingsModel';
-import { openAttachment } from './openAttachment';
+import { type Attachment, downloadAttachment, openAttachment } from './openAttachment';
import { useReservationDelete } from './useReservationDelete';
/** What a phone card needs from the planner it sits in. */
@@ -19,10 +19,9 @@ export interface ReservationCardHost {
* opening: its booking code blurred until tapped while the "Blur booking codes"
* preference is on (off, the code is plain and has no toggle), the delete
* behind a question with a toast when it fails, and its attached files, which
- * open from a chip inside the card's own button (so a click or Enter/Space on
- * the chip stops there). A file that cannot be opened stays silent here; the
- * desktop panel toasts it. The delete question names a linked expense, which
- * goes with the booking.
+ * open or download from their own row. A file that cannot be opened or saved
+ * stays silent here; the desktop panel toasts it. The delete question names a
+ * linked expense, which goes with the booking.
*/
export function useReservationCard(res: Reservation, host: ReservationCardHost) {
const blurCodes = host.settings.blur_booking_codes;
@@ -45,18 +44,7 @@ export function useReservationCard(res: Reservation, host: ReservationCardHost)
confirmDelete: () => {
void confirmDelete();
},
- fileChip: (f: { url: string; original_name: string }) => ({
- onClick: (e: MouseEvent) => {
- e.stopPropagation();
- openAttachment(f);
- },
- onKeyDown: (e: KeyboardEvent) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- e.stopPropagation();
- openAttachment(f);
- }
- },
- }),
+ openFile: (f: Attachment) => openAttachment(f),
+ downloadFile: (f: Attachment) => downloadAttachment(f),
};
}
diff --git a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx
index 87229b78b..c5e74c627 100644
--- a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx
@@ -185,9 +185,9 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
)}
-
-
+
+
)}
{!compact && }
diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx
index 3067d1fb0..221e1b336 100644
--- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx
+++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx
@@ -29,7 +29,8 @@ function cardState(overrides: Partial> = {
askDelete: vi.fn(),
cancelDelete: vi.fn(),
confirmDelete: vi.fn(),
- fileChip: () => ({ onClick: vi.fn(), onKeyDown: vi.fn() }),
+ openFile: vi.fn(),
+ downloadFile: vi.fn(),
...overrides,
} as unknown as ReturnType;
}
@@ -130,18 +131,30 @@ describe('MReservationCardParts', () => {
expect(toggleCode).toHaveBeenCalledOnce();
});
- it('FE-COMP-MRESCARD-006: files render as button-role chips, and no files render nothing', () => {
+ it('FE-COMP-MRESCARD-006: each file row opens the file by its name and downloads it by the icon (#2585)', () => {
const { container, rerender } = render();
expect(container).toBeEmptyDOMElement();
const files = [{ id: 5, original_name: 'ticket.pdf', url: '/uploads/f/5' }] as Parameters<
typeof CardFiles
>[0]['files'];
- rerender();
+ const card = cardState();
+ rerender();
expect(screen.getByText('files.title')).toBeInTheDocument();
- const chip = screen.getByRole('button', { name: 'ticket.pdf' });
- expect(chip.tagName).toBe('SPAN');
- expect(chip).toHaveAttribute('tabindex', '0');
+
+ const open = screen.getByRole('button', { name: 'ticket.pdf' });
+ const download = screen.getByRole('button', { name: 'files.download: ticket.pdf' });
+ // Two sibling buttons, never one inside the other.
+ expect(open.tagName).toBe('BUTTON');
+ expect(open.contains(download)).toBe(false);
+ expect(download.closest('button')).toBe(download);
+
+ fireEvent.click(open);
+ expect(card.openFile).toHaveBeenCalledWith(files[0]);
+ expect(card.downloadFile).not.toHaveBeenCalled();
+ fireEvent.click(download);
+ expect(card.downloadFile).toHaveBeenCalledWith(files[0]);
+ expect(card.openFile).toHaveBeenCalledOnce();
});
it('FE-COMP-MRESCARD-007: the delete sheet names the reservation and confirms through the card', () => {
diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
index 0696c9d3f..906e34c02 100644
--- a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
+++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
@@ -1,4 +1,4 @@
-import { FileText, type LucideIcon, Pencil, Trash2 } from 'lucide-react';
+import { Download, FileText, type LucideIcon, Pencil, Trash2 } from 'lucide-react';
import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
import type { useTranslation } from '../../../../i18n';
@@ -163,7 +163,11 @@ export function CardNotes({ notes }: { notes: string | null | undefined }) {
);
}
-/** Attached files, listed inside the card body button. */
+/**
+ * Attached files, under the card body button rather than inside it, so each row
+ * can hold two real buttons: the name opens the file, the icon at the end saves
+ * it (a PDF ticket that only opens in a viewer cannot be kept otherwise).
+ */
export function CardFiles({ files, card, t }: { files: ReturnType; card: CardState; t: T }) {
if (files.length === 0) return null;
return (
@@ -172,21 +176,30 @@ export function CardFiles({ files, card, t }: { files: ReturnType
- {/* A span with a button role, not a
))}
diff --git a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx
index 0f5ee6cbd..ccbd63e00 100644
--- a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx
@@ -194,9 +194,9 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
-
-
+
+
)}
{!compact && }
diff --git a/shared/src/i18n/ar/files.ts b/shared/src/i18n/ar/files.ts
index 840f92a2a..68b49bfec 100644
--- a/shared/src/i18n/ar/files.ts
+++ b/shared/src/i18n/ar/files.ts
@@ -17,6 +17,7 @@ const files: TranslationStrings = {
'files.subtitle.many': '{count} ملفًا لـ {trip}',
'files.download': 'تنزيل',
'files.openError': 'تعذر فتح الملف',
+ 'files.downloadError': 'تعذر تنزيل الملف',
'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملف',
'files.count.zero': 'لا توجد ملفات',
diff --git a/shared/src/i18n/az/files.ts b/shared/src/i18n/az/files.ts
index 6098027b3..a61a6edac 100644
--- a/shared/src/i18n/az/files.ts
+++ b/shared/src/i18n/az/files.ts
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{trip} üçün {count} fayl',
'files.download': 'Endir',
'files.openError': 'Faylı açmaq mümkün olmadı',
+ 'files.downloadError': 'Faylı yükləmək mümkün olmadı',
'files.downloadPdf': 'PDF-i endir',
'files.count': '{count} fayl',
'files.count.one': '{count} fayl',
diff --git a/shared/src/i18n/br/files.ts b/shared/src/i18n/br/files.ts
index 851ce013b..169eb27f9 100644
--- a/shared/src/i18n/br/files.ts
+++ b/shared/src/i18n/br/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} arquivo para {trip}',
'files.download': 'Baixar',
'files.openError': 'Não foi possível abrir o arquivo',
+ 'files.downloadError': 'Não foi possível baixar o arquivo',
'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos',
'files.count.one': '{count} arquivo',
diff --git a/shared/src/i18n/ca/files.ts b/shared/src/i18n/ca/files.ts
index 897829d90..26ad3a1a4 100644
--- a/shared/src/i18n/ca/files.ts
+++ b/shared/src/i18n/ca/files.ts
@@ -7,6 +7,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} fitxer per a {trip}',
'files.download': 'Baixa',
'files.openError': "No s'ha pogut obrir el fitxer",
+ 'files.downloadError': "No s'ha pogut baixar el fitxer",
'files.downloadPdf': 'Baixa el PDF',
'files.count': '{count} fitxers',
'files.count.one': '{count} fitxer',
diff --git a/shared/src/i18n/cs/files.ts b/shared/src/i18n/cs/files.ts
index a4023a305..6db784d87 100644
--- a/shared/src/i18n/cs/files.ts
+++ b/shared/src/i18n/cs/files.ts
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
'files.subtitle.few': '{count} soubory pro {trip}',
'files.download': 'Stáhnout',
'files.openError': 'Soubor nelze otevřít',
+ 'files.downloadError': 'Soubor nelze stáhnout',
'files.downloadPdf': 'Stáhnout PDF',
'files.count': '{count} souborů',
'files.count.one': '{count} soubor',
diff --git a/shared/src/i18n/de/files.ts b/shared/src/i18n/de/files.ts
index c350f31d6..74992507c 100644
--- a/shared/src/i18n/de/files.ts
+++ b/shared/src/i18n/de/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} Datei für {trip}',
'files.download': 'Herunterladen',
'files.openError': 'Datei konnte nicht geöffnet werden',
+ 'files.downloadError': 'Datei konnte nicht heruntergeladen werden',
'files.downloadPdf': 'PDF herunterladen',
'files.count': '{count} Dateien',
'files.count.one': '{count} Datei',
diff --git a/shared/src/i18n/en/files.ts b/shared/src/i18n/en/files.ts
index 4f15e2d4a..baba37b45 100644
--- a/shared/src/i18n/en/files.ts
+++ b/shared/src/i18n/en/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} file for {trip}',
'files.download': 'Download',
'files.openError': 'Could not open file',
+ 'files.downloadError': 'Could not download file',
'files.downloadPdf': 'Download PDF',
'files.count': '{count} files',
'files.count.one': '{count} file',
diff --git a/shared/src/i18n/es/files.ts b/shared/src/i18n/es/files.ts
index f8373ae77..d43d5114b 100644
--- a/shared/src/i18n/es/files.ts
+++ b/shared/src/i18n/es/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} archivo para {trip}',
'files.download': 'Descargar',
'files.openError': 'No se pudo abrir el archivo',
+ 'files.downloadError': 'No se pudo descargar el archivo',
'files.downloadPdf': 'Descargar PDF',
'files.count': '{count} archivos',
'files.count.one': '{count} archivo',
diff --git a/shared/src/i18n/et/files.ts b/shared/src/i18n/et/files.ts
index a68501112..8d7662583 100644
--- a/shared/src/i18n/et/files.ts
+++ b/shared/src/i18n/et/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} fail reisile {trip}',
'files.download': 'Laadi alla',
'files.openError': 'Faili avamine ebaõnnestus',
+ 'files.downloadError': 'Faili allalaadimine ebaõnnestus',
'files.downloadPdf': 'Laadi PDF alla',
'files.count': '{count} faili',
'files.count.one': '{count} fail',
diff --git a/shared/src/i18n/fr/files.ts b/shared/src/i18n/fr/files.ts
index a3d92020a..7947529cf 100644
--- a/shared/src/i18n/fr/files.ts
+++ b/shared/src/i18n/fr/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} fichier pour {trip}',
'files.download': 'Télécharger',
'files.openError': "Impossible d'ouvrir le fichier",
+ 'files.downloadError': 'Impossible de télécharger le fichier',
'files.downloadPdf': 'Télécharger le PDF',
'files.count': '{count} fichiers',
'files.count.one': '{count} fichier',
diff --git a/shared/src/i18n/gr/files.ts b/shared/src/i18n/gr/files.ts
index efda3d335..7f76b6b5e 100644
--- a/shared/src/i18n/gr/files.ts
+++ b/shared/src/i18n/gr/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} αρχείο για {trip}',
'files.download': 'Λήψη',
'files.openError': 'Δεν ήταν δυνατό το άνοιγμα του αρχείου',
+ 'files.downloadError': 'Δεν ήταν δυνατή η λήψη του αρχείου',
'files.downloadPdf': 'Λήψη PDF',
'files.count': '{count} αρχεία',
'files.count.one': '{count} αρχείο',
diff --git a/shared/src/i18n/he/files.ts b/shared/src/i18n/he/files.ts
index 4270876eb..39322e054 100644
--- a/shared/src/i18n/he/files.ts
+++ b/shared/src/i18n/he/files.ts
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
'files.subtitle.two': 'שני קבצים עבור {trip}',
'files.download': 'הורדה',
'files.openError': 'לא ניתן לפתוח את הקובץ',
+ 'files.downloadError': 'לא ניתן להוריד את הקובץ',
'files.downloadPdf': 'הורדת PDF',
'files.count': '{count} קבצים',
'files.count.one': 'קובץ אחד',
diff --git a/shared/src/i18n/hu/files.ts b/shared/src/i18n/hu/files.ts
index 05ffa5910..abd1a1d52 100644
--- a/shared/src/i18n/hu/files.ts
+++ b/shared/src/i18n/hu/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} fájl a következőhöz: {trip}',
'files.download': 'Letöltés',
'files.openError': 'A fájl megnyitása sikertelen',
+ 'files.downloadError': 'A fájl letöltése sikertelen',
'files.downloadPdf': 'PDF letöltése',
'files.count': '{count} fájl',
'files.count.one': '{count} fájl',
diff --git a/shared/src/i18n/id/files.ts b/shared/src/i18n/id/files.ts
index 3ccc88022..2ce2b8b8e 100644
--- a/shared/src/i18n/id/files.ts
+++ b/shared/src/i18n/id/files.ts
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
'files.subtitle': '{count} file untuk {trip}',
'files.download': 'Unduh',
'files.openError': 'Tidak dapat membuka file',
+ 'files.downloadError': 'Tidak dapat mengunduh file',
'files.downloadPdf': 'Unduh PDF',
'files.count': '{count} file',
'files.uploaded': '{count} diunggah',
diff --git a/shared/src/i18n/it/files.ts b/shared/src/i18n/it/files.ts
index 71db217ab..05883dcc3 100644
--- a/shared/src/i18n/it/files.ts
+++ b/shared/src/i18n/it/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} file per {trip}',
'files.download': 'Scarica',
'files.openError': 'Impossibile aprire il file',
+ 'files.downloadError': 'Impossibile scaricare il file',
'files.downloadPdf': 'Scarica PDF',
'files.count': '{count} file',
'files.count.one': '{count} file', // same-as-en
diff --git a/shared/src/i18n/ja/files.ts b/shared/src/i18n/ja/files.ts
index edbff0f52..9b60dcd2b 100644
--- a/shared/src/i18n/ja/files.ts
+++ b/shared/src/i18n/ja/files.ts
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
'files.subtitle': '{trip} のファイル {count} 件',
'files.download': 'ダウンロード',
'files.openError': 'ファイルを開けませんでした',
+ 'files.downloadError': 'ファイルをダウンロードできませんでした',
'files.downloadPdf': 'PDFをダウンロード',
'files.count': '{count}件のファイル',
'files.uploaded': '{count}件アップロードしました',
diff --git a/shared/src/i18n/ko/files.ts b/shared/src/i18n/ko/files.ts
index 4cad27299..ec6c2b695 100644
--- a/shared/src/i18n/ko/files.ts
+++ b/shared/src/i18n/ko/files.ts
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
'files.subtitle': '{trip}의 파일 {count}개',
'files.download': '다운로드',
'files.openError': '파일을 열 수 없습니다',
+ 'files.downloadError': '파일을 다운로드할 수 없습니다',
'files.downloadPdf': 'PDF 다운로드',
'files.count': '파일 {count}개',
'files.uploaded': '{count}개 업로드됨',
diff --git a/shared/src/i18n/nl/files.ts b/shared/src/i18n/nl/files.ts
index a66049f24..0f267f440 100644
--- a/shared/src/i18n/nl/files.ts
+++ b/shared/src/i18n/nl/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} bestand voor {trip}',
'files.download': 'Downloaden',
'files.openError': 'Bestand kon niet worden geopend',
+ 'files.downloadError': 'Bestand kon niet worden gedownload',
'files.downloadPdf': 'PDF downloaden',
'files.count': '{count} bestanden',
'files.count.one': '{count} bestand',
diff --git a/shared/src/i18n/pl/files.ts b/shared/src/i18n/pl/files.ts
index 0aac29f72..eb7250090 100644
--- a/shared/src/i18n/pl/files.ts
+++ b/shared/src/i18n/pl/files.ts
@@ -15,6 +15,7 @@ const files: TranslationStrings = {
'files.subtitle.many': '{count} plików z podróży {trip}',
'files.download': 'Pobierz',
'files.openError': 'Nie można otworzyć pliku',
+ 'files.downloadError': 'Nie można pobrać pliku',
'files.downloadPdf': 'Pobierz PDF',
'files.count': '{count} pliku',
'files.count.one': '{count} plik',
diff --git a/shared/src/i18n/ru/files.ts b/shared/src/i18n/ru/files.ts
index df4d1bfcb..bd563fd47 100644
--- a/shared/src/i18n/ru/files.ts
+++ b/shared/src/i18n/ru/files.ts
@@ -15,6 +15,7 @@ const files: TranslationStrings = {
'files.subtitle.many': '{count} файлов для {trip}',
'files.download': 'Скачать',
'files.openError': 'Не удалось открыть файл',
+ 'files.downloadError': 'Не удалось скачать файл',
'files.downloadPdf': 'Скачать PDF',
'files.count': '{count} файла',
'files.count.one': '{count} файл',
diff --git a/shared/src/i18n/sk/files.ts b/shared/src/i18n/sk/files.ts
index df4f03c03..14763c418 100644
--- a/shared/src/i18n/sk/files.ts
+++ b/shared/src/i18n/sk/files.ts
@@ -14,6 +14,7 @@ const files: TranslationStrings = {
'files.subtitle.few': '{count} súbory pre {trip}',
'files.download': 'Stiahnuť',
'files.openError': 'Súbor sa nedá otvoriť',
+ 'files.downloadError': 'Súbor sa nedá stiahnuť',
'files.downloadPdf': 'Stiahnuť PDF',
'files.count': '{count} súborov',
'files.count.one': '{count} súbor',
diff --git a/shared/src/i18n/sv/files.ts b/shared/src/i18n/sv/files.ts
index bed794a24..eae80b2da 100644
--- a/shared/src/i18n/sv/files.ts
+++ b/shared/src/i18n/sv/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{count} fil för {trip}',
'files.download': 'Ladda ner',
'files.openError': 'Kunde inte öppna fil',
+ 'files.downloadError': 'Kunde inte ladda ner filen',
'files.downloadPdf': 'Ladda ner PDF',
'files.count': '{count} filer',
'files.count.one': '{count} fil',
diff --git a/shared/src/i18n/th/files.ts b/shared/src/i18n/th/files.ts
index 99431145d..75441b622 100644
--- a/shared/src/i18n/th/files.ts
+++ b/shared/src/i18n/th/files.ts
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
'files.subtitle': '{count} ไฟล์สำหรับ {trip}',
'files.download': 'ดาวน์โหลด',
'files.openError': 'ไม่สามารถเปิดไฟล์ได้',
+ 'files.downloadError': 'ไม่สามารถดาวน์โหลดไฟล์ได้',
'files.downloadPdf': 'ดาวน์โหลดไฟล์ PDF',
'files.count': '{count} ไฟล์',
'files.uploaded': 'อัปโหลดแล้ว {count} ไฟล์',
diff --git a/shared/src/i18n/tr/files.ts b/shared/src/i18n/tr/files.ts
index b900aeb09..5c9fb4538 100644
--- a/shared/src/i18n/tr/files.ts
+++ b/shared/src/i18n/tr/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle.one': '{trip} için {count} dosya',
'files.download': 'İndir',
'files.openError': 'Dosya açılamadı',
+ 'files.downloadError': 'Dosya indirilemedi',
'files.downloadPdf': 'PDF İndir',
'files.count': '{count} dosya',
'files.count.one': '{count} dosya',
diff --git a/shared/src/i18n/uk/files.ts b/shared/src/i18n/uk/files.ts
index 2dceb8da3..18450d3fb 100644
--- a/shared/src/i18n/uk/files.ts
+++ b/shared/src/i18n/uk/files.ts
@@ -15,6 +15,7 @@ const files: TranslationStrings = {
'files.subtitle.many': '{count} файлів для {trip}',
'files.download': 'Завантажити',
'files.openError': 'Не вдалося відкрити файл',
+ 'files.downloadError': 'Не вдалося завантажити файл',
'files.downloadPdf': 'Завантажити PDF',
'files.count': '{count} файлу',
'files.count.one': '{count} файл',
diff --git a/shared/src/i18n/vi/files.ts b/shared/src/i18n/vi/files.ts
index 079b853c3..04d5e0bc4 100644
--- a/shared/src/i18n/vi/files.ts
+++ b/shared/src/i18n/vi/files.ts
@@ -13,6 +13,7 @@ const files: TranslationStrings = {
'files.subtitle': '{count} tệp cho {trip}',
'files.download': 'Tải xuống',
'files.openError': 'Không thể mở tập tin',
+ 'files.downloadError': 'Không thể tải xuống tập tin',
'files.downloadPdf': 'Tải xuống PDF',
'files.count': '{count} tập tin',
'files.uploaded': 'Đã tải lên {count} tệp',
diff --git a/shared/src/i18n/zh-TW/files.ts b/shared/src/i18n/zh-TW/files.ts
index cf0b369a0..efeb13edb 100644
--- a/shared/src/i18n/zh-TW/files.ts
+++ b/shared/src/i18n/zh-TW/files.ts
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
'files.subtitle': '{trip} 的 {count} 個檔案',
'files.download': '下載',
'files.openError': '無法開啟檔案',
+ 'files.downloadError': '無法下載檔案',
'files.downloadPdf': '下載 PDF',
'files.count': '{count} 個檔案',
'files.uploaded': '已上傳 {count} 個檔案',
diff --git a/shared/src/i18n/zh/files.ts b/shared/src/i18n/zh/files.ts
index ce952c597..378197320 100644
--- a/shared/src/i18n/zh/files.ts
+++ b/shared/src/i18n/zh/files.ts
@@ -12,6 +12,7 @@ const files: TranslationStrings = {
'files.subtitle': '{trip} 的 {count} 个文件',
'files.download': '下载',
'files.openError': '无法打开文件',
+ 'files.downloadError': '无法下载文件',
'files.downloadPdf': '下载 PDF',
'files.count': '{count} 个文件',
'files.uploaded': '已上传 {count} 个文件',