From 3f68c8a50e550e35c2b6fc8de560f70559a043f3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 09:16:53 +0100 Subject: [PATCH] feat(bookings): download button on booking and transport attachments (#2585) Each attached file row now has a download icon next to its name, on the desktop booking cards and detail dialog and on the phone's bookings and transports cards. On the phone the file rows move out of the card body button so open and download are two real sibling buttons. --- .../Planner/bookings/bookingParts.test.tsx | 50 +++++++++++++++++-- .../Planner/bookings/bookingParts.tsx | 48 +++++++++++++----- .../Planner/bookings/openAttachment.test.ts | 39 +++++++++++++-- .../Planner/bookings/openAttachment.ts | 23 +++++++-- .../bookings/useReservationCard.test.ts | 36 ++++++------- .../Planner/bookings/useReservationCard.ts | 26 +++------- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 4 +- .../trip/tabs/MReservationCardParts.test.tsx | 25 +++++++--- .../trip/tabs/MReservationCardParts.tsx | 41 +++++++++------ .../screens/trip/tabs/MTransportsTab.tsx | 4 +- shared/src/i18n/ar/files.ts | 1 + shared/src/i18n/az/files.ts | 1 + shared/src/i18n/br/files.ts | 1 + shared/src/i18n/ca/files.ts | 1 + shared/src/i18n/cs/files.ts | 1 + shared/src/i18n/de/files.ts | 1 + shared/src/i18n/en/files.ts | 1 + shared/src/i18n/es/files.ts | 1 + shared/src/i18n/et/files.ts | 1 + shared/src/i18n/fr/files.ts | 1 + shared/src/i18n/gr/files.ts | 1 + shared/src/i18n/he/files.ts | 1 + shared/src/i18n/hu/files.ts | 1 + shared/src/i18n/id/files.ts | 1 + shared/src/i18n/it/files.ts | 1 + shared/src/i18n/ja/files.ts | 1 + shared/src/i18n/ko/files.ts | 1 + shared/src/i18n/nl/files.ts | 1 + shared/src/i18n/pl/files.ts | 1 + shared/src/i18n/ru/files.ts | 1 + shared/src/i18n/sk/files.ts | 1 + shared/src/i18n/sv/files.ts | 1 + shared/src/i18n/th/files.ts | 1 + shared/src/i18n/tr/files.ts | 1 + shared/src/i18n/uk/files.ts | 1 + shared/src/i18n/vi/files.ts | 1 + shared/src/i18n/zh-TW/files.ts | 1 + shared/src/i18n/zh/files.ts | 1 + 38 files changed, 236 insertions(+), 88 deletions(-) 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} 个文件',