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.
This commit is contained in:
Maurice
2026-11-13 09:16:53 +01:00
parent 74af74fb5e
commit 3f68c8a50e
38 changed files with 236 additions and 88 deletions
@@ -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(
<div onClick={onCard}>
<FileRows files={[file]} />
</div>
);
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(<FileRows files={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -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 (
<div className="flex flex-col gap-1">
{files.map(f => (
<button
key={f.id}
type="button"
onClick={e => { e.stopPropagation(); open(f) }}
className={`${BOX} flex items-center gap-1.5 px-[10px] py-[7px] text-start hover:bg-surface-hover`}
>
<FileText size={12} strokeWidth={2} className="flex-none text-content-muted" />
<span className="truncate font-geist font-semibold text-content-muted" style={fs(11.5)}>{f.original_name}</span>
</button>
<div key={f.id} className={`${BOX} flex items-stretch overflow-hidden`}>
<button
type="button"
onClick={e => { e.stopPropagation(); open(f) }}
className="flex min-w-0 flex-1 items-center gap-1.5 py-[7px] pe-1 ps-[10px] text-start hover:bg-surface-hover"
>
<FileText size={12} strokeWidth={2} className="flex-none text-content-muted" />
<span className="truncate font-geist font-semibold text-content-muted" style={fs(11.5)}>{f.original_name}</span>
</button>
<Tooltip label={t('files.download')}>
<button
type="button"
onClick={e => { e.stopPropagation(); download(f) }}
aria-label={`${t('files.download')}: ${f.original_name}`}
className="grid flex-none place-items-center px-[9px] text-content-faint hover:bg-surface-hover hover:text-content"
>
<Download size={12} strokeWidth={2} />
</button>
</Tooltip>
</div>
))}
</div>
)
@@ -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);
});
});
@@ -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<void>, onError?: () => void): void {
// Without a handler the rejection is still caught, so it never surfaces as unhandled.
void pending.catch(() => onError?.());
}
@@ -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)', () => {
@@ -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),
};
}
@@ -185,9 +185,9 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
)}
<CardNotes notes={res.notes} />
<CardFiles files={files} card={card} t={t} />
</button>
<CardFiles files={files} card={card} t={t} />
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
@@ -29,7 +29,8 @@ function cardState(overrides: Partial<ReturnType<typeof useReservationCard>> = {
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<typeof useReservationCard>;
}
@@ -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(<CardFiles files={[]} card={cardState()} t={t} />);
expect(container).toBeEmptyDOMElement();
const files = [{ id: 5, original_name: 'ticket.pdf', url: '/uploads/f/5' }] as Parameters<
typeof CardFiles
>[0]['files'];
rerender(<CardFiles files={files} card={cardState()} t={t} />);
const card = cardState();
rerender(<CardFiles files={files} card={card} t={t} />);
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', () => {
@@ -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<typeof filesFor>; card: CardState; t: T }) {
if (files.length === 0) return null;
return (
@@ -172,21 +176,30 @@ export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFo
{t('files.title')}
</div>
<div className="flex flex-col gap-1">
{/* A span with a button role, not a <button>: the whole card body
is already one, and buttons cannot nest. */}
{files.map((f) => (
<span
<div
key={f.id}
role="button"
tabIndex={0}
{...card.fileChip(f)}
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
className="flex items-stretch overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card"
>
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
{f.original_name}
</span>
</span>
<button
type="button"
onClick={() => card.openFile(f)}
className="flex min-w-0 flex-1 items-center gap-[6px] py-[7px] pe-1 ps-[10px] text-start"
>
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
{f.original_name}
</span>
</button>
<button
type="button"
onClick={() => card.downloadFile(f)}
aria-label={`${t('files.download')}: ${f.original_name}`}
className="grid w-9 flex-none place-items-center text-m-muted"
>
<Download size={13} strokeWidth={2} />
</button>
</div>
))}
</div>
</div>
@@ -194,9 +194,9 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
<CardNotes notes={res.notes} />
<CardFiles files={files} card={card} t={t} />
</button>
<CardFiles files={files} card={card} t={t} />
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
+1
View File
@@ -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': 'لا توجد ملفات',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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} αρχείο',
+1
View File
@@ -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': 'קובץ אחד',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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
+1
View File
@@ -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}件アップロードしました',
+1
View File
@@ -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}개 업로드됨',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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} файл',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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} ไฟล์',
+1
View File
@@ -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',
+1
View File
@@ -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} файл',
+1
View File
@@ -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',
+1
View File
@@ -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} 個檔案',
+1
View File
@@ -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} 个文件',