mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
Merge branch 'feat/structured-mail-cards' into feat/structured-mail
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// Booking, parcel and order cards in the reader: the single viewer (desktop
|
||||
// and Mountain View's open message) and the conversation body (phone thread,
|
||||
// Mountain View's other expanded messages). A card only for mail the
|
||||
// receiving server tied to its From domain.
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseAuthenticationResults } from '@/lib/email-headers';
|
||||
import type { Email, EmailBodyPart, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ConversationEmailBody, ThreadConversationView } = await import('../thread-conversation-view');
|
||||
const { usePolicyStore } = await import('@/stores/policy-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const ORDER = {
|
||||
'@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-77120', merchant: { '@type': 'Organization', name: 'Outdoor Shop' },
|
||||
orderStatus: 'http://schema.org/OrderProcessing',
|
||||
acceptedOffer: [{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Tent', image: 'https://img.shop.example/tent.jpg' } }],
|
||||
};
|
||||
const HTML = `<html><head><script type="application/ld+json">${JSON.stringify(ORDER)}</script></head><body><p>Thanks for your order.</p></body></html>`;
|
||||
const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example';
|
||||
|
||||
type Variant = 'trusted' | 'no-authentication-results' | 'not-on-receipt' | 'dkim-fail';
|
||||
|
||||
function mail(id: string, variant: Variant = 'trusted', extra: Partial<Email> = {}): Email {
|
||||
const ar = variant === 'dkim-fail' ? PASS.replace('dkim=pass', 'dkim=fail').replace('dmarc=pass', 'dmarc=fail') : PASS;
|
||||
return {
|
||||
id, blobId: `b-${id}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Outdoor Shop', email: 'orders@shop.example' }], to: [{ email: 'me@example.org' }],
|
||||
subject: 'Your order', receivedAt: '2026-10-30T09:00:00Z', size: 100, preview: 'Thanks', hasAttachment: false,
|
||||
...(variant === 'no-authentication-results' ? {} : {
|
||||
authenticationResults: parseAuthenticationResults([ar]),
|
||||
authenticationResultsOnReceipt: variant !== 'not-on-receipt',
|
||||
}),
|
||||
htmlBody: [{ partId: '1', type: 'text/html' } as EmailBodyPart], textBody: [], attachments: [],
|
||||
bodyValues: { '1': { value: HTML, isTruncated: false, isEncodingProblem: false } },
|
||||
...extra,
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
|
||||
const initialPolicy = usePolicyStore.getState();
|
||||
function gate(on: boolean) {
|
||||
usePolicyStore.setState((s) => ({ policy: { ...s.policy, features: { ...s.policy.features, structuredMailEnabled: on } } }));
|
||||
}
|
||||
const initialSettings = useSettingsStore.getState();
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState(initialPolicy, true);
|
||||
useSettingsStore.setState(initialSettings, true);
|
||||
});
|
||||
|
||||
const cardImages = () => cardRegion()?.querySelectorAll('img') ?? [];
|
||||
|
||||
const cardRegion = () => screen.queryByRole('region', { name: 'region_shipment' });
|
||||
|
||||
describe('cards in the single viewer', () => {
|
||||
const viewer = (email: Email) => render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
it('shows the order card of a verified sender in the notification block', () => {
|
||||
gate(true);
|
||||
viewer(mail('v1'));
|
||||
const region = cardRegion();
|
||||
expect(region).not.toBeNull();
|
||||
expect(within(region!).getByRole('heading', { name: 'Outdoor Shop' })).toBeInTheDocument();
|
||||
expect(region).toHaveTextContent('SO-77120');
|
||||
expect(screen.getAllByTestId('structured-mail-cards')).toHaveLength(1);
|
||||
}, 30_000);
|
||||
|
||||
it.each<Variant>(['no-authentication-results', 'not-on-receipt', 'dkim-fail'])('shows no card for a forged or unverified mail (%s)', (variant) => {
|
||||
gate(true);
|
||||
viewer(mail(`v-${variant}`, variant));
|
||||
expect(screen.getByText('Your order')).toBeInTheDocument();
|
||||
expect(cardRegion()).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('loads item images only when the viewer loads remote content', () => {
|
||||
gate(true);
|
||||
expect(useSettingsStore.getState().externalContentPolicy).toBe('ask');
|
||||
const first = viewer(mail('v3'));
|
||||
expect(cardImages()).toHaveLength(0);
|
||||
first.unmount();
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
viewer(mail('v4'));
|
||||
expect(cardImages()).toHaveLength(1);
|
||||
expect(cardImages()[0]).toHaveAttribute('src', 'https://img.shop.example/tent.jpg');
|
||||
}, 30_000);
|
||||
|
||||
it('shows no card with the admin gate off', () => {
|
||||
gate(false);
|
||||
viewer(mail('v2'));
|
||||
expect(cardRegion()).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('cards in the conversation body', () => {
|
||||
const body = (email: Email, allowExternal = false) => render(
|
||||
<ConversationEmailBody email={email} allowExternal={allowExternal} onAllowExternal={() => {}} />,
|
||||
);
|
||||
|
||||
it('loads item images only when the message may load remote content', () => {
|
||||
gate(true);
|
||||
const first = body(mail('c2'));
|
||||
expect(cardImages()).toHaveLength(0);
|
||||
first.unmount();
|
||||
body(mail('c3'), true);
|
||||
expect(cardImages()).toHaveLength(1);
|
||||
}, 30_000);
|
||||
|
||||
it('loads no item images while mail is shown as plain text', () => {
|
||||
gate(true);
|
||||
useSettingsStore.setState({ mailViewMode: 'plain' });
|
||||
body(mail('c4'), true);
|
||||
expect(cardRegion()).not.toBeNull();
|
||||
expect(cardImages()).toHaveLength(0);
|
||||
}, 30_000);
|
||||
|
||||
it('shows the card of a verified sender above the body', () => {
|
||||
gate(true);
|
||||
const { container } = body(mail('c1'));
|
||||
const region = cardRegion();
|
||||
expect(region).not.toBeNull();
|
||||
expect(region).toHaveTextContent('SO-77120');
|
||||
// Before the body's frame.
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(region!.compareDocumentPosition(iframe!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
}, 30_000);
|
||||
|
||||
it.each<Variant>(['no-authentication-results', 'not-on-receipt', 'dkim-fail'])('shows no card for a forged or unverified mail (%s)', (variant) => {
|
||||
gate(true);
|
||||
body(mail(`c-${variant}`, variant));
|
||||
expect(cardRegion()).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('gives each expanded message its own card and none to a collapsed one', () => {
|
||||
gate(true);
|
||||
const newer = mail('t-new');
|
||||
const older = mail('t-old', 'trusted', {
|
||||
receivedAt: '2026-10-29T09:00:00Z',
|
||||
bodyValues: { '1': { value: HTML.replace('SO-77120', 'SO-11111'), isTruncated: false, isEncodingProblem: false } },
|
||||
});
|
||||
render(
|
||||
<ThreadConversationView
|
||||
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
|
||||
emails={[newer, older]}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
// The newest message is expanded, the older (read) one collapsed.
|
||||
const regions = screen.getAllByRole('region', { name: 'region_shipment' });
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0]).toHaveTextContent('SO-77120');
|
||||
expect(screen.queryByText('SO-11111')).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -152,6 +152,8 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
|
||||
import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { StructuredMailCards } from "./structured-mail/structured-mail-cards";
|
||||
import { useStructuredMail } from "./structured-mail/use-structured-mail";
|
||||
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -2700,6 +2702,11 @@ export function EmailViewer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]);
|
||||
|
||||
// Booking, parcel and order cards of the message itself (never of an
|
||||
// unwrapped attached .eml); empty unless the sender is verified and the
|
||||
// admin gate is on.
|
||||
const structuredCards = useStructuredMail(email, blobSource);
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
@@ -5760,7 +5767,8 @@ export function EmailViewer({
|
||||
{(senderVerification ||
|
||||
(!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt) ||
|
||||
structuredCards.length > 0) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
@@ -5859,6 +5867,18 @@ export function EmailViewer({
|
||||
<CalendarInvitationBanner email={email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking, parcel and order cards (structured mail) */}
|
||||
{structuredCards.length > 0 && (
|
||||
<div className="py-1">
|
||||
<StructuredMailCards
|
||||
cards={structuredCards}
|
||||
email={email}
|
||||
// Product images load only when the body's remote content would.
|
||||
allowRemoteImages={!shouldBlockExternal && !plainView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Keys with their parameters, so a test can see the facts that go into a string.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'route') return `${params!.from} to ${params!.to}`;
|
||||
return params ? `${key}${JSON.stringify(params)}` : key;
|
||||
},
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
const toastSuccess = vi.fn();
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { success: (m: string) => toastSuccess(m), error: vi.fn() } }));
|
||||
|
||||
import expectedCards from '@/lib/structured-mail/__tests__/fixtures/expected-cards.json';
|
||||
import type { Booking, Shipment, StructuredCard, TransportBooking } from '@/lib/structured-mail/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { StructuredMailCards } from '../structured-mail-cards';
|
||||
|
||||
const fixtures = expectedCards as unknown as Record<string, StructuredCard[]>;
|
||||
const card = <T extends StructuredCard = StructuredCard>(id: string, index = 0) => structuredClone(fixtures[id][index]) as T;
|
||||
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
const initialSettings = useSettingsStore.getState();
|
||||
|
||||
beforeAll(() => {
|
||||
// Only Date is faked: "now" decides whether a year is shown.
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-10-30T12:00:00Z'));
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ timeFormat: '24h', timeZone: 'Europe/Berlin' });
|
||||
});
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(initialSettings, true);
|
||||
writeText.mockClear();
|
||||
toastSuccess.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function show(cards: StructuredCard[], extra: Partial<Parameters<typeof StructuredMailCards>[0]> = {}) {
|
||||
return render(<StructuredMailCards cards={cards} email={{ id: 'm1' }} allowRemoteImages={false} {...extra} />);
|
||||
}
|
||||
|
||||
describe('StructuredMailCards', () => {
|
||||
it('renders nothing for an empty list', () => {
|
||||
const { container } = show([]);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows a flight with its route, legs at local time, passengers and seats', () => {
|
||||
show([card('flight-two-passengers-two-legs')]);
|
||||
const region = screen.getByRole('region', { name: 'region_flight' });
|
||||
expect(within(region).getByRole('heading', { name: 'FRA to JFK to FRA' })).toBeInTheDocument();
|
||||
expect(within(region).getAllByTestId('structured-mail-stop').map((el) => el.textContent)).toEqual(['FRA', 'JFK', 'FRA']);
|
||||
expect(region).toHaveTextContent('kind_flight · Lufthansa');
|
||||
const legs = within(region).getAllByTestId('structured-mail-leg');
|
||||
expect(legs).toHaveLength(2);
|
||||
// The mail's wall clock at each airport: 10:15 in Frankfurt, 13:05 in New York.
|
||||
expect(legs[0]).toHaveTextContent('Lufthansa LH 400');
|
||||
expect(legs[0]).toHaveTextContent('Tue, Dec 1, 10:15 · Frankfurt Airport (FRA)');
|
||||
expect(legs[0]).toHaveTextContent('13:05local_time · John F. Kennedy International Airport (JFK)');
|
||||
// Overnight return: the arrival date is written out.
|
||||
expect(legs[1]).toHaveTextContent('Tue, Dec 8, 17:30');
|
||||
expect(legs[1]).toHaveTextContent('Wed, Dec 9, 07:20 · Frankfurt Airport (FRA)');
|
||||
expect(legs[0]).toHaveTextContent('seats: Erika Mustermann 12A, Max Mustermann 12B');
|
||||
expect(region).toHaveTextContent('Erika Mustermann, Max Mustermann');
|
||||
});
|
||||
|
||||
it('marks only the times whose place keeps another clock than the reader', () => {
|
||||
show([card('flight-two-passengers-two-legs')]);
|
||||
const [outbound] = screen.getAllByTestId('structured-mail-leg');
|
||||
// Reader in Berlin: Frankfurt needs no mark, New York does.
|
||||
const hints = within(outbound).getAllByTestId('local-time-hint');
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0].closest('div')).toHaveTextContent('John F. Kennedy');
|
||||
});
|
||||
|
||||
it('never shifts the time to the reader zone', () => {
|
||||
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
|
||||
show([card('flight-two-passengers-two-legs')]);
|
||||
const [outbound] = screen.getAllByTestId('structured-mail-leg');
|
||||
expect(outbound).toHaveTextContent('Tue, Dec 1, 10:15');
|
||||
expect(outbound).toHaveTextContent('13:05');
|
||||
expect(within(outbound).getAllByTestId('local-time-hint')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('copies the booking reference', async () => {
|
||||
show([card('flight-two-passengers-two-legs')]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copy_reference{"value":"QX7K2M"}' }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('QX7K2M'));
|
||||
expect(toastSuccess).toHaveBeenCalledWith('copied_reference');
|
||||
});
|
||||
|
||||
it('shows the check-in link with its host and opens it like a body link', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
show([card('flight-two-passengers-two-legs')]);
|
||||
const link = screen.getByRole('link', { name: /link_check_in/ });
|
||||
expect(link).toHaveTextContent('link_check_in· airline-a.example.com');
|
||||
expect(link).toHaveAttribute('href', 'https://www.airline-a.example.com/checkin?pnr=QX7K2M');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await act(async () => { fireEvent.click(link); });
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith('https://www.airline-a.example.com/checkin?pnr=QX7K2M', '_blank', 'noopener,noreferrer'));
|
||||
});
|
||||
|
||||
it('never renders a link that is not http(s)', () => {
|
||||
const flight = card<TransportBooking>('flight-two-passengers-two-legs');
|
||||
flight.links = { checkin: { url: 'javascript:alert(1)', host: 'evil.example' }, manage: { url: 'data:text/html,x', host: 'x' } };
|
||||
show([flight]);
|
||||
expect(screen.queryAllByTestId('structured-mail-link')).toHaveLength(0);
|
||||
expect(document.body.innerHTML).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('shows a cancelled booking struck through with a Cancelled pill', () => {
|
||||
show([card('flight-cancelled')]);
|
||||
const region = screen.getByRole('region', { name: 'region_flight' });
|
||||
expect(within(region).getByText('status_cancelled')).toBeInTheDocument();
|
||||
expect(within(region).getByRole('heading')).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('marks a single cancelled leg of a live booking', () => {
|
||||
const flight = card<TransportBooking>('flight-two-passengers-two-legs');
|
||||
flight.legs[1].status = 'cancelled';
|
||||
show([flight]);
|
||||
const legs = screen.getAllByTestId('structured-mail-leg');
|
||||
expect(within(legs[1]).getByText('status_cancelled')).toBeInTheDocument();
|
||||
expect(within(legs[0]).queryByText('status_cancelled')).toBeNull();
|
||||
expect(screen.getByRole('heading')).not.toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('shows Pending and On hold', () => {
|
||||
const pending = card<TransportBooking>('train-multiple-types');
|
||||
pending.status = 'pending';
|
||||
const hold = card<Booking>('graph-hotel');
|
||||
hold.status = 'hold';
|
||||
show([pending, hold]);
|
||||
expect(screen.getByRole('region', { name: 'region_train' })).toHaveTextContent('status_pending');
|
||||
expect(screen.getByRole('region', { name: 'region_lodging' })).toHaveTextContent('status_hold');
|
||||
});
|
||||
|
||||
it('titles a train with its towns and lists the stations', () => {
|
||||
show([card('train-multiple-types')]);
|
||||
const region = screen.getByRole('region', { name: 'region_train' });
|
||||
expect(within(region).getByRole('heading', { name: 'Hamburg to München' })).toBeInTheDocument();
|
||||
expect(region).toHaveTextContent('Wed, Nov 18, 08:01 · Hamburg Hbf');
|
||||
expect(region).toHaveTextContent('13:58 · München Hbf');
|
||||
expect(region).toHaveTextContent('Rail Co ICE 577');
|
||||
});
|
||||
|
||||
it('shows a hotel stay with address, check-in and check-out times, nights and the manage link', () => {
|
||||
show([card('graph-hotel')]);
|
||||
const region = screen.getByRole('region', { name: 'region_lodging' });
|
||||
expect(within(region).getByRole('heading', { name: 'Hotel Am Markt' })).toBeInTheDocument();
|
||||
expect(region).toHaveTextContent('Marktplatz 1, 10178 Berlin, Germany');
|
||||
expect(region).toHaveTextContent('check_in_label Fri, Dec 4, from_time{"time":"15:00"}');
|
||||
expect(region).toHaveTextContent('check_out_label Sun, Dec 6, until_time{"time":"11:00"}');
|
||||
expect(region).toHaveTextContent('nights{"count":2}');
|
||||
expect(within(region).getByRole('link', { name: /link_manage/ })).toHaveTextContent('hotel-booking.example.com');
|
||||
});
|
||||
|
||||
it('shows a dates-only stay without times', () => {
|
||||
show([card('hotel-dates-only')]);
|
||||
const region = screen.getByRole('region', { name: 'region_lodging' });
|
||||
expect(region).toHaveTextContent('check_in_label Tue, Jun 1, 2027');
|
||||
expect(region).toHaveTextContent('check_out_label Tue, Jun 8, 2027');
|
||||
expect(region).not.toHaveTextContent('from_time');
|
||||
expect(region).toHaveTextContent('nights{"count":7}');
|
||||
});
|
||||
|
||||
it('shows a rental car with pick-up, return and car', () => {
|
||||
show([card('rental-car')]);
|
||||
const region = screen.getByRole('region', { name: 'region_rentalcar' });
|
||||
expect(within(region).getByRole('heading', { name: 'Car Rental Co' })).toBeInTheDocument();
|
||||
expect(region).toHaveTextContent('pickup Fri, Dec 4, 09:00 · Flughafen BER, Schönefeld');
|
||||
expect(region).toHaveTextContent('dropoff Mon, Dec 7, 18:00 · Flughafen BER, Schönefeld');
|
||||
expect(region).toHaveTextContent('VW Golf');
|
||||
});
|
||||
|
||||
it('shows an event with its times, venue, tickets and ticket link', () => {
|
||||
const event = card('eventbrite-trailing-commas');
|
||||
const ahoi = card('ahoi-event');
|
||||
show([event, ahoi]);
|
||||
const [eventbrite, workshop] = screen.getAllByRole('region', { name: 'region_event' });
|
||||
expect(within(eventbrite).getByRole('heading', { name: 'Jazz im Park' })).toBeInTheDocument();
|
||||
expect(eventbrite).toHaveTextContent('Sat, Nov 14, 19:00 – 22:30');
|
||||
expect(eventbrite).toHaveTextContent('Stadtpark, Parkweg 2, Hamburg, Germany');
|
||||
expect(within(eventbrite).getByRole('link', { name: /link_ticket/ })).toHaveTextContent('tickets.example.com');
|
||||
expect(workshop).toHaveTextContent('Sun, Jul 18, 2021, 14:00');
|
||||
expect(workshop).toHaveTextContent('tickets: AB12CD-1');
|
||||
});
|
||||
|
||||
it('shows a restaurant table with time, address and party size', () => {
|
||||
show([card('restaurant-cafe')]);
|
||||
const region = screen.getByRole('region', { name: 'region_restaurant' });
|
||||
expect(within(region).getByRole('heading', { name: 'Café Kranzler' })).toBeInTheDocument();
|
||||
expect(region).toHaveTextContent('Sat, Nov 7, 19:30');
|
||||
expect(region).toHaveTextContent('Kurfürstendamm 18, 10719 Berlin');
|
||||
expect(region).toHaveTextContent('party_size{"count":4}');
|
||||
});
|
||||
|
||||
it('shows an order with its parcel: number, status, items, total, tracking and links', async () => {
|
||||
show([card('order-and-parcel')]);
|
||||
const region = screen.getByRole('region', { name: 'region_shipment' });
|
||||
expect(within(region).getByRole('heading', { name: 'Outdoor Shop' })).toBeInTheDocument();
|
||||
expect(region).toHaveTextContent('kind_order · DHL');
|
||||
expect(region).toHaveTextContent('order_status_in_transit');
|
||||
expect(region).toHaveTextContent('Wanderschuhe, Socken');
|
||||
expect(region).toHaveTextContent('items_count{"count":3} · total{"total":"€129.90"}');
|
||||
expect(region).toHaveTextContent('expected_by{"date":"Tue, Nov 3"}');
|
||||
expect(within(region).getByRole('link', { name: /link_track/ })).toHaveTextContent('link_track· dhl.de');
|
||||
expect(within(region).getByRole('link', { name: /link_view_order/ })).toHaveTextContent('outdoor-shop.example.com');
|
||||
|
||||
fireEvent.click(within(region).getByRole('button', { name: 'copy_tracking_number{"value":"00340434161234567890"}' }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('00340434161234567890'));
|
||||
fireEvent.click(within(region).getByRole('button', { name: 'copy_order_number{"value":"SO-449120"}' }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('SO-449120'));
|
||||
});
|
||||
|
||||
it('drops the expected date once the order is delivered', () => {
|
||||
const shipment = card<Shipment>('order-and-parcel');
|
||||
shipment.status = 'delivered';
|
||||
show([shipment]);
|
||||
const region = screen.getByRole('region', { name: 'region_shipment' });
|
||||
expect(region).toHaveTextContent('order_status_delivered');
|
||||
expect(region).not.toHaveTextContent('expected_by');
|
||||
// Carrier and tracking number stay.
|
||||
expect(region).toHaveTextContent('00340434161234567890');
|
||||
});
|
||||
|
||||
describe('right-to-left place names and values', () => {
|
||||
const hebrewTrain = (): TransportBooking => {
|
||||
const train = card<TransportBooking>('train-multiple-types');
|
||||
train.legs[0].departure.place = { name: 'תל אביב סבידור מרכז', address: { locality: 'תל אביב', country: 'IL' } };
|
||||
train.legs[0].arrival!.place = { name: 'חיפה מרכז השמונה', address: { locality: 'חיפה', country: 'IL' } };
|
||||
return train;
|
||||
};
|
||||
|
||||
it.each(['rtl', 'ltr'] as const)('isolates each stop and draws the arrow as a mirrored icon (%s UI)', (dir) => {
|
||||
render(
|
||||
<div dir={dir}>
|
||||
<StructuredMailCards cards={[hebrewTrain()]} email={{ id: 'm1' }} allowRemoteImages={false} />
|
||||
</div>,
|
||||
);
|
||||
const region = screen.getByRole('region', { name: 'region_train' });
|
||||
// Spoken in reading order, whatever the script.
|
||||
expect(within(region).getByRole('heading', { name: 'תל אביב to חיפה' })).toBeInTheDocument();
|
||||
const stops = within(region).getAllByTestId('structured-mail-stop');
|
||||
expect(stops.map((el) => el.tagName)).toEqual(['BDI', 'BDI']);
|
||||
expect(stops.map((el) => el.textContent)).toEqual(['תל אביב', 'חיפה']);
|
||||
const visual = within(region).getByTestId('structured-mail-title-visual');
|
||||
// No arrow character the bidi algorithm could turn around: an icon between the stops.
|
||||
expect(visual.textContent).not.toMatch(/[→←]/);
|
||||
const arrow = visual.querySelector('svg');
|
||||
expect(arrow).toHaveClass('tabler-icon-arrow-right');
|
||||
expect(arrow!.compareDocumentPosition(stops[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(stops[0].compareDocumentPosition(arrow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
// Station names in the leg rows are isolated too.
|
||||
const leg = within(region).getByTestId('structured-mail-leg');
|
||||
expect([...leg.querySelectorAll('bdi')].map((el) => el.textContent)).toEqual(expect.arrayContaining(['תל אביב סבידור מרכז', 'חיפה מרכז השמונה']));
|
||||
});
|
||||
|
||||
it('relies on the global mirror for that arrow in RTL', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const css = readFileSync(`${process.cwd()}/app/globals.css`, 'utf8');
|
||||
expect(css).toMatch(/\[dir="rtl"\] \.tabler-icon-arrow-right/);
|
||||
});
|
||||
|
||||
it('keeps references, tracking and order numbers, seats and tickets left to right', () => {
|
||||
render(
|
||||
<div dir="rtl">
|
||||
<StructuredMailCards
|
||||
cards={[card('flight-two-passengers-two-legs'), card('ahoi-event'), card('order-and-parcel')]}
|
||||
email={{ id: 'm1' }}
|
||||
allowRemoteImages={false}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
for (const value of ['QX7K2M', 'AB12CD', 'SO-449120', '00340434161234567890']) {
|
||||
expect(screen.getAllByText(value)[0]).toHaveAttribute('dir', 'ltr');
|
||||
}
|
||||
const seats = screen.getAllByTestId('structured-mail-seat');
|
||||
expect(seats.map((el) => el.textContent)).toEqual(['12A', '12B', '31K', '31J']);
|
||||
seats.forEach((el) => expect(el).toHaveAttribute('dir', 'ltr'));
|
||||
expect(screen.getByTestId('structured-mail-tickets')).toHaveAttribute('dir', 'ltr');
|
||||
expect(screen.getByTestId('structured-mail-tickets')).toHaveTextContent('AB12CD-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no action row when a card has nothing it would put there', () => {
|
||||
const restaurant = card<Booking>('restaurant-cafe');
|
||||
delete restaurant.reference;
|
||||
// A restaurant card shows no ticket link, so this alone gives no row.
|
||||
restaurant.links = { ticket: { url: 'https://tables.example.com/t/1', host: 'tables.example.com' } };
|
||||
show([restaurant]);
|
||||
const region = screen.getByRole('region', { name: 'region_restaurant' });
|
||||
expect(within(region).queryByTestId('structured-mail-actions')).toBeNull();
|
||||
expect(within(region).queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows one Track button per tracking page', () => {
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const shipment = card<Shipment>('order-and-parcel');
|
||||
const parcel = shipment.parcels[0];
|
||||
// The same parcel twice (same number and page), and a second one with its own page.
|
||||
shipment.parcels = [parcel, { ...parcel }, { ...parcel, trackingNumber: '00340434169999999999', trackingUrl: { url: 'https://www.dhl.de/x?piececode=00340434169999999999', host: 'dhl.de' } }];
|
||||
show([shipment]);
|
||||
const region = screen.getByRole('region', { name: 'region_shipment' });
|
||||
const tracks = within(region).getAllByRole('link', { name: /link_track/ });
|
||||
expect(tracks.map((a) => a.getAttribute('href'))).toEqual([parcel.trackingUrl!.url, 'https://www.dhl.de/x?piececode=00340434169999999999']);
|
||||
expect(errors.mock.calls.flat().join(' ')).not.toMatch(/same key/);
|
||||
});
|
||||
|
||||
it('lists three items and says how many more there are', () => {
|
||||
const shipment: Shipment = {
|
||||
kind: 'shipment', key: 's', merchant: 'Shop', orderNumber: 'A-1', source: 'json-ld', parcels: [],
|
||||
items: ['One', 'Two', 'Three', 'Four', 'Five'].map((name) => ({ name })),
|
||||
total: { amount: '1299.00', currency: 'USD' },
|
||||
};
|
||||
show([shipment]);
|
||||
const region = screen.getByRole('region', { name: 'region_shipment' });
|
||||
expect(region).toHaveTextContent('One, Two, Three items_more{"count":2}');
|
||||
expect(region).not.toHaveTextContent('Four');
|
||||
expect(region).toHaveTextContent('total{"total":"$1,299.00"}');
|
||||
});
|
||||
|
||||
it('loads item images only when remote content is allowed, without a referrer', () => {
|
||||
const { unmount, container } = show([card('order-and-parcel')]);
|
||||
expect(container.querySelectorAll('img')).toHaveLength(0);
|
||||
unmount();
|
||||
const allowed = show([card('order-and-parcel')], { allowRemoteImages: true });
|
||||
const imgs = allowed.container.querySelectorAll('img');
|
||||
expect(imgs).toHaveLength(1);
|
||||
expect(imgs[0]).toHaveAttribute('src', 'https://img.shop.example.com/p/1.jpg');
|
||||
expect(imgs[0]).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
// The item names are in the text already.
|
||||
expect(imgs[0]).toHaveAttribute('alt', '');
|
||||
});
|
||||
|
||||
it('gives each booking to the calendar slot, and none for a shipment', () => {
|
||||
const slot = vi.fn((booking: Booking) => <button type="button">{`add ${booking.kind}`}</button>);
|
||||
show([card('flight-two-passengers-two-legs'), card('graph-hotel'), card('order-and-parcel')], { calendarActions: slot });
|
||||
expect(slot).toHaveBeenCalledTimes(2);
|
||||
expect(slot.mock.calls.map(([b]) => b.kind)).toEqual(['flight', 'lodging']);
|
||||
expect(slot.mock.calls[0][0]).toMatchObject({ reference: 'QX7K2M' });
|
||||
expect(within(screen.getByRole('region', { name: 'region_flight' })).getByRole('button', { name: 'add flight' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no action row items for the calendar when the slot is undefined', () => {
|
||||
show([card('restaurant-cafe')]);
|
||||
const region = screen.getByRole('region', { name: 'region_restaurant' });
|
||||
// Only the reference chip.
|
||||
expect(within(region).getAllByRole('button')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowRight, Bed, Bus, Car, Clock, MapPin, Phone, Plane, Restaurant, Ticket, Train, Users } from "@/components/icons";
|
||||
import {
|
||||
formatAddress,
|
||||
formatMomentDate,
|
||||
formatMomentTime,
|
||||
formatPlace,
|
||||
isOtherZone,
|
||||
isWebLink,
|
||||
legPlaceLabel,
|
||||
legService,
|
||||
nightsBetween,
|
||||
routeRuns,
|
||||
routeTitle,
|
||||
sameLocalDate,
|
||||
} from "@/lib/structured-mail/format";
|
||||
import type {
|
||||
Booking,
|
||||
EventBooking,
|
||||
Link,
|
||||
LodgingBooking,
|
||||
Moment,
|
||||
RentalCarBooking,
|
||||
RestaurantBooking,
|
||||
TransportBooking,
|
||||
TransportLeg,
|
||||
} from "@/lib/structured-mail/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ACTION_CHIP_CLASS,
|
||||
BookingStatusPill,
|
||||
CardLink,
|
||||
CardShell,
|
||||
MetaRow,
|
||||
PILL_CLASS,
|
||||
When,
|
||||
eyebrowText,
|
||||
useCardFormat,
|
||||
} from "./card-parts";
|
||||
import { CopyValueChip } from "./copy-value-chip";
|
||||
|
||||
export interface BookingCardProps<B extends Booking = Booking> {
|
||||
booking: B;
|
||||
emailId?: string;
|
||||
/** Slot in the action row, for "Add to calendar". */
|
||||
calendarActions?: (booking: Booking) => ReactNode;
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON = { flight: Plane, train: Train, bus: Bus } as const;
|
||||
|
||||
interface CardLinkSpec {
|
||||
link?: Link;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The action row: reference chip, the links this card shows and the
|
||||
* calendar slot. Undefined when there is none of them, so no empty row.
|
||||
*/
|
||||
function bookingActions(booking: Booking, calendarActions: BookingCardProps["calendarActions"], emailId: string | undefined, links: CardLinkSpec[]): ReactNode {
|
||||
const shown = links.filter((l) => isWebLink(l.link));
|
||||
if (!booking.reference && !calendarActions && shown.length === 0) return undefined;
|
||||
return (
|
||||
<BookingActions booking={booking} calendarActions={calendarActions}>
|
||||
{shown.map((l) => <CardLink key={l.label} link={l.link} label={l.label} emailId={emailId} />)}
|
||||
</BookingActions>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingActions({ booking, calendarActions, children }: Pick<BookingCardProps, "booking" | "calendarActions"> & { children: ReactNode }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
return (
|
||||
<>
|
||||
{booking.reference && (
|
||||
<CopyValueChip
|
||||
value={booking.reference}
|
||||
label={t("reference")}
|
||||
copyLabel={t("copy_reference", { value: booking.reference })}
|
||||
copiedMessage={t("copied_reference")}
|
||||
className={ACTION_CHIP_CLASS}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{calendarActions?.(booking)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PassengersRow({ names }: { names: string[] }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
if (names.length === 0) return null;
|
||||
return (
|
||||
<MetaRow icon={Users}>
|
||||
<span className="sr-only">{t("passengers")}: </span>
|
||||
{names.join(", ")}
|
||||
</MetaRow>
|
||||
);
|
||||
}
|
||||
|
||||
function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: TransportBooking["kind"]; bookingCancelled: boolean }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const cancelled = leg.status === "cancelled";
|
||||
const service = legService(leg);
|
||||
const Icon = TRANSPORT_ICON[kind];
|
||||
const arrival = leg.arrival;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5 min-w-0" data-testid="structured-mail-leg" data-cancelled={cancelled || undefined}>
|
||||
{(service || (cancelled && !bookingCancelled)) && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
|
||||
{service && (
|
||||
<span className={cn("flex items-center gap-1.5 min-w-0 font-medium text-foreground", cancelled && "line-through text-muted-foreground")}>
|
||||
<Icon aria-hidden="true" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="break-words">{service}</span>
|
||||
</span>
|
||||
)}
|
||||
{cancelled && !bookingCancelled && (
|
||||
<span className={cn(PILL_CLASS, "bg-destructive/15 text-destructive")}>{t("status_cancelled")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<MetaRow icon={Clock}>
|
||||
<When at={leg.departure.at} struck={cancelled} />
|
||||
<span className={cn(cancelled && "line-through")}> · <bdi>{legPlaceLabel(leg.departure.place, kind)}</bdi></span>
|
||||
</MetaRow>
|
||||
{arrival && (
|
||||
<MetaRow icon={ArrowRight}>
|
||||
{arrival.at && <When at={arrival.at} timeOnly={sameLocalDate(arrival.at, leg.departure.at)} struck={cancelled} />}
|
||||
<span className={cn(cancelled && "line-through")}>{arrival.at ? " · " : ""}<bdi>{legPlaceLabel(arrival.place, kind)}</bdi></span>
|
||||
</MetaRow>
|
||||
)}
|
||||
{leg.seats.length > 0 && (
|
||||
<MetaRow icon={Ticket}>
|
||||
<span>{t("seats")}: </span>
|
||||
{leg.seats.map((seat, i) => (
|
||||
<Fragment key={`${seat.passenger ?? ""}-${seat.seat}-${i}`}>
|
||||
{i > 0 && ", "}
|
||||
{seat.passenger && <><bdi>{seat.passenger}</bdi> </>}
|
||||
<bdi dir="ltr" data-testid="structured-mail-seat">{seat.seat}</bdi>
|
||||
</Fragment>
|
||||
))}
|
||||
</MetaRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "FRA → JFK → FRA" as parts: every stop isolated in a <bdi>, and ArrowRight,
|
||||
* which globals.css mirrors under [dir=rtl], between them, so the arrows
|
||||
* point the reading direction whatever script the names are in.
|
||||
*/
|
||||
function RouteTitle({ runs }: { runs: string[][] }) {
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, r) => (
|
||||
<Fragment key={r}>
|
||||
{r > 0 && ", "}
|
||||
{run.map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <ArrowRight aria-hidden="true" className="inline-block w-3.5 h-3.5 mx-1 align-[-0.125em]" />}
|
||||
<bdi data-testid="structured-mail-stop">{stop}</bdi>
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransportCard({ booking, emailId, calendarActions }: BookingCardProps<TransportBooking>) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const kindLabel = booking.kind === "flight" ? t("kind_flight") : booking.kind === "train" ? t("kind_train") : t("kind_bus");
|
||||
const name = booking.kind === "flight" ? t("region_flight") : booking.kind === "train" ? t("region_train") : t("region_bus");
|
||||
const title = routeTitle(booking);
|
||||
const runs = routeRuns(booking);
|
||||
const cancelled = booking.status === "cancelled";
|
||||
return (
|
||||
<CardShell
|
||||
kind={booking.kind}
|
||||
name={name}
|
||||
icon={TRANSPORT_ICON[booking.kind]}
|
||||
cancelled={cancelled}
|
||||
eyebrow={eyebrowText(kindLabel, booking.provider, title)}
|
||||
title={<RouteTitle runs={runs} />}
|
||||
titleLabel={runs.map((run) => run.reduce((from, to) => t("route", { from, to }))).join(", ")}
|
||||
pills={booking.status && booking.status !== "confirmed" ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.checkin, label: t("link_check_in") }, { link: booking.links.manage, label: t("link_manage") }, { link: booking.links.ticket, label: t("link_ticket") }])}
|
||||
>
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
{booking.legs.map((leg, i) => (
|
||||
<Leg key={`${leg.departure.at.date}-${leg.number ?? ""}-${i}`} leg={leg} kind={booking.kind} bookingCancelled={cancelled} />
|
||||
))}
|
||||
</div>
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Fri, Dec 4, from 15:00" / "Sun, Dec 6, until 11:00" / the date alone. */
|
||||
function StayEnd({ label, at, edge }: { label: string; at: Moment; edge: "from" | "until" }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts } = useCardFormat();
|
||||
const time = formatMomentTime(at, opts);
|
||||
return (
|
||||
<MetaRow icon={Clock}>
|
||||
<span>{label} </span>
|
||||
<span className="text-foreground/90">{formatMomentDate(at, opts)}</span>
|
||||
{time && (
|
||||
<>
|
||||
<span>, </span>
|
||||
<span className="text-foreground/90">{edge === "from" ? t("from_time", { time }) : t("until_time", { time })}</span>
|
||||
<LocalHint at={at} />
|
||||
</>
|
||||
)}
|
||||
</MetaRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the "local time" mark of a moment (the time itself is already written). */
|
||||
function LocalHint({ at }: { at: Moment }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { readerZone } = useCardFormat();
|
||||
// When renders the mark next to its own text; here the text is the from/until phrase.
|
||||
return isOtherZone(at, readerZone) ? (
|
||||
<span className="ms-1 text-xs text-muted-foreground" title={t("local_time_title")} data-testid="local-time-hint">{t("local_time")}</span>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function LodgingCard({ booking, emailId, calendarActions }: BookingCardProps<LodgingBooking>) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts } = useCardFormat();
|
||||
const nights = nightsBetween(booking.checkin, booking.checkout);
|
||||
const address = formatAddress(booking.address, opts.locale);
|
||||
return (
|
||||
<CardShell
|
||||
kind="lodging"
|
||||
name={t("region_lodging")}
|
||||
icon={Bed}
|
||||
cancelled={booking.status === "cancelled"}
|
||||
eyebrow={eyebrowText(t("kind_lodging"), booking.provider, booking.name)}
|
||||
title={booking.name}
|
||||
pills={booking.status && booking.status !== "confirmed" ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }, { link: booking.links.checkin, label: t("link_check_in") }])}
|
||||
>
|
||||
{address && <MetaRow icon={MapPin}>{address}</MetaRow>}
|
||||
<StayEnd label={t("check_in_label")} at={booking.checkin} edge="from" />
|
||||
<StayEnd label={t("check_out_label")} at={booking.checkout} edge="until" />
|
||||
{nights !== undefined && <MetaRow icon={Bed}>{t("nights", { count: nights })}</MetaRow>}
|
||||
{booking.phone && <MetaRow icon={Phone}><span dir="ltr">{booking.phone}</span></MetaRow>}
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RentalCarCard({ booking, emailId, calendarActions }: BookingCardProps<RentalCarBooking>) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const title = booking.company?.trim() || t("kind_rentalcar");
|
||||
const pickupPlace = formatPlace(booking.pickup.place);
|
||||
const dropoffPlace = formatPlace(booking.dropoff.place);
|
||||
return (
|
||||
<CardShell
|
||||
kind="rentalcar"
|
||||
name={t("region_rentalcar")}
|
||||
icon={Car}
|
||||
cancelled={booking.status === "cancelled"}
|
||||
eyebrow={eyebrowText(t("kind_rentalcar"), booking.provider, title)}
|
||||
title={title}
|
||||
pills={booking.status && booking.status !== "confirmed" ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }])}
|
||||
>
|
||||
<MetaRow icon={Clock}>
|
||||
<span>{t("pickup")} </span>
|
||||
<When at={booking.pickup.at} />
|
||||
{pickupPlace && <span> · {pickupPlace}</span>}
|
||||
</MetaRow>
|
||||
<MetaRow icon={ArrowRight}>
|
||||
<span>{t("dropoff")} </span>
|
||||
<When at={booking.dropoff.at} />
|
||||
{dropoffPlace && <span> · {dropoffPlace}</span>}
|
||||
</MetaRow>
|
||||
{booking.car && <MetaRow icon={Car}>{booking.car}</MetaRow>}
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventCard({ booking, emailId, calendarActions }: BookingCardProps<EventBooking>) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts } = useCardFormat();
|
||||
const venueName = booking.venue?.name?.trim();
|
||||
const venueAddress = formatAddress(booking.venue?.address, opts.locale);
|
||||
// "Stadtpark, Parkweg 2, Hamburg"; the name alone, or the address alone, when that's all there is.
|
||||
const venue = venueName && venueAddress
|
||||
? (venueAddress.includes(venueName) ? venueAddress : `${venueName}, ${venueAddress}`)
|
||||
: venueName || venueAddress || formatPlace(booking.venue);
|
||||
const end = booking.end;
|
||||
return (
|
||||
<CardShell
|
||||
kind="event"
|
||||
name={t("region_event")}
|
||||
icon={Ticket}
|
||||
cancelled={booking.status === "cancelled"}
|
||||
eyebrow={eyebrowText(t("kind_event"), booking.provider, booking.name)}
|
||||
title={booking.name}
|
||||
pills={booking.status && booking.status !== "confirmed" ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.ticket, label: t("link_ticket") }, { link: booking.links.manage, label: t("link_manage") }])}
|
||||
>
|
||||
<MetaRow icon={Clock}>
|
||||
<When at={booking.start} />
|
||||
{end && (end.time || !sameLocalDate(end, booking.start)) && (
|
||||
<>
|
||||
<span> – </span>
|
||||
<When at={end} timeOnly={sameLocalDate(end, booking.start) && !!booking.start.time} />
|
||||
</>
|
||||
)}
|
||||
</MetaRow>
|
||||
{venue && <MetaRow icon={MapPin}>{venue}</MetaRow>}
|
||||
{booking.tickets.length > 0 && (
|
||||
<MetaRow icon={Ticket}>
|
||||
<span>{t("tickets")}: </span>
|
||||
<bdi dir="ltr" className="font-mono break-all" data-testid="structured-mail-tickets">{booking.tickets.join(", ")}</bdi>
|
||||
</MetaRow>
|
||||
)}
|
||||
<PassengersRow names={booking.passengers} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestaurantCard({ booking, emailId, calendarActions }: BookingCardProps<RestaurantBooking>) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts } = useCardFormat();
|
||||
const address = formatAddress(booking.address, opts.locale);
|
||||
return (
|
||||
<CardShell
|
||||
kind="restaurant"
|
||||
name={t("region_restaurant")}
|
||||
icon={Restaurant}
|
||||
cancelled={booking.status === "cancelled"}
|
||||
eyebrow={eyebrowText(t("kind_restaurant"), booking.provider, booking.name)}
|
||||
title={booking.name}
|
||||
pills={booking.status && booking.status !== "confirmed" ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }])}
|
||||
>
|
||||
<MetaRow icon={Clock}><When at={booking.start} /></MetaRow>
|
||||
{address && <MetaRow icon={MapPin}>{address}</MetaRow>}
|
||||
{booking.partySize !== undefined && booking.partySize > 0 && (
|
||||
<MetaRow icon={Users}>{t("party_size", { count: booking.partySize })}</MetaRow>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "@/components/icons";
|
||||
import { resolveExternalLink } from "@/lib/plugin-hook-result";
|
||||
import { formatMoment, formatMomentTime, isOtherZone, isWebLink, type FormatOptions } from "@/lib/structured-mail/format";
|
||||
import type { Link, Moment, ReservationStatus } from "@/lib/structured-mail/types";
|
||||
import { resolveTimeZone } from "@/lib/timezone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type IconType = ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
|
||||
|
||||
export const ACTION_CLASS =
|
||||
"inline-flex max-w-full min-w-0 items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]";
|
||||
export const PILL_CLASS = "rounded-full px-2 py-0.5 text-[0.6875rem] font-medium whitespace-nowrap";
|
||||
/** The copy chip in an action row, as tall as the buttons beside it. */
|
||||
export const ACTION_CHIP_CLASS = "min-h-[36px] px-3 text-sm";
|
||||
|
||||
/** Locale, clock and the reader's zone, for formatting a card's times. */
|
||||
export function useCardFormat(): { opts: FormatOptions; readerZone: string } {
|
||||
const locale = useLocale();
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const timeZone = useSettingsStore((s) => s.timeZone);
|
||||
return {
|
||||
opts: { locale, hour12: timeFormat === "12h" ? true : timeFormat === "24h" ? false : undefined },
|
||||
readerZone: resolveTimeZone(timeZone),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A time as the mail gave it, at the place. `timeOnly` leaves out the date
|
||||
* (an arrival on the departure day). Marked "local time" when the place's
|
||||
* clock differs from the reader's at that instant.
|
||||
*/
|
||||
export function When({ at, timeOnly = false, struck = false }: { at: Moment; timeOnly?: boolean; struck?: boolean }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts, readerZone } = useCardFormat();
|
||||
const text = timeOnly && at.time ? formatMomentTime(at, opts) : formatMoment(at, opts);
|
||||
const other = isOtherZone(at, readerZone);
|
||||
return (
|
||||
<>
|
||||
<span className={cn("text-foreground/90 whitespace-nowrap", struck && "line-through")}>{text}</span>
|
||||
{other && (
|
||||
<span className="ms-1 text-xs text-muted-foreground" title={t("local_time_title")} data-testid="local-time-hint">
|
||||
{t("local_time")}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One meta row: a small icon and text that wraps instead of overflowing. */
|
||||
export function MetaRow({ icon: Icon, children, className }: { icon: IconType; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-start gap-1.5 min-w-0", className)}>
|
||||
<Icon aria-hidden="true" className="w-3.5 h-3.5 flex-shrink-0 mt-[0.1875rem]" />
|
||||
<div className="min-w-0 break-words">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<Exclude<ReservationStatus, "confirmed">, string> = {
|
||||
cancelled: "bg-destructive/15 text-destructive",
|
||||
pending: "bg-warning/15 text-warning",
|
||||
hold: "bg-warning/15 text-warning",
|
||||
};
|
||||
|
||||
/** Cancelled, Pending or On hold; nothing for a confirmed booking. */
|
||||
export function BookingStatusPill({ status }: { status?: ReservationStatus }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
if (!status || status === "confirmed") return null;
|
||||
const label = status === "cancelled" ? t("status_cancelled") : status === "pending" ? t("status_pending") : t("status_hold");
|
||||
return <span className={cn(PILL_CLASS, STATUS_TONE[status])}>{label}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A link from the mail with its host beside the label ("Track · dhl.de").
|
||||
* Opens like a link in the message body: plugins may stop or redirect it,
|
||||
* then a new tab without opener or referrer.
|
||||
*/
|
||||
export function CardLink({ link, label, icon: Icon = ExternalLink, emailId }: { link?: Link; label: string; icon?: IconType; emailId?: string }) {
|
||||
if (!link || !isWebLink(link)) return null;
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(ACTION_CLASS, "flex-wrap gap-y-0")}
|
||||
data-testid="structured-mail-link"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = await resolveExternalLink({ href: link.url, target: "_blank", emailId });
|
||||
if (!target) return;
|
||||
window.open(target, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden="true" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
{/* The host is how a reader judges the link: wrapped, never cut off. */}
|
||||
<span className="max-w-full [overflow-wrap:anywhere] text-muted-foreground/80">· {link.host}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardShellProps {
|
||||
/** Accessible name of the region ("Flight booking"). */
|
||||
name: string;
|
||||
icon: IconType;
|
||||
/** Destructive tone and a struck-through title. */
|
||||
cancelled?: boolean;
|
||||
eyebrow: string;
|
||||
title: ReactNode;
|
||||
/** What screen readers hear instead of a title drawn from parts (a route with arrow icons). */
|
||||
titleLabel?: string;
|
||||
pills?: ReactNode;
|
||||
children?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** The banner layout every card shares (CalendarInvitationBanner's look). */
|
||||
export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title, titleLabel, pills, children, actions, kind }: CardShellProps) {
|
||||
return (
|
||||
<section aria-label={name} className="flex items-start gap-3 min-w-0" data-testid="structured-mail-card" data-kind={kind}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
cancelled ? "bg-destructive/15 text-destructive" : "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[0.6875rem] font-semibold text-muted-foreground break-words">{eyebrow}</div>
|
||||
<h3 className={cn("text-sm font-semibold leading-snug break-words", cancelled ? "line-through text-muted-foreground" : "text-foreground")}>
|
||||
{titleLabel ? (
|
||||
<>
|
||||
<span className="sr-only">{titleLabel}</span>
|
||||
<span aria-hidden="true" data-testid="structured-mail-title-visual">{title}</span>
|
||||
</>
|
||||
) : title}
|
||||
</h3>
|
||||
</div>
|
||||
{pills && <div className="flex flex-wrap items-center gap-1.5">{pills}</div>}
|
||||
{children && <div className="flex flex-col gap-1 text-sm text-muted-foreground">{children}</div>}
|
||||
{actions && <div className="flex flex-wrap items-center gap-1.5 pt-0.5" data-testid="structured-mail-actions">{actions}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Flight · Lufthansa"; the kind alone when the provider is missing or already the title. */
|
||||
export function eyebrowText(kind: string, provider: string | undefined, title: string): string {
|
||||
const p = provider?.trim();
|
||||
return p && p.toLowerCase() !== title.trim().toLowerCase() ? `${kind} · ${p}` : kind;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Copy } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { CHIP_BOX_CLASS, CHIP_ICON_CLASS } from "../attachment-chips";
|
||||
|
||||
interface CopyValueChipProps {
|
||||
/** What a click copies: a booking reference, order or tracking number. */
|
||||
value: string;
|
||||
/** The button's accessible name, naming what is copied ("Copy booking reference QX7K2M"). */
|
||||
copyLabel: string;
|
||||
/** The toast after copying ("Booking reference copied"). */
|
||||
copiedMessage: string;
|
||||
/** A visible label in front of the value ("Booking reference"). */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A number from a booking or parcel card; a click copies it. Looks like the verification code chip. */
|
||||
export function CopyValueChip({ value, copyLabel, copiedMessage, label, className }: CopyValueChipProps) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success(copiedMessage);
|
||||
} catch {
|
||||
toast.error(t("copy_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = copied ? Check : Copy;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); void copy(); }}
|
||||
title={copyLabel}
|
||||
aria-label={copyLabel}
|
||||
data-testid="copy-value-chip"
|
||||
className={cn(CHIP_BOX_CLASS, "max-w-full bg-background/60 font-medium text-foreground hover:bg-muted transition-colors", className)}
|
||||
>
|
||||
<Icon aria-hidden="true" className={cn(CHIP_ICON_CLASS, copied ? "text-success" : "text-muted-foreground")} />
|
||||
{label && <span className="font-normal text-muted-foreground">{label}</span>}
|
||||
{/* Digit groups keep their order in a right-to-left UI. */}
|
||||
<bdi dir="ltr" className="min-w-0 break-all font-mono tracking-wide">{value}</bdi>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Clock, ExternalLink, Package, ShoppingBag, Truck } from "@/components/icons";
|
||||
import { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from "@/lib/structured-mail/format";
|
||||
import type { Item, OrderStatus, Parcel, Shipment } from "@/lib/structured-mail/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CardLink, CardShell, MetaRow, PILL_CLASS, useCardFormat } from "./card-parts";
|
||||
import { CopyValueChip } from "./copy-value-chip";
|
||||
|
||||
const STATUS_TONE: Record<OrderStatus, string> = {
|
||||
payment_due: "bg-warning/15 text-warning",
|
||||
processing: "bg-muted text-muted-foreground",
|
||||
problem: "bg-destructive/15 text-destructive",
|
||||
in_transit: "bg-info/15 text-info",
|
||||
pickup_available: "bg-success/15 text-success",
|
||||
returned: "bg-muted text-muted-foreground",
|
||||
delivered: "bg-success/15 text-success",
|
||||
cancelled: "bg-destructive/15 text-destructive",
|
||||
};
|
||||
|
||||
/** States in which an expected arrival date is out of date. */
|
||||
const ARRIVED_OR_STOPPED = new Set<OrderStatus>(["delivered", "returned", "cancelled"]);
|
||||
|
||||
function OrderStatusPill({ status }: { status: OrderStatus }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const labels: Record<OrderStatus, string> = {
|
||||
payment_due: t("order_status_payment_due"),
|
||||
processing: t("order_status_processing"),
|
||||
problem: t("order_status_problem"),
|
||||
in_transit: t("order_status_in_transit"),
|
||||
pickup_available: t("order_status_pickup_available"),
|
||||
returned: t("order_status_returned"),
|
||||
delivered: t("order_status_delivered"),
|
||||
cancelled: t("order_status_cancelled"),
|
||||
};
|
||||
return <span className={cn(PILL_CLASS, STATUS_TONE[status])}>{labels[status]}</span>;
|
||||
}
|
||||
|
||||
/** Product images, only when remote content may load for this message; https only, no referrer. */
|
||||
function ItemImages({ items }: { items: Item[] }) {
|
||||
const shown = items.filter((item) => item.image && isWebLink(item.image) && item.image.url.toLowerCase().startsWith("https:"));
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{shown.map((item, i) => (
|
||||
<img
|
||||
key={`${item.image!.url}-${i}`}
|
||||
src={item.image!.url}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
className="h-10 w-10 rounded-md border border-border bg-background object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Carrier, tracking number and expected date; no date once the order is delivered, returned or cancelled. */
|
||||
function ParcelRows({ parcel, showExpected }: { parcel: Parcel; showExpected: boolean }) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts } = useCardFormat();
|
||||
const expectedBy = showExpected ? parcel.expectedBy : undefined;
|
||||
if (!parcel.carrier && !parcel.trackingNumber && !expectedBy) return null;
|
||||
return (
|
||||
<>
|
||||
{(parcel.carrier || parcel.trackingNumber) && (
|
||||
<MetaRow icon={Truck}>
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-1 min-w-0">
|
||||
{parcel.carrier && <span className="text-foreground/90">{parcel.carrier}</span>}
|
||||
{!parcel.carrier && parcel.trackingNumber && <span>{t("tracking_number")}</span>}
|
||||
{parcel.trackingNumber && <CopyValueChip
|
||||
value={parcel.trackingNumber}
|
||||
copyLabel={t("copy_tracking_number", { value: parcel.trackingNumber })}
|
||||
copiedMessage={t("copied_tracking_number")}
|
||||
/>}
|
||||
</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
{expectedBy && (
|
||||
<MetaRow icon={Clock}>{t("expected_by", { date: formatMomentDate(expectedBy, opts) })}</MetaRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShipmentCardProps {
|
||||
shipment: Shipment;
|
||||
emailId?: string;
|
||||
allowRemoteImages: boolean;
|
||||
}
|
||||
|
||||
export function ShipmentCard({ shipment, emailId, allowRemoteImages }: ShipmentCardProps) {
|
||||
const t = useTranslations("email_viewer.structured_mail");
|
||||
const { opts } = useCardFormat();
|
||||
const carrier = shipment.parcels.find((p) => p.carrier)?.carrier;
|
||||
const isParcel = !shipment.orderNumber && !shipment.merchant && shipment.parcels.length > 0;
|
||||
const kindLabel = isParcel ? t("kind_parcel") : t("kind_order");
|
||||
const title = shipment.merchant?.trim() || carrier || kindLabel;
|
||||
const eyebrow = shipment.merchant && carrier ? `${kindLabel} · ${carrier}` : kindLabel;
|
||||
const items = shipmentItems(shipment);
|
||||
const { shown, more, count } = summarizeItems(items);
|
||||
const total = shipment.total ? formatMoney(shipment.total, opts.locale) : undefined;
|
||||
// One Track button per distinct tracking page.
|
||||
const trackLinks = shipment.parcels.map((p) => p.trackingUrl)
|
||||
.filter((l, i, all): l is NonNullable<typeof l> => isWebLink(l) && all.findIndex((o) => o?.url === l!.url) === i);
|
||||
const hasOrderLink = isWebLink(shipment.orderUrl);
|
||||
const cancelled = shipment.status === "cancelled";
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
kind="shipment"
|
||||
name={t("region_shipment")}
|
||||
icon={isParcel ? Truck : ShoppingBag}
|
||||
cancelled={cancelled}
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
pills={shipment.status ? <OrderStatusPill status={shipment.status} /> : undefined}
|
||||
actions={trackLinks.length > 0 || hasOrderLink ? (
|
||||
<>
|
||||
{trackLinks.map((link, i) => (
|
||||
<CardLink key={`${link.url}-${i}`} link={link} label={t("link_track")} icon={Truck} emailId={emailId} />
|
||||
))}
|
||||
<CardLink link={shipment.orderUrl} label={t("link_view_order")} icon={ExternalLink} emailId={emailId} />
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{shipment.orderNumber && (
|
||||
<MetaRow icon={ShoppingBag}>
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-1 min-w-0">
|
||||
<span>{t("order_number")}</span>
|
||||
<CopyValueChip
|
||||
value={shipment.orderNumber}
|
||||
copyLabel={t("copy_order_number", { value: shipment.orderNumber })}
|
||||
copiedMessage={t("copied_order_number")}
|
||||
/>
|
||||
</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
{shown.length > 0 && (
|
||||
<MetaRow icon={Package}>
|
||||
<span className="text-foreground/90">{shown.map((item) => item.name).join(", ")}</span>
|
||||
{more > 0 && <span> {t("items_more", { count: more })}</span>}
|
||||
{allowRemoteImages && <ItemImages items={shown} />}
|
||||
</MetaRow>
|
||||
)}
|
||||
{(items.length > 0 || total) && (
|
||||
<div className="ps-5 text-sm text-muted-foreground">
|
||||
{[items.length > 0 ? t("items_count", { count }) : undefined, total ? t("total", { total }) : undefined]
|
||||
.filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{shipment.parcels.map((parcel, i) => <ParcelRows key={`${parcel.trackingNumber ?? ""}-${i}`} parcel={parcel} showExpected={!ARRIVED_OR_STOPPED.has(shipment.status ?? "processing")} />)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import type { Booking, StructuredCard } from "@/lib/structured-mail/types";
|
||||
import { EventCard, LodgingCard, RentalCarCard, RestaurantCard, TransportCard } from "./booking-cards";
|
||||
import { ShipmentCard } from "./shipment-card";
|
||||
|
||||
export interface StructuredMailCardsProps {
|
||||
/** Resolved cards from useStructuredMail; nothing renders for an empty list. */
|
||||
cards: StructuredCard[];
|
||||
/** The message the cards come from (its id goes to the external-link hook). */
|
||||
email: Pick<Email, "id">;
|
||||
/** Whether remote content may load for this message: product images show only then. */
|
||||
allowRemoteImages: boolean;
|
||||
/** Rendered in each booking card's action row ("Add to calendar"); nothing when undefined. */
|
||||
calendarActions?: (booking: Booking) => ReactNode;
|
||||
}
|
||||
|
||||
/** The booking, parcel and order cards shown above an open message. */
|
||||
export function StructuredMailCards({ cards, email, allowRemoteImages, calendarActions }: StructuredMailCardsProps) {
|
||||
if (cards.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-w-0" data-testid="structured-mail-cards">
|
||||
{cards.map((card, i) => {
|
||||
const props = { emailId: email.id, calendarActions };
|
||||
switch (card.kind) {
|
||||
case "flight":
|
||||
case "train":
|
||||
case "bus":
|
||||
return <TransportCard key={`${card.key}-${i}`} booking={card} {...props} />;
|
||||
case "lodging":
|
||||
return <LodgingCard key={`${card.key}-${i}`} booking={card} {...props} />;
|
||||
case "rentalcar":
|
||||
return <RentalCarCard key={`${card.key}-${i}`} booking={card} {...props} />;
|
||||
case "event":
|
||||
return <EventCard key={`${card.key}-${i}`} booking={card} {...props} />;
|
||||
case "restaurant":
|
||||
return <RestaurantCard key={`${card.key}-${i}`} booking={card} {...props} />;
|
||||
case "shipment":
|
||||
return <ShipmentCard key={`${card.key}-${i}`} shipment={card} emailId={email.id} allowRemoteImages={allowRemoteImages} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { emailDisplayDate } from "@/lib/email-date";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
|
||||
import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { StructuredMailCards } from "./structured-mail/structured-mail-cards";
|
||||
import { useStructuredMail } from "./structured-mail/use-structured-mail";
|
||||
import { attachmentIcon } from "./attachment-chips";
|
||||
import { ExternalSenderChip } from "./external-sender-chip";
|
||||
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
@@ -565,6 +567,8 @@ export function ConversationEmailBody({
|
||||
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
|
||||
const source = blobSource ?? fallbackSource;
|
||||
const { client: blobClient, accountId } = source;
|
||||
// Booking, parcel and order cards; only an expanded message mounts this body.
|
||||
const structuredCards = useStructuredMail(email, source);
|
||||
// Local CID URLs belong to a particular client/owner/message/part list.
|
||||
// Hide the previous set during render, before the new fetch's effect runs.
|
||||
const cidRequest = useMemo(() => ({ blobClient, accountId, emailId: email.id, attachments: email.attachments }),
|
||||
@@ -929,6 +933,15 @@ export function ConversationEmailBody({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{structuredCards.length > 0 && (
|
||||
// Aligned with the body: its own padding on the phone card, the
|
||||
// caller's (className) in Mountain View.
|
||||
<div style={bodyStyle ? { paddingTop: '0.5rem', paddingBottom: '0.25rem' } : { paddingInline: 'var(--density-card-p)', paddingTop: 'var(--density-card-p)' }}>
|
||||
{/* No product images when the mail is shown as plain text. */}
|
||||
<StructuredMailCards cards={structuredCards} email={email} allowRemoteImages={allowExternal && mailViewMode !== 'plain'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="relative" style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
|
||||
{emailContent.isHtml ? (
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatAddress,
|
||||
formatMoment,
|
||||
formatMomentDate,
|
||||
formatMomentTime,
|
||||
formatMoney,
|
||||
isOtherZone,
|
||||
isWebLink,
|
||||
legPlaceLabel,
|
||||
legService,
|
||||
momentOffsetMinutes,
|
||||
nightsBetween,
|
||||
routeTitle,
|
||||
shipmentItems,
|
||||
summarizeItems,
|
||||
} from '../format';
|
||||
import type { Moment, Shipment, TransportBooking, TransportLeg } from '../types';
|
||||
|
||||
const NOW = new Date('2026-10-30T12:00:00Z');
|
||||
const en = { locale: 'en-US', hour12: false, now: NOW } as const;
|
||||
|
||||
// SFO 20:15 −08:00 = 04:15Z the next day; JFK 06:30 −05:00.
|
||||
const SFO_DEP: Moment = { date: '2026-12-03', time: '20:15:00', offsetMinutes: -480, utc: '2026-12-04T04:15:00Z', zone: 'America/Los_Angeles' };
|
||||
const BERLIN: Moment = { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z', zone: 'Europe/Berlin' };
|
||||
// A floating time the airport table resolved: no offset in the mail, utc from the zone.
|
||||
const NRT_RESOLVED: Moment = { date: '2026-12-02', time: '17:40:00', utc: '2026-12-02T08:40:00Z', zone: 'Asia/Tokyo' };
|
||||
|
||||
const originalTz = process.env.TZ;
|
||||
afterEach(() => {
|
||||
process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
describe('wall-clock formatting', () => {
|
||||
for (const tz of ['UTC', 'America/Los_Angeles', 'Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati']) {
|
||||
it(`keeps 20:15 −08:00 at 20:15 on 3 Dec with TZ=${tz}`, () => {
|
||||
process.env.TZ = tz;
|
||||
expect(formatMomentTime(SFO_DEP, en)).toBe('20:15');
|
||||
expect(formatMomentDate(SFO_DEP, en)).toBe('Thu, Dec 3');
|
||||
expect(formatMoment(SFO_DEP, en)).toBe('Thu, Dec 3, 20:15');
|
||||
});
|
||||
}
|
||||
|
||||
it('shows the clock the mail gave whatever the offset', () => {
|
||||
const tokyo: Moment = { date: '2026-12-02', time: '07:05:00', offsetMinutes: 540, utc: '2026-12-01T22:05:00Z' };
|
||||
const kolkata: Moment = { date: '2026-12-02', time: '23:50:00', offsetMinutes: 330, utc: '2026-12-02T18:20:00Z' };
|
||||
expect(formatMoment(tokyo, en)).toBe('Wed, Dec 2, 07:05');
|
||||
expect(formatMoment(kolkata, en)).toBe('Wed, Dec 2, 23:50');
|
||||
});
|
||||
|
||||
it('formats a date-only moment without a time', () => {
|
||||
const d: Moment = { date: '2027-06-01' };
|
||||
expect(formatMomentTime(d, en)).toBeUndefined();
|
||||
// Another year than "now": the year is shown.
|
||||
expect(formatMoment(d, en)).toBe('Tue, Jun 1, 2027');
|
||||
expect(formatMomentDate({ date: '2026-11-03' }, en)).toBe('Tue, Nov 3');
|
||||
});
|
||||
|
||||
it('follows the UI locale and the 12-hour setting', () => {
|
||||
expect(formatMoment(BERLIN, { locale: 'de', hour12: false, now: NOW })).toBe('Di., 1. Dez., 10:15');
|
||||
expect(formatMomentTime(SFO_DEP, { locale: 'en-US', hour12: true, now: NOW })).toBe('8:15 PM');
|
||||
});
|
||||
|
||||
it('accepts HH:mm and falls back to the raw value for a malformed moment', () => {
|
||||
expect(formatMomentTime({ date: '2026-12-01', time: '09:05' }, en)).toBe('09:05');
|
||||
expect(formatMomentDate({ date: 'soon' }, en)).toBe('soon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('local-time hint', () => {
|
||||
it('knows the offset of a moment from the mail or from its resolved zone', () => {
|
||||
expect(momentOffsetMinutes(SFO_DEP)).toBe(-480);
|
||||
expect(momentOffsetMinutes(NRT_RESOLVED)).toBe(540);
|
||||
expect(momentOffsetMinutes({ date: '2026-12-01', time: '10:00:00' })).toBeUndefined();
|
||||
expect(momentOffsetMinutes({ date: '2026-12-01' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks a time whose place keeps another clock than the reader', () => {
|
||||
expect(isOtherZone(SFO_DEP, 'Europe/Berlin')).toBe(true);
|
||||
expect(isOtherZone(SFO_DEP, 'America/Los_Angeles')).toBe(false);
|
||||
expect(isOtherZone(NRT_RESOLVED, 'Asia/Tokyo')).toBe(false);
|
||||
expect(isOtherZone(NRT_RESOLVED, 'Europe/Berlin')).toBe(true);
|
||||
});
|
||||
|
||||
it('compares at the instant, so DST on the reader side counts', () => {
|
||||
// 10:15 +01:00 in December: Berlin is +01:00 then, London +00:00.
|
||||
expect(isOtherZone(BERLIN, 'Europe/Berlin')).toBe(false);
|
||||
expect(isOtherZone(BERLIN, 'Europe/London')).toBe(true);
|
||||
// A +02:00 time in July matches Berlin's summer time.
|
||||
expect(isOtherZone({ date: '2026-07-18', time: '14:00:00', offsetMinutes: 120, utc: '2026-07-18T12:00:00Z' }, 'Europe/Berlin')).toBe(false);
|
||||
expect(isOtherZone(BERLIN, 'x', () => 60)).toBe(false);
|
||||
expect(isOtherZone(BERLIN, 'x', () => 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('never marks a floating or date-only moment', () => {
|
||||
expect(isOtherZone({ date: '2026-12-01', time: '10:00:00', zone: null }, 'Asia/Tokyo')).toBe(false);
|
||||
expect(isOtherZone({ date: '2026-12-01' }, 'Asia/Tokyo')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('card text helpers', () => {
|
||||
const leg = (from: string, to: string, extra: Partial<TransportLeg> = {}): TransportLeg => ({
|
||||
carrier: 'Lufthansa', carrierCode: 'LH', number: '400',
|
||||
departure: { place: { code: from, name: `${from} Airport` }, at: BERLIN },
|
||||
arrival: { place: { code: to }, at: SFO_DEP },
|
||||
seats: [], ...extra,
|
||||
});
|
||||
const flight = (legs: TransportLeg[]): TransportBooking => ({ key: 'k', kind: 'flight', passengers: [], links: {}, source: 'json-ld', legs });
|
||||
|
||||
it('titles a flight with airport codes, chaining connecting legs', () => {
|
||||
expect(routeTitle(flight([leg('FRA', 'JFK')]))).toBe('FRA → JFK');
|
||||
expect(routeTitle(flight([leg('FRA', 'JFK'), leg('JFK', 'FRA')]))).toBe('FRA → JFK → FRA');
|
||||
expect(routeTitle(flight([leg('FRA', 'JFK'), leg('BOS', 'FRA')]))).toBe('FRA → JFK, BOS → FRA');
|
||||
});
|
||||
|
||||
it('titles a train with towns when every stop has one, else station names', () => {
|
||||
const station = (name: string, locality?: string) => ({ name, ...(locality ? { address: { locality } } : {}) });
|
||||
const train = (a: ReturnType<typeof station>, b: ReturnType<typeof station>): TransportBooking => ({
|
||||
key: 'k', kind: 'train', passengers: [], links: {}, source: 'json-ld',
|
||||
legs: [{ departure: { place: a, at: BERLIN }, arrival: { place: b, at: BERLIN }, seats: [] }],
|
||||
});
|
||||
expect(routeTitle(train(station('Hamburg Hbf', 'Hamburg'), station('München Hbf', 'München')))).toBe('Hamburg → München');
|
||||
expect(routeTitle(train(station('Hamburg Hbf', 'Hamburg'), station('München Hbf')))).toBe('Hamburg Hbf → München Hbf');
|
||||
expect(routeTitle(train(station('Berlin Hbf', 'Berlin'), station('Berlin Südkreuz', 'Berlin')))).toBe('Berlin Hbf → Berlin Südkreuz');
|
||||
});
|
||||
|
||||
it('labels airports and services', () => {
|
||||
expect(legPlaceLabel({ code: 'FRA', name: 'Frankfurt Airport' }, 'flight')).toBe('Frankfurt Airport (FRA)');
|
||||
expect(legPlaceLabel({ code: 'FRA' }, 'flight')).toBe('FRA');
|
||||
expect(legPlaceLabel({ code: 'JFK', name: 'New York JFK' }, 'flight')).toBe('New York JFK');
|
||||
expect(legPlaceLabel({ name: 'Hamburg Hbf', code: 'X' }, 'train')).toBe('Hamburg Hbf');
|
||||
expect(legService(leg('FRA', 'JFK'))).toBe('Lufthansa LH 400');
|
||||
expect(legService({ ...leg('FRA', 'JFK'), carrierCode: undefined, carrier: 'Flix', number: 'FLX30' })).toBe('Flix FLX30');
|
||||
});
|
||||
|
||||
it('counts nights by date', () => {
|
||||
expect(nightsBetween({ date: '2026-12-04', time: '15:00:00' }, { date: '2026-12-06', time: '11:00:00' })).toBe(2);
|
||||
expect(nightsBetween({ date: '2027-03-27' }, { date: '2027-03-29' })).toBe(2);
|
||||
expect(nightsBetween({ date: '2027-03-29' }, { date: '2027-03-29' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats money in the UI locale with the card currency', () => {
|
||||
expect(formatMoney({ amount: '129.90', currency: 'EUR' }, 'en-US')).toBe('€129.90');
|
||||
expect(formatMoney({ amount: '1299.00', currency: 'EUR' }, 'de').replace(/\s/g, ' ')).toBe('1.299,00 €');
|
||||
expect(formatMoney({ amount: '25.99', currency: 'USD' }, 'en-US')).toBe('$25.99');
|
||||
expect(formatMoney({ amount: '5.00', currency: 'XX1' }, 'en-US')).toBe('5.00 XX1');
|
||||
});
|
||||
|
||||
it('writes an address on one line without repeating a combined street', () => {
|
||||
expect(formatAddress({ street: 'Marktplatz 1', postalCode: '10178', locality: 'Berlin', country: 'DE' }, 'en')).toBe('Marktplatz 1, 10178 Berlin, Germany');
|
||||
expect(formatAddress({ street: 'Kurfürstendamm 18, 10719 Berlin' }, 'en')).toBe('Kurfürstendamm 18, 10719 Berlin');
|
||||
expect(formatAddress({}, 'en')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists the first three items and counts pieces', () => {
|
||||
const items = [{ name: 'A', quantity: 2 }, { name: 'B' }, { name: 'C' }, { name: 'D' }, { name: 'E', quantity: 3 }];
|
||||
const s = summarizeItems(items);
|
||||
expect(s.shown.map((i) => i.name)).toEqual(['A', 'B', 'C']);
|
||||
expect(s.more).toBe(2);
|
||||
expect(s.count).toBe(8);
|
||||
});
|
||||
|
||||
it('takes item images from the parcel when the order has none', () => {
|
||||
const shipment: Shipment = {
|
||||
kind: 'shipment', key: 'k', items: [{ name: 'Shoes', quantity: 1 }], source: 'json-ld',
|
||||
parcels: [{ items: [{ name: 'Shoes', image: { url: 'https://img.example.com/1.jpg', host: 'img.example.com' } }] }],
|
||||
};
|
||||
expect(shipmentItems(shipment)[0].image?.host).toBe('img.example.com');
|
||||
expect(shipmentItems({ ...shipment, items: [] }).map((i) => i.name)).toEqual(['Shoes']);
|
||||
});
|
||||
|
||||
it('accepts only http(s) links', () => {
|
||||
expect(isWebLink({ url: 'https://dhl.de/x' })).toBe(true);
|
||||
expect(isWebLink({ url: 'http://dhl.de/x' })).toBe(true);
|
||||
expect(isWebLink({ url: 'javascript:alert(1)' })).toBe(false);
|
||||
expect(isWebLink({ url: 'data:text/html,x' })).toBe(false);
|
||||
expect(isWebLink({ url: 'not a url' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting does not depend on the process zone', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TZ = 'Pacific/Honolulu';
|
||||
});
|
||||
it('keeps a Berlin morning a Berlin morning in Honolulu', () => {
|
||||
expect(formatMoment(BERLIN, en)).toBe('Tue, Dec 1, 10:15');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Text for the reader cards. Plain TypeScript, no React.
|
||||
*
|
||||
* A card shows a time the way the mail gave it: the wall clock at the place
|
||||
* (departure airport, hotel, venue), never converted to the reader's zone.
|
||||
* The local date and time are formatted as if they were UTC, with
|
||||
* `timeZone: 'UTC'`, so neither the browser's zone nor DST can move them.
|
||||
*/
|
||||
import { zoneOffsetAt } from './time-zones';
|
||||
import type { Address, Item, Moment, Place, Shipment, TransportBooking, TransportLeg } from './types';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const DAY = 24 * 60 * MINUTE;
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_RE = /^\d{2}:\d{2}(?::\d{2})?$/;
|
||||
|
||||
export interface FormatOptions {
|
||||
/** UI locale ("en", "de", ...). */
|
||||
locale: string;
|
||||
/** true for a 12-hour clock, false for 24 hours, undefined for the locale's own. */
|
||||
hour12?: boolean;
|
||||
/** "Now", to decide whether the year is shown. Defaults to the current date. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/** The moment's local wall clock as a Date whose UTC fields hold it; null when malformed. */
|
||||
export function wallClockDate(m: Moment): Date | null {
|
||||
if (!DATE_RE.test(m.date)) return null;
|
||||
if (m.time !== undefined && !TIME_RE.test(m.time)) return null;
|
||||
const time = m.time ? (m.time.length === 5 ? `${m.time}:00` : m.time) : '00:00:00';
|
||||
const d = new Date(`${m.date}T${time}Z`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function formatter(options: Intl.DateTimeFormatOptions, opts: FormatOptions): Intl.DateTimeFormat {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(opts.locale, { ...options, timeZone: 'UTC' });
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en', { ...options, timeZone: 'UTC' });
|
||||
}
|
||||
}
|
||||
|
||||
function clockOptions(opts: FormatOptions): Intl.DateTimeFormatOptions {
|
||||
if (opts.hour12 === true) return { hour: 'numeric', minute: '2-digit', hour12: true };
|
||||
if (opts.hour12 === false) return { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
|
||||
return { hour: 'numeric', minute: '2-digit' };
|
||||
}
|
||||
|
||||
function dateOptions(m: Moment, opts: FormatOptions): Intl.DateTimeFormatOptions {
|
||||
const year = Number(m.date.slice(0, 4));
|
||||
const currentYear = (opts.now ?? new Date()).getUTCFullYear();
|
||||
return { weekday: 'short', day: 'numeric', month: 'short', ...(year !== currentYear ? { year: 'numeric' } : {}) };
|
||||
}
|
||||
|
||||
/** "Tue, Dec 1" (the year only when it isn't this year). */
|
||||
export function formatMomentDate(m: Moment, opts: FormatOptions): string {
|
||||
const d = wallClockDate(m);
|
||||
return d ? formatter(dateOptions(m, opts), opts).format(d) : m.date;
|
||||
}
|
||||
|
||||
/** "10:15", or undefined for a date-only moment. */
|
||||
export function formatMomentTime(m: Moment, opts: FormatOptions): string | undefined {
|
||||
if (!m.time) return undefined;
|
||||
const d = wallClockDate(m);
|
||||
return d ? formatter(clockOptions(opts), opts).format(d) : m.time.slice(0, 5);
|
||||
}
|
||||
|
||||
/** "Tue, Dec 1, 10:15" in the locale's own order; the date alone for a date-only moment. */
|
||||
export function formatMoment(m: Moment, opts: FormatOptions): string {
|
||||
if (!m.time) return formatMomentDate(m, opts);
|
||||
const d = wallClockDate(m);
|
||||
if (!d) return `${m.date} ${m.time.slice(0, 5)}`;
|
||||
return formatter({ ...dateOptions(m, opts), ...clockOptions(opts) }, opts).format(d);
|
||||
}
|
||||
|
||||
/** Whether two moments fall on the same local date. */
|
||||
export function sameLocalDate(a: Moment, b: Moment): boolean {
|
||||
return a.date === b.date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment's UTC offset in minutes east of UTC: the one the mail gave, or
|
||||
* the one its resolved zone implies. Undefined for a floating time (shown as
|
||||
* written, no zone known) or a date-only moment.
|
||||
*/
|
||||
export function momentOffsetMinutes(m: Moment): number | undefined {
|
||||
if (!m.time) return undefined;
|
||||
if (m.offsetMinutes !== undefined) return m.offsetMinutes;
|
||||
if (!m.utc) return undefined;
|
||||
const wall = wallClockDate(m);
|
||||
const instant = Date.parse(m.utc);
|
||||
if (!wall || Number.isNaN(instant)) return undefined;
|
||||
return Math.round((wall.getTime() - instant) / MINUTE);
|
||||
}
|
||||
|
||||
function instantOf(m: Moment, offset: number): number | undefined {
|
||||
if (m.utc) {
|
||||
const t = Date.parse(m.utc);
|
||||
if (!Number.isNaN(t)) return t;
|
||||
}
|
||||
const wall = wallClockDate(m);
|
||||
return wall ? wall.getTime() - offset * MINUTE : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a timed moment's place keeps a different clock than the reader
|
||||
* at that instant, so the card marks the time as local to the place.
|
||||
* `readerOffsetAt` gives the reader's offset (minutes east of UTC) at an
|
||||
* instant; by default the offset of `readerZone`.
|
||||
*/
|
||||
export function isOtherZone(m: Moment, readerZone: string, readerOffsetAt?: (utcMs: number) => number): boolean {
|
||||
const offset = momentOffsetMinutes(m);
|
||||
if (offset === undefined) return false;
|
||||
const instant = instantOf(m, offset);
|
||||
if (instant === undefined) return false;
|
||||
try {
|
||||
const reader = readerOffsetAt ? readerOffsetAt(instant) : zoneOffsetAt(readerZone, instant);
|
||||
return reader !== offset;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Nights between check-in and check-out dates; undefined when it doesn't add up. */
|
||||
export function nightsBetween(checkin: Moment, checkout: Moment): number | undefined {
|
||||
const a = wallClockDate({ date: checkin.date });
|
||||
const b = wallClockDate({ date: checkout.date });
|
||||
if (!a || !b) return undefined;
|
||||
const nights = Math.round((b.getTime() - a.getTime()) / DAY);
|
||||
return nights > 0 ? nights : undefined;
|
||||
}
|
||||
|
||||
/** "€129.90" in the UI locale; amount and code as given when Intl can't format them. */
|
||||
export function formatMoney(total: { amount: string; currency: string }, locale: string): string {
|
||||
const n = Number(total.amount);
|
||||
if (!Number.isFinite(n)) return `${total.amount} ${total.currency}`;
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency: total.currency }).format(n);
|
||||
} catch {
|
||||
return `${total.amount} ${total.currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function regionName(code: string | undefined, locale: string): string | undefined {
|
||||
if (!code) return undefined;
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** One line: "Marktplatz 1, 10178 Berlin, Germany". */
|
||||
export function formatAddress(address: Address | undefined, locale: string): string | undefined {
|
||||
if (!address) return undefined;
|
||||
const town = [address.postalCode, address.locality].filter(Boolean).join(' ');
|
||||
const country = address.countryName ?? regionName(address.country, locale);
|
||||
const parts = [address.street, town, address.region, country].filter((p): p is string => !!p && p.trim() !== '');
|
||||
// A street given as one string may already hold the town.
|
||||
const unique = parts.filter((p, i) => !parts.slice(0, i).some((q) => q.includes(p)));
|
||||
return unique.length ? unique.join(', ') : undefined;
|
||||
}
|
||||
|
||||
/** A place on one line: "Stadtpark, Hamburg"; the name alone when it already names the town. */
|
||||
export function formatPlace(place: Place | undefined): string | undefined {
|
||||
if (!place) return undefined;
|
||||
const name = place.name?.trim();
|
||||
const town = place.address?.locality?.trim();
|
||||
if (name && town && !name.toLowerCase().includes(town.toLowerCase())) return `${name}, ${town}`;
|
||||
return name || town || place.code;
|
||||
}
|
||||
|
||||
/** An airport or station in a leg row: "Frankfurt Airport (FRA)", "Hamburg Hbf". */
|
||||
export function legPlaceLabel(place: Place, kind: TransportBooking['kind']): string {
|
||||
const name = place.name?.trim() || place.address?.locality?.trim();
|
||||
if (kind === 'flight' && place.code) {
|
||||
if (!name) return place.code;
|
||||
// "Frankfurt" holds the letters FRA; only a code standing on its own counts.
|
||||
const code = place.code.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
const hasCode = new RegExp(`(^|[^A-Z0-9])${code}([^A-Z0-9]|$)`).test(name.toUpperCase());
|
||||
return hasCode ? name : `${name} (${place.code})`;
|
||||
}
|
||||
return name || place.code || '';
|
||||
}
|
||||
|
||||
/** Carrier and number: "Lufthansa LH 400", "Rail Co ICE 577". */
|
||||
export function legService(leg: TransportLeg): string | undefined {
|
||||
const number = leg.carrierCode && leg.number && !leg.number.toUpperCase().startsWith(leg.carrierCode.toUpperCase())
|
||||
? `${leg.carrierCode} ${leg.number}`
|
||||
: leg.number;
|
||||
const parts = [leg.carrier, number].filter((p): p is string => !!p);
|
||||
return parts.length ? parts.join(' ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stops of a route, for its title: airport codes for flights, city or
|
||||
* station names otherwise. Connecting legs form one run (FRA, JFK, FRA);
|
||||
* a leg that doesn't connect starts the next run.
|
||||
*/
|
||||
export function routeRuns(booking: TransportBooking): string[][] {
|
||||
const legs = booking.legs;
|
||||
const towns = legs.flatMap((leg) => [leg.departure.place, leg.arrival?.place]).filter((p): p is Place => !!p)
|
||||
.map((p) => p.address?.locality?.trim());
|
||||
const useTowns = booking.kind !== 'flight' && towns.every(Boolean)
|
||||
&& legs.every((leg) => !leg.arrival || leg.departure.place.address?.locality !== leg.arrival.place.address?.locality);
|
||||
const label = (place: Place): string => {
|
||||
if (booking.kind === 'flight' && place.code) return place.code;
|
||||
if (useTowns && place.address?.locality) return place.address.locality.trim();
|
||||
return place.name?.trim() || place.address?.locality?.trim() || place.code || '?';
|
||||
};
|
||||
const runs: string[][] = [];
|
||||
for (const leg of legs) {
|
||||
const from = label(leg.departure.place);
|
||||
const to = leg.arrival ? label(leg.arrival.place) : undefined;
|
||||
const run = runs[runs.length - 1];
|
||||
if (run && run[run.length - 1] === from) {
|
||||
if (to) run.push(to);
|
||||
} else {
|
||||
runs.push(to ? [from, to] : [from]);
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** The route as plain text, "FRA → JFK → FRA"; runs separated by a comma. */
|
||||
export function routeTitle(booking: TransportBooking): string {
|
||||
return routeRuns(booking).map((run) => run.join(' → ')).join(', ');
|
||||
}
|
||||
|
||||
/** The items a shipment card lists: the order's, else the parcels', with images from either. */
|
||||
export function shipmentItems(shipment: Shipment): Item[] {
|
||||
const parcelItems = shipment.parcels.flatMap((p) => p.items);
|
||||
if (shipment.items.length === 0) {
|
||||
const seen = new Set<string>();
|
||||
return parcelItems.filter((item) => (seen.has(item.name) ? false : (seen.add(item.name), true)));
|
||||
}
|
||||
return shipment.items.map((item) => {
|
||||
if (item.image) return item;
|
||||
const image = parcelItems.find((p) => p.name === item.name && p.image)?.image;
|
||||
return image ? { ...item, image } : item;
|
||||
});
|
||||
}
|
||||
|
||||
/** The first `max` item names, how many more there are, and the number of pieces. */
|
||||
export function summarizeItems(items: Item[], max = 3): { shown: Item[]; more: number; count: number } {
|
||||
const count = items.reduce((sum, item) => sum + (item.quantity && item.quantity > 0 ? item.quantity : 1), 0);
|
||||
return { shown: items.slice(0, max), more: Math.max(0, items.length - max), count };
|
||||
}
|
||||
|
||||
/** Whether a link may be shown and opened: http(s) only, checked again here. */
|
||||
export function isWebLink(link: { url: string } | undefined): boolean {
|
||||
if (!link) return false;
|
||||
try {
|
||||
const { protocol } = new URL(link.url);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "تم نسخ الرمز",
|
||||
"copy_failed": "تعذّر نسخ الرمز"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "رحلة جوية",
|
||||
"kind_train": "قطار",
|
||||
"kind_bus": "حافلة",
|
||||
"kind_lodging": "فندق",
|
||||
"kind_rentalcar": "سيارة مستأجرة",
|
||||
"kind_event": "فعالية",
|
||||
"kind_restaurant": "مطعم",
|
||||
"kind_order": "طلب",
|
||||
"kind_parcel": "طرد",
|
||||
"region_flight": "حجز رحلة جوية",
|
||||
"region_train": "حجز قطار",
|
||||
"region_bus": "حجز حافلة",
|
||||
"region_lodging": "حجز فندق",
|
||||
"region_rentalcar": "حجز سيارة مستأجرة",
|
||||
"region_event": "حجز فعالية",
|
||||
"region_restaurant": "حجز مطعم",
|
||||
"region_shipment": "الطلب والتوصيل",
|
||||
"status_cancelled": "ملغى",
|
||||
"status_pending": "قيد الانتظار",
|
||||
"status_hold": "محجوز مؤقتًا",
|
||||
"order_status_payment_due": "بانتظار الدفع",
|
||||
"order_status_processing": "قيد التجهيز",
|
||||
"order_status_problem": "مشكلة",
|
||||
"order_status_in_transit": "تم الشحن",
|
||||
"order_status_pickup_available": "جاهز للاستلام",
|
||||
"order_status_returned": "مُرتجع",
|
||||
"order_status_delivered": "تم التوصيل",
|
||||
"order_status_cancelled": "ملغى",
|
||||
"reference": "رقم الحجز",
|
||||
"order_number": "رقم الطلب",
|
||||
"tracking_number": "رقم التتبع",
|
||||
"copy_reference": "نسخ رقم الحجز {value}",
|
||||
"copy_order_number": "نسخ رقم الطلب {value}",
|
||||
"copy_tracking_number": "نسخ رقم التتبع {value}",
|
||||
"copied_reference": "تم نسخ رقم الحجز",
|
||||
"copied_order_number": "تم نسخ رقم الطلب",
|
||||
"copied_tracking_number": "تم نسخ رقم التتبع",
|
||||
"copy_failed": "تعذّر النسخ إلى الحافظة",
|
||||
"passengers": "المسافرون",
|
||||
"seats": "المقاعد",
|
||||
"tickets": "التذاكر",
|
||||
"link_check_in": "تسجيل الوصول",
|
||||
"link_manage": "إدارة الحجز",
|
||||
"link_ticket": "التذكرة",
|
||||
"link_track": "تتبّع",
|
||||
"link_view_order": "عرض الطلب",
|
||||
"check_in_label": "تسجيل الوصول",
|
||||
"check_out_label": "تسجيل المغادرة",
|
||||
"from_time": "من الساعة {time}",
|
||||
"until_time": "حتى الساعة {time}",
|
||||
"nights": "{count, plural, zero {# ليلة} one {ليلة واحدة} two {ليلتان} few {# ليالٍ} many {# ليلة} other {# ليلة}}",
|
||||
"pickup": "الاستلام",
|
||||
"dropoff": "الإرجاع",
|
||||
"party_size": "{count, plural, zero {طاولة لـ # شخص} one {طاولة لشخص واحد} two {طاولة لشخصين} few {طاولة لـ # أشخاص} many {طاولة لـ # شخصًا} other {طاولة لـ # شخص}}",
|
||||
"items_more": "و{count} أخرى",
|
||||
"items_count": "{count, plural, zero {# منتج} one {منتج واحد} two {منتجان} few {# منتجات} many {# منتجًا} other {# منتج}}",
|
||||
"total": "الإجمالي {total}",
|
||||
"expected_by": "يصل بحلول {date}",
|
||||
"local_time": "بالتوقيت المحلي",
|
||||
"local_time_title": "بالتوقيت المحلي للمكان، وليس بمنطقتك الزمنية",
|
||||
"route": "{from} إلى {to}"
|
||||
},
|
||||
"mv_position": "{index} من {total}",
|
||||
"plugin_panel_show": "إظهار لوحة الإضافة",
|
||||
"plugin_panel_hide": "إخفاء لوحة الإضافة",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Codi copiat",
|
||||
"copy_failed": "No s'ha pogut copiar el codi"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Vol",
|
||||
"kind_train": "Tren",
|
||||
"kind_bus": "Autobús",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Cotxe de lloguer",
|
||||
"kind_event": "Esdeveniment",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Comanda",
|
||||
"kind_parcel": "Paquet",
|
||||
"region_flight": "Reserva de vol",
|
||||
"region_train": "Reserva de tren",
|
||||
"region_bus": "Reserva d'autobús",
|
||||
"region_lodging": "Reserva d'hotel",
|
||||
"region_rentalcar": "Reserva de cotxe de lloguer",
|
||||
"region_event": "Reserva d'esdeveniment",
|
||||
"region_restaurant": "Reserva de restaurant",
|
||||
"region_shipment": "Comanda i lliurament",
|
||||
"status_cancelled": "Cancel·lada",
|
||||
"status_pending": "Pendent",
|
||||
"status_hold": "En espera",
|
||||
"order_status_payment_due": "Pagament pendent",
|
||||
"order_status_processing": "En preparació",
|
||||
"order_status_problem": "Problema",
|
||||
"order_status_in_transit": "Enviada",
|
||||
"order_status_pickup_available": "A punt per recollir",
|
||||
"order_status_returned": "Retornada",
|
||||
"order_status_delivered": "Lliurada",
|
||||
"order_status_cancelled": "Cancel·lada",
|
||||
"reference": "Codi de reserva",
|
||||
"order_number": "Número de comanda",
|
||||
"tracking_number": "Número de seguiment",
|
||||
"copy_reference": "Copia el codi de reserva {value}",
|
||||
"copy_order_number": "Copia el número de comanda {value}",
|
||||
"copy_tracking_number": "Copia el número de seguiment {value}",
|
||||
"copied_reference": "S'ha copiat el codi de reserva",
|
||||
"copied_order_number": "S'ha copiat el número de comanda",
|
||||
"copied_tracking_number": "S'ha copiat el número de seguiment",
|
||||
"copy_failed": "No s'ha pogut copiar al porta-retalls",
|
||||
"passengers": "Passatgers",
|
||||
"seats": "Seients",
|
||||
"tickets": "Entrades",
|
||||
"link_check_in": "Facturació",
|
||||
"link_manage": "Gestiona la reserva",
|
||||
"link_ticket": "Entrada",
|
||||
"link_track": "Segueix l'enviament",
|
||||
"link_view_order": "Mostra la comanda",
|
||||
"check_in_label": "Entrada",
|
||||
"check_out_label": "Sortida",
|
||||
"from_time": "des de les {time}",
|
||||
"until_time": "fins a les {time}",
|
||||
"nights": "{count, plural, one {# nit} other {# nits}}",
|
||||
"pickup": "Recollida",
|
||||
"dropoff": "Devolució",
|
||||
"party_size": "{count, plural, one {Taula per a # persona} other {Taula per a # persones}}",
|
||||
"items_more": "i {count} més",
|
||||
"items_count": "{count, plural, one {# article} other {# articles}}",
|
||||
"total": "Total {total}",
|
||||
"expected_by": "Arriba abans del {date}",
|
||||
"local_time": "hora local",
|
||||
"local_time_title": "Hora local del lloc, no la de la teva zona horària",
|
||||
"route": "{from} a {to}"
|
||||
},
|
||||
"mv_position": "{index} de {total}",
|
||||
"plugin_panel_show": "Mostra el tauler del connector",
|
||||
"plugin_panel_hide": "Amaga el tauler del connector",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kód zkopírován",
|
||||
"copy_failed": "Kód se nepodařilo zkopírovat"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Let",
|
||||
"kind_train": "Vlak",
|
||||
"kind_bus": "Autobus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Půjčené auto",
|
||||
"kind_event": "Akce",
|
||||
"kind_restaurant": "Restaurace",
|
||||
"kind_order": "Objednávka",
|
||||
"kind_parcel": "Zásilka",
|
||||
"region_flight": "Rezervace letu",
|
||||
"region_train": "Rezervace vlaku",
|
||||
"region_bus": "Rezervace autobusu",
|
||||
"region_lodging": "Rezervace hotelu",
|
||||
"region_rentalcar": "Rezervace půjčeného auta",
|
||||
"region_event": "Rezervace akce",
|
||||
"region_restaurant": "Rezervace stolu",
|
||||
"region_shipment": "Objednávka a doručení",
|
||||
"status_cancelled": "Zrušeno",
|
||||
"status_pending": "Čeká na potvrzení",
|
||||
"status_hold": "Blokováno",
|
||||
"order_status_payment_due": "Čeká na platbu",
|
||||
"order_status_processing": "Zpracovává se",
|
||||
"order_status_problem": "Problém",
|
||||
"order_status_in_transit": "Odesláno",
|
||||
"order_status_pickup_available": "Připraveno k vyzvednutí",
|
||||
"order_status_returned": "Vráceno",
|
||||
"order_status_delivered": "Doručeno",
|
||||
"order_status_cancelled": "Zrušeno",
|
||||
"reference": "Číslo rezervace",
|
||||
"order_number": "Číslo objednávky",
|
||||
"tracking_number": "Číslo zásilky",
|
||||
"copy_reference": "Kopírovat číslo rezervace {value}",
|
||||
"copy_order_number": "Kopírovat číslo objednávky {value}",
|
||||
"copy_tracking_number": "Kopírovat číslo zásilky {value}",
|
||||
"copied_reference": "Číslo rezervace zkopírováno",
|
||||
"copied_order_number": "Číslo objednávky zkopírováno",
|
||||
"copied_tracking_number": "Číslo zásilky zkopírováno",
|
||||
"copy_failed": "Kopírování do schránky se nezdařilo",
|
||||
"passengers": "Cestující",
|
||||
"seats": "Místa",
|
||||
"tickets": "Vstupenky",
|
||||
"link_check_in": "Odbavit se",
|
||||
"link_manage": "Spravovat rezervaci",
|
||||
"link_ticket": "Vstupenka",
|
||||
"link_track": "Sledovat",
|
||||
"link_view_order": "Zobrazit objednávku",
|
||||
"check_in_label": "Příjezd",
|
||||
"check_out_label": "Odjezd",
|
||||
"from_time": "od {time}",
|
||||
"until_time": "do {time}",
|
||||
"nights": "{count, plural, one {# noc} few {# noci} many {# noci} other {# nocí}}",
|
||||
"pickup": "Vyzvednutí",
|
||||
"dropoff": "Vrácení",
|
||||
"party_size": "{count, plural, one {Stůl pro # osobu} few {Stůl pro # osoby} many {Stůl pro # osoby} other {Stůl pro # osob}}",
|
||||
"items_more": "a {count} další",
|
||||
"items_count": "{count, plural, one {# položka} few {# položky} many {# položky} other {# položek}}",
|
||||
"total": "Celkem {total}",
|
||||
"expected_by": "Doručení do {date}",
|
||||
"local_time": "místní čas",
|
||||
"local_time_title": "Místní čas na daném místě, ne ve vašem časovém pásmu",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} z {total}",
|
||||
"plugin_panel_show": "Zobrazit panel pluginu",
|
||||
"plugin_panel_hide": "Skrýt panel pluginu",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kode kopieret",
|
||||
"copy_failed": "Koden kunne ikke kopieres"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Fly",
|
||||
"kind_train": "Tog",
|
||||
"kind_bus": "Bus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Lejebil",
|
||||
"kind_event": "Begivenhed",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Ordre",
|
||||
"kind_parcel": "Pakke",
|
||||
"region_flight": "Flybooking",
|
||||
"region_train": "Togbooking",
|
||||
"region_bus": "Busbooking",
|
||||
"region_lodging": "Hotelbooking",
|
||||
"region_rentalcar": "Lejebilsbooking",
|
||||
"region_event": "Begivenhedsbooking",
|
||||
"region_restaurant": "Bordreservation",
|
||||
"region_shipment": "Ordre og levering",
|
||||
"status_cancelled": "Annulleret",
|
||||
"status_pending": "Afventer",
|
||||
"status_hold": "Reserveret",
|
||||
"order_status_payment_due": "Betaling mangler",
|
||||
"order_status_processing": "Behandles",
|
||||
"order_status_problem": "Problem",
|
||||
"order_status_in_transit": "Afsendt",
|
||||
"order_status_pickup_available": "Klar til afhentning",
|
||||
"order_status_returned": "Returneret",
|
||||
"order_status_delivered": "Leveret",
|
||||
"order_status_cancelled": "Annulleret",
|
||||
"reference": "Bookingnummer",
|
||||
"order_number": "Ordrenummer",
|
||||
"tracking_number": "Sporingsnummer",
|
||||
"copy_reference": "Kopiér bookingnummer {value}",
|
||||
"copy_order_number": "Kopiér ordrenummer {value}",
|
||||
"copy_tracking_number": "Kopiér sporingsnummer {value}",
|
||||
"copied_reference": "Bookingnummer kopieret",
|
||||
"copied_order_number": "Ordrenummer kopieret",
|
||||
"copied_tracking_number": "Sporingsnummer kopieret",
|
||||
"copy_failed": "Kunne ikke kopiere til udklipsholderen",
|
||||
"passengers": "Rejsende",
|
||||
"seats": "Pladser",
|
||||
"tickets": "Billetter",
|
||||
"link_check_in": "Check ind",
|
||||
"link_manage": "Administrér booking",
|
||||
"link_ticket": "Billet",
|
||||
"link_track": "Spor",
|
||||
"link_view_order": "Se ordre",
|
||||
"check_in_label": "Indtjekning",
|
||||
"check_out_label": "Udtjekning",
|
||||
"from_time": "fra kl. {time}",
|
||||
"until_time": "senest kl. {time}",
|
||||
"nights": "{count, plural, one {# nat} other {# nætter}}",
|
||||
"pickup": "Afhentning",
|
||||
"dropoff": "Aflevering",
|
||||
"party_size": "{count, plural, one {Bord til # person} other {Bord til # personer}}",
|
||||
"items_more": "og {count} mere",
|
||||
"items_count": "{count, plural, one {# vare} other {# varer}}",
|
||||
"total": "I alt {total}",
|
||||
"expected_by": "Ankommer senest {date}",
|
||||
"local_time": "lokal tid",
|
||||
"local_time_title": "Lokal tid på stedet, ikke din tidszone",
|
||||
"route": "{from} til {to}"
|
||||
},
|
||||
"mv_position": "{index} af {total}",
|
||||
"plugin_panel_show": "Vis pluginpanel",
|
||||
"plugin_panel_hide": "Skjul pluginpanel",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Code kopiert",
|
||||
"copy_failed": "Code konnte nicht kopiert werden"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Flug",
|
||||
"kind_train": "Zug",
|
||||
"kind_bus": "Bus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Mietwagen",
|
||||
"kind_event": "Veranstaltung",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Bestellung",
|
||||
"kind_parcel": "Paket",
|
||||
"region_flight": "Flugbuchung",
|
||||
"region_train": "Zugbuchung",
|
||||
"region_bus": "Busbuchung",
|
||||
"region_lodging": "Hotelbuchung",
|
||||
"region_rentalcar": "Mietwagenbuchung",
|
||||
"region_event": "Veranstaltungsbuchung",
|
||||
"region_restaurant": "Tischreservierung",
|
||||
"region_shipment": "Bestellung und Lieferung",
|
||||
"status_cancelled": "Storniert",
|
||||
"status_pending": "Ausstehend",
|
||||
"status_hold": "Vorgemerkt",
|
||||
"order_status_payment_due": "Zahlung ausstehend",
|
||||
"order_status_processing": "In Bearbeitung",
|
||||
"order_status_problem": "Problem",
|
||||
"order_status_in_transit": "Versandt",
|
||||
"order_status_pickup_available": "Abholbereit",
|
||||
"order_status_returned": "Zurückgesendet",
|
||||
"order_status_delivered": "Zugestellt",
|
||||
"order_status_cancelled": "Storniert",
|
||||
"reference": "Buchungsnummer",
|
||||
"order_number": "Bestellnummer",
|
||||
"tracking_number": "Sendungsnummer",
|
||||
"copy_reference": "Buchungsnummer {value} kopieren",
|
||||
"copy_order_number": "Bestellnummer {value} kopieren",
|
||||
"copy_tracking_number": "Sendungsnummer {value} kopieren",
|
||||
"copied_reference": "Buchungsnummer kopiert",
|
||||
"copied_order_number": "Bestellnummer kopiert",
|
||||
"copied_tracking_number": "Sendungsnummer kopiert",
|
||||
"copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen",
|
||||
"passengers": "Reisende",
|
||||
"seats": "Sitzplätze",
|
||||
"tickets": "Tickets",
|
||||
"link_check_in": "Einchecken",
|
||||
"link_manage": "Buchung verwalten",
|
||||
"link_ticket": "Ticket",
|
||||
"link_track": "Verfolgen",
|
||||
"link_view_order": "Bestellung ansehen",
|
||||
"check_in_label": "Check-in",
|
||||
"check_out_label": "Check-out",
|
||||
"from_time": "ab {time}",
|
||||
"until_time": "bis {time}",
|
||||
"nights": "{count, plural, one {# Nacht} other {# Nächte}}",
|
||||
"pickup": "Abholung",
|
||||
"dropoff": "Rückgabe",
|
||||
"party_size": "{count, plural, one {Tisch für # Person} other {Tisch für # Personen}}",
|
||||
"items_more": "und {count} weitere",
|
||||
"items_count": "{count, plural, one {# Artikel} other {# Artikel}}",
|
||||
"total": "Gesamt {total}",
|
||||
"expected_by": "Ankunft bis {date}",
|
||||
"local_time": "Ortszeit",
|
||||
"local_time_title": "Uhrzeit vor Ort, nicht in Ihrer Zeitzone",
|
||||
"route": "{from} nach {to}"
|
||||
},
|
||||
"mv_position": "{index} von {total}",
|
||||
"plugin_panel_show": "Plugin-Bereich einblenden",
|
||||
"plugin_panel_hide": "Plugin-Bereich ausblenden",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Code copied",
|
||||
"copy_failed": "Could not copy the code"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Flight",
|
||||
"kind_train": "Train",
|
||||
"kind_bus": "Bus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Rental car",
|
||||
"kind_event": "Event",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Order",
|
||||
"kind_parcel": "Parcel",
|
||||
"region_flight": "Flight booking",
|
||||
"region_train": "Train booking",
|
||||
"region_bus": "Bus booking",
|
||||
"region_lodging": "Hotel booking",
|
||||
"region_rentalcar": "Rental car booking",
|
||||
"region_event": "Event booking",
|
||||
"region_restaurant": "Restaurant reservation",
|
||||
"region_shipment": "Order and delivery",
|
||||
"status_cancelled": "Cancelled",
|
||||
"status_pending": "Pending",
|
||||
"status_hold": "On hold",
|
||||
"order_status_payment_due": "Payment due",
|
||||
"order_status_processing": "Processing",
|
||||
"order_status_problem": "Problem",
|
||||
"order_status_in_transit": "Shipped",
|
||||
"order_status_pickup_available": "Ready for pickup",
|
||||
"order_status_returned": "Returned",
|
||||
"order_status_delivered": "Delivered",
|
||||
"order_status_cancelled": "Cancelled",
|
||||
"reference": "Booking reference",
|
||||
"order_number": "Order number",
|
||||
"tracking_number": "Tracking number",
|
||||
"copy_reference": "Copy booking reference {value}",
|
||||
"copy_order_number": "Copy order number {value}",
|
||||
"copy_tracking_number": "Copy tracking number {value}",
|
||||
"copied_reference": "Booking reference copied",
|
||||
"copied_order_number": "Order number copied",
|
||||
"copied_tracking_number": "Tracking number copied",
|
||||
"copy_failed": "Could not copy to the clipboard",
|
||||
"passengers": "Passengers",
|
||||
"seats": "Seats",
|
||||
"tickets": "Tickets",
|
||||
"link_check_in": "Check in",
|
||||
"link_manage": "Manage booking",
|
||||
"link_ticket": "Ticket",
|
||||
"link_track": "Track",
|
||||
"link_view_order": "View order",
|
||||
"check_in_label": "Check-in",
|
||||
"check_out_label": "Check-out",
|
||||
"from_time": "from {time}",
|
||||
"until_time": "until {time}",
|
||||
"nights": "{count, plural, one {# night} other {# nights}}",
|
||||
"pickup": "Pick-up",
|
||||
"dropoff": "Return",
|
||||
"party_size": "{count, plural, one {Table for #} other {Table for #}}",
|
||||
"items_more": "and {count} more",
|
||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||
"total": "Total {total}",
|
||||
"expected_by": "Arrives by {date}",
|
||||
"local_time": "local time",
|
||||
"local_time_title": "Local time at the place, not your time zone",
|
||||
"route": "{from} to {to}"
|
||||
},
|
||||
"mv_position": "{index} of {total}",
|
||||
"copy": "Copy",
|
||||
"contact_added": "Contact added",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Código copiado",
|
||||
"copy_failed": "No se pudo copiar el código"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Vuelo",
|
||||
"kind_train": "Tren",
|
||||
"kind_bus": "Autobús",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Coche de alquiler",
|
||||
"kind_event": "Evento",
|
||||
"kind_restaurant": "Restaurante",
|
||||
"kind_order": "Pedido",
|
||||
"kind_parcel": "Paquete",
|
||||
"region_flight": "Reserva de vuelo",
|
||||
"region_train": "Reserva de tren",
|
||||
"region_bus": "Reserva de autobús",
|
||||
"region_lodging": "Reserva de hotel",
|
||||
"region_rentalcar": "Reserva de coche de alquiler",
|
||||
"region_event": "Reserva de evento",
|
||||
"region_restaurant": "Reserva de restaurante",
|
||||
"region_shipment": "Pedido y envío",
|
||||
"status_cancelled": "Cancelada",
|
||||
"status_pending": "Pendiente",
|
||||
"status_hold": "En espera",
|
||||
"order_status_payment_due": "Pago pendiente",
|
||||
"order_status_processing": "En preparación",
|
||||
"order_status_problem": "Problema",
|
||||
"order_status_in_transit": "Enviado",
|
||||
"order_status_pickup_available": "Listo para recoger",
|
||||
"order_status_returned": "Devuelto",
|
||||
"order_status_delivered": "Entregado",
|
||||
"order_status_cancelled": "Cancelado",
|
||||
"reference": "Código de reserva",
|
||||
"order_number": "Número de pedido",
|
||||
"tracking_number": "Número de seguimiento",
|
||||
"copy_reference": "Copiar el código de reserva {value}",
|
||||
"copy_order_number": "Copiar el número de pedido {value}",
|
||||
"copy_tracking_number": "Copiar el número de seguimiento {value}",
|
||||
"copied_reference": "Código de reserva copiado",
|
||||
"copied_order_number": "Número de pedido copiado",
|
||||
"copied_tracking_number": "Número de seguimiento copiado",
|
||||
"copy_failed": "No se pudo copiar al portapapeles",
|
||||
"passengers": "Pasajeros",
|
||||
"seats": "Asientos",
|
||||
"tickets": "Entradas",
|
||||
"link_check_in": "Facturar",
|
||||
"link_manage": "Gestionar reserva",
|
||||
"link_ticket": "Entrada",
|
||||
"link_track": "Seguir envío",
|
||||
"link_view_order": "Ver pedido",
|
||||
"check_in_label": "Entrada",
|
||||
"check_out_label": "Salida",
|
||||
"from_time": "desde las {time}",
|
||||
"until_time": "hasta las {time}",
|
||||
"nights": "{count, plural, one {# noche} other {# noches}}",
|
||||
"pickup": "Recogida",
|
||||
"dropoff": "Devolución",
|
||||
"party_size": "{count, plural, one {Mesa para # persona} other {Mesa para # personas}}",
|
||||
"items_more": "y {count} más",
|
||||
"items_count": "{count, plural, one {# artículo} other {# artículos}}",
|
||||
"total": "Total {total}",
|
||||
"expected_by": "Llega antes del {date}",
|
||||
"local_time": "hora local",
|
||||
"local_time_title": "Hora local del lugar, no la de tu zona horaria",
|
||||
"route": "{from} a {to}"
|
||||
},
|
||||
"mv_position": "{index} de {total}",
|
||||
"plugin_panel_show": "Mostrar el panel del complemento",
|
||||
"plugin_panel_hide": "Ocultar el panel del complemento",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kood kopeeritud",
|
||||
"copy_failed": "Koodi kopeerimine ebaõnnestus"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Lend",
|
||||
"kind_train": "Rong",
|
||||
"kind_bus": "Buss",
|
||||
"kind_lodging": "Hotell",
|
||||
"kind_rentalcar": "Rendiauto",
|
||||
"kind_event": "Üritus",
|
||||
"kind_restaurant": "Restoran",
|
||||
"kind_order": "Tellimus",
|
||||
"kind_parcel": "Pakk",
|
||||
"region_flight": "Lennubroneering",
|
||||
"region_train": "Rongibroneering",
|
||||
"region_bus": "Bussibroneering",
|
||||
"region_lodging": "Hotellibroneering",
|
||||
"region_rentalcar": "Rendiauto broneering",
|
||||
"region_event": "Ürituse broneering",
|
||||
"region_restaurant": "Lauabroneering",
|
||||
"region_shipment": "Tellimus ja kohaletoimetamine",
|
||||
"status_cancelled": "Tühistatud",
|
||||
"status_pending": "Ootel",
|
||||
"status_hold": "Reserveeritud",
|
||||
"order_status_payment_due": "Makse ootel",
|
||||
"order_status_processing": "Töötlemisel",
|
||||
"order_status_problem": "Probleem",
|
||||
"order_status_in_transit": "Saadetud",
|
||||
"order_status_pickup_available": "Valmis kättesaamiseks",
|
||||
"order_status_returned": "Tagastatud",
|
||||
"order_status_delivered": "Kohale toimetatud",
|
||||
"order_status_cancelled": "Tühistatud",
|
||||
"reference": "Broneeringu number",
|
||||
"order_number": "Tellimuse number",
|
||||
"tracking_number": "Jälgimisnumber",
|
||||
"copy_reference": "Kopeeri broneeringu number {value}",
|
||||
"copy_order_number": "Kopeeri tellimuse number {value}",
|
||||
"copy_tracking_number": "Kopeeri jälgimisnumber {value}",
|
||||
"copied_reference": "Broneeringu number kopeeritud",
|
||||
"copied_order_number": "Tellimuse number kopeeritud",
|
||||
"copied_tracking_number": "Jälgimisnumber kopeeritud",
|
||||
"copy_failed": "Lõikelauale kopeerimine ebaõnnestus",
|
||||
"passengers": "Reisijad",
|
||||
"seats": "Kohad",
|
||||
"tickets": "Piletid",
|
||||
"link_check_in": "Registreeru",
|
||||
"link_manage": "Halda broneeringut",
|
||||
"link_ticket": "Pilet",
|
||||
"link_track": "Jälgi",
|
||||
"link_view_order": "Vaata tellimust",
|
||||
"check_in_label": "Sisseregistreerimine",
|
||||
"check_out_label": "Väljaregistreerimine",
|
||||
"from_time": "alates {time}",
|
||||
"until_time": "kuni {time}",
|
||||
"nights": "{count, plural, one {# öö} other {# ööd}}",
|
||||
"pickup": "Kättesaamine",
|
||||
"dropoff": "Tagastamine",
|
||||
"party_size": "{count, plural, one {Laud # inimesele} other {Laud # inimesele}}",
|
||||
"items_more": "ja veel {count}",
|
||||
"items_count": "{count, plural, one {# toode} other {# toodet}}",
|
||||
"total": "Kokku {total}",
|
||||
"expected_by": "Saabub hiljemalt {date}",
|
||||
"local_time": "kohalik aeg",
|
||||
"local_time_title": "Kohalik aeg sihtkohas, mitte sinu ajavööndis",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} / {total}",
|
||||
"plugin_panel_show": "Näita pistikprogrammi paneeli",
|
||||
"plugin_panel_hide": "Peida pistikprogrammi paneel",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "کد کپی شد",
|
||||
"copy_failed": "کپی کد ممکن نشد"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "پرواز",
|
||||
"kind_train": "قطار",
|
||||
"kind_bus": "اتوبوس",
|
||||
"kind_lodging": "هتل",
|
||||
"kind_rentalcar": "خودروی اجارهای",
|
||||
"kind_event": "رویداد",
|
||||
"kind_restaurant": "رستوران",
|
||||
"kind_order": "سفارش",
|
||||
"kind_parcel": "مرسوله",
|
||||
"region_flight": "رزرو پرواز",
|
||||
"region_train": "رزرو قطار",
|
||||
"region_bus": "رزرو اتوبوس",
|
||||
"region_lodging": "رزرو هتل",
|
||||
"region_rentalcar": "رزرو خودروی اجارهای",
|
||||
"region_event": "رزرو رویداد",
|
||||
"region_restaurant": "رزرو رستوران",
|
||||
"region_shipment": "سفارش و ارسال",
|
||||
"status_cancelled": "لغو شده",
|
||||
"status_pending": "در انتظار",
|
||||
"status_hold": "نگهداشته شده",
|
||||
"order_status_payment_due": "در انتظار پرداخت",
|
||||
"order_status_processing": "در حال پردازش",
|
||||
"order_status_problem": "مشکل",
|
||||
"order_status_in_transit": "ارسال شده",
|
||||
"order_status_pickup_available": "آماده تحویل",
|
||||
"order_status_returned": "مرجوع شده",
|
||||
"order_status_delivered": "تحویل داده شده",
|
||||
"order_status_cancelled": "لغو شده",
|
||||
"reference": "کد رزرو",
|
||||
"order_number": "شماره سفارش",
|
||||
"tracking_number": "کد رهگیری",
|
||||
"copy_reference": "کپی کد رزرو {value}",
|
||||
"copy_order_number": "کپی شماره سفارش {value}",
|
||||
"copy_tracking_number": "کپی کد رهگیری {value}",
|
||||
"copied_reference": "کد رزرو کپی شد",
|
||||
"copied_order_number": "شماره سفارش کپی شد",
|
||||
"copied_tracking_number": "کد رهگیری کپی شد",
|
||||
"copy_failed": "کپی در کلیپبورد انجام نشد",
|
||||
"passengers": "مسافران",
|
||||
"seats": "صندلیها",
|
||||
"tickets": "بلیتها",
|
||||
"link_check_in": "پذیرش آنلاین",
|
||||
"link_manage": "مدیریت رزرو",
|
||||
"link_ticket": "بلیت",
|
||||
"link_track": "رهگیری",
|
||||
"link_view_order": "مشاهده سفارش",
|
||||
"check_in_label": "ورود",
|
||||
"check_out_label": "خروج",
|
||||
"from_time": "از ساعت {time}",
|
||||
"until_time": "تا ساعت {time}",
|
||||
"nights": "{count, plural, one {# شب} other {# شب}}",
|
||||
"pickup": "تحویل گرفتن",
|
||||
"dropoff": "بازگرداندن",
|
||||
"party_size": "{count, plural, one {میز برای # نفر} other {میز برای # نفر}}",
|
||||
"items_more": "و {count} مورد دیگر",
|
||||
"items_count": "{count, plural, one {# کالا} other {# کالا}}",
|
||||
"total": "مجموع {total}",
|
||||
"expected_by": "رسیدن تا {date}",
|
||||
"local_time": "به وقت محلی",
|
||||
"local_time_title": "به وقت محلی همان مکان، نه منطقه زمانی شما",
|
||||
"route": "{from} به {to}"
|
||||
},
|
||||
"mv_position": "{index} از {total}",
|
||||
"plugin_panel_show": "نمایش پنل افزونه",
|
||||
"plugin_panel_hide": "پنهان کردن پنل افزونه",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Code copié",
|
||||
"copy_failed": "Impossible de copier le code"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Vol",
|
||||
"kind_train": "Train",
|
||||
"kind_bus": "Bus",
|
||||
"kind_lodging": "Hôtel",
|
||||
"kind_rentalcar": "Voiture de location",
|
||||
"kind_event": "Événement",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Commande",
|
||||
"kind_parcel": "Colis",
|
||||
"region_flight": "Réservation de vol",
|
||||
"region_train": "Réservation de train",
|
||||
"region_bus": "Réservation de bus",
|
||||
"region_lodging": "Réservation d'hôtel",
|
||||
"region_rentalcar": "Location de voiture",
|
||||
"region_event": "Réservation d'événement",
|
||||
"region_restaurant": "Réservation de restaurant",
|
||||
"region_shipment": "Commande et livraison",
|
||||
"status_cancelled": "Annulée",
|
||||
"status_pending": "En attente",
|
||||
"status_hold": "Option",
|
||||
"order_status_payment_due": "Paiement en attente",
|
||||
"order_status_processing": "En préparation",
|
||||
"order_status_problem": "Problème",
|
||||
"order_status_in_transit": "Expédiée",
|
||||
"order_status_pickup_available": "Prête à retirer",
|
||||
"order_status_returned": "Retournée",
|
||||
"order_status_delivered": "Livrée",
|
||||
"order_status_cancelled": "Annulée",
|
||||
"reference": "Référence de réservation",
|
||||
"order_number": "Numéro de commande",
|
||||
"tracking_number": "Numéro de suivi",
|
||||
"copy_reference": "Copier la référence de réservation {value}",
|
||||
"copy_order_number": "Copier le numéro de commande {value}",
|
||||
"copy_tracking_number": "Copier le numéro de suivi {value}",
|
||||
"copied_reference": "Référence de réservation copiée",
|
||||
"copied_order_number": "Numéro de commande copié",
|
||||
"copied_tracking_number": "Numéro de suivi copié",
|
||||
"copy_failed": "Impossible de copier dans le presse-papiers",
|
||||
"passengers": "Passagers",
|
||||
"seats": "Places",
|
||||
"tickets": "Billets",
|
||||
"link_check_in": "Enregistrement",
|
||||
"link_manage": "Gérer la réservation",
|
||||
"link_ticket": "Billet",
|
||||
"link_track": "Suivre",
|
||||
"link_view_order": "Voir la commande",
|
||||
"check_in_label": "Arrivée",
|
||||
"check_out_label": "Départ",
|
||||
"from_time": "à partir de {time}",
|
||||
"until_time": "jusqu'à {time}",
|
||||
"nights": "{count, plural, one {# nuit} other {# nuits}}",
|
||||
"pickup": "Prise en charge",
|
||||
"dropoff": "Restitution",
|
||||
"party_size": "{count, plural, one {Table pour # personne} other {Table pour # personnes}}",
|
||||
"items_more": "et {count} de plus",
|
||||
"items_count": "{count, plural, one {# article} other {# articles}}",
|
||||
"total": "Total {total}",
|
||||
"expected_by": "Livraison prévue d'ici le {date}",
|
||||
"local_time": "heure locale",
|
||||
"local_time_title": "Heure locale du lieu, pas celle de votre fuseau horaire",
|
||||
"route": "{from} vers {to}"
|
||||
},
|
||||
"mv_position": "{index} sur {total}",
|
||||
"plugin_panel_show": "Afficher le panneau du plugin",
|
||||
"plugin_panel_hide": "Masquer le panneau du plugin",
|
||||
|
||||
@@ -740,6 +740,69 @@
|
||||
"copied": "הקוד הועתק",
|
||||
"copy_failed": "לא ניתן להעתיק את הקוד"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "טיסה",
|
||||
"kind_train": "רכבת",
|
||||
"kind_bus": "אוטובוס",
|
||||
"kind_lodging": "מלון",
|
||||
"kind_rentalcar": "רכב שכור",
|
||||
"kind_event": "אירוע",
|
||||
"kind_restaurant": "מסעדה",
|
||||
"kind_order": "הזמנה",
|
||||
"kind_parcel": "חבילה",
|
||||
"region_flight": "הזמנת טיסה",
|
||||
"region_train": "הזמנת רכבת",
|
||||
"region_bus": "הזמנת אוטובוס",
|
||||
"region_lodging": "הזמנת מלון",
|
||||
"region_rentalcar": "הזמנת רכב שכור",
|
||||
"region_event": "הזמנה לאירוע",
|
||||
"region_restaurant": "הזמנת מקום במסעדה",
|
||||
"region_shipment": "הזמנה ומשלוח",
|
||||
"status_cancelled": "בוטלה",
|
||||
"status_pending": "ממתינה",
|
||||
"status_hold": "בהמתנה",
|
||||
"order_status_payment_due": "ממתינה לתשלום",
|
||||
"order_status_processing": "בטיפול",
|
||||
"order_status_problem": "בעיה",
|
||||
"order_status_in_transit": "נשלחה",
|
||||
"order_status_pickup_available": "מוכנה לאיסוף",
|
||||
"order_status_returned": "הוחזרה",
|
||||
"order_status_delivered": "נמסרה",
|
||||
"order_status_cancelled": "בוטלה",
|
||||
"reference": "מספר הזמנה",
|
||||
"order_number": "מספר הזמנה",
|
||||
"tracking_number": "מספר מעקב",
|
||||
"copy_reference": "העתקת מספר ההזמנה {value}",
|
||||
"copy_order_number": "העתקת מספר ההזמנה {value}",
|
||||
"copy_tracking_number": "העתקת מספר המעקב {value}",
|
||||
"copied_reference": "מספר ההזמנה הועתק",
|
||||
"copied_order_number": "מספר ההזמנה הועתק",
|
||||
"copied_tracking_number": "מספר המעקב הועתק",
|
||||
"copy_failed": "לא ניתן להעתיק ללוח",
|
||||
"passengers": "נוסעים",
|
||||
"seats": "מושבים",
|
||||
"tickets": "כרטיסים",
|
||||
"link_check_in": "צ'ק-אין",
|
||||
"link_manage": "ניהול ההזמנה",
|
||||
"link_ticket": "כרטיס",
|
||||
"link_track": "מעקב",
|
||||
"link_view_order": "הצגת ההזמנה",
|
||||
"check_in_label": "צ'ק-אין",
|
||||
"check_out_label": "צ'ק-אאוט",
|
||||
"from_time": "החל מ-{time}",
|
||||
"until_time": "עד {time}",
|
||||
"nights": "{count, plural, one {לילה אחד} two {שני לילות} other {# לילות}}",
|
||||
"pickup": "איסוף",
|
||||
"dropoff": "החזרה",
|
||||
"party_size": "{count, plural, one {שולחן לאדם אחד} other {שולחן ל-# אנשים}}",
|
||||
"items_more": "ועוד {count}",
|
||||
"items_count": "{count, plural, one {פריט אחד} other {# פריטים}}",
|
||||
"total": "סה\"כ {total}",
|
||||
"expected_by": "יגיע עד {date}",
|
||||
"local_time": "שעון מקומי",
|
||||
"local_time_title": "השעה המקומית במקום, לא באזור הזמן שלך",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} מתוך {total}",
|
||||
"plugin_panel_show": "הצגת חלונית התוסף",
|
||||
"plugin_panel_hide": "הסתרת חלונית התוסף",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kód másolva",
|
||||
"copy_failed": "Nem sikerült másolni a kódot"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Repülőjárat",
|
||||
"kind_train": "Vonat",
|
||||
"kind_bus": "Busz",
|
||||
"kind_lodging": "Szálloda",
|
||||
"kind_rentalcar": "Bérautó",
|
||||
"kind_event": "Esemény",
|
||||
"kind_restaurant": "Étterem",
|
||||
"kind_order": "Rendelés",
|
||||
"kind_parcel": "Csomag",
|
||||
"region_flight": "Repülőjegy-foglalás",
|
||||
"region_train": "Vonatjegy-foglalás",
|
||||
"region_bus": "Buszjegy-foglalás",
|
||||
"region_lodging": "Szállásfoglalás",
|
||||
"region_rentalcar": "Bérautó-foglalás",
|
||||
"region_event": "Eseményfoglalás",
|
||||
"region_restaurant": "Asztalfoglalás",
|
||||
"region_shipment": "Rendelés és szállítás",
|
||||
"status_cancelled": "Lemondva",
|
||||
"status_pending": "Függőben",
|
||||
"status_hold": "Előfoglalva",
|
||||
"order_status_payment_due": "Fizetésre vár",
|
||||
"order_status_processing": "Feldolgozás alatt",
|
||||
"order_status_problem": "Probléma",
|
||||
"order_status_in_transit": "Feladva",
|
||||
"order_status_pickup_available": "Átvehető",
|
||||
"order_status_returned": "Visszaküldve",
|
||||
"order_status_delivered": "Kézbesítve",
|
||||
"order_status_cancelled": "Lemondva",
|
||||
"reference": "Foglalási szám",
|
||||
"order_number": "Rendelésszám",
|
||||
"tracking_number": "Követési szám",
|
||||
"copy_reference": "{value} foglalási szám másolása",
|
||||
"copy_order_number": "{value} rendelésszám másolása",
|
||||
"copy_tracking_number": "{value} követési szám másolása",
|
||||
"copied_reference": "Foglalási szám másolva",
|
||||
"copied_order_number": "Rendelésszám másolva",
|
||||
"copied_tracking_number": "Követési szám másolva",
|
||||
"copy_failed": "Nem sikerült a vágólapra másolni",
|
||||
"passengers": "Utasok",
|
||||
"seats": "Ülőhelyek",
|
||||
"tickets": "Jegyek",
|
||||
"link_check_in": "Utasfelvétel",
|
||||
"link_manage": "Foglalás kezelése",
|
||||
"link_ticket": "Jegy",
|
||||
"link_track": "Követés",
|
||||
"link_view_order": "Rendelés megtekintése",
|
||||
"check_in_label": "Bejelentkezés",
|
||||
"check_out_label": "Kijelentkezés",
|
||||
"from_time": "{time}-tól",
|
||||
"until_time": "{time}-ig",
|
||||
"nights": "{count, plural, one {# éjszaka} other {# éjszaka}}",
|
||||
"pickup": "Átvétel",
|
||||
"dropoff": "Leadás",
|
||||
"party_size": "{count, plural, one {Asztal # főre} other {Asztal # főre}}",
|
||||
"items_more": "és még {count}",
|
||||
"items_count": "{count, plural, one {# tétel} other {# tétel}}",
|
||||
"total": "Összesen {total}",
|
||||
"expected_by": "Érkezés legkésőbb: {date}",
|
||||
"local_time": "helyi idő",
|
||||
"local_time_title": "Az adott hely helyi ideje, nem a saját időzónád",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} / {total}",
|
||||
"plugin_panel_show": "Bővítménypanel megjelenítése",
|
||||
"plugin_panel_hide": "Bővítménypanel elrejtése",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Codice copiato",
|
||||
"copy_failed": "Impossibile copiare il codice"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Volo",
|
||||
"kind_train": "Treno",
|
||||
"kind_bus": "Autobus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Auto a noleggio",
|
||||
"kind_event": "Evento",
|
||||
"kind_restaurant": "Ristorante",
|
||||
"kind_order": "Ordine",
|
||||
"kind_parcel": "Pacco",
|
||||
"region_flight": "Prenotazione volo",
|
||||
"region_train": "Prenotazione treno",
|
||||
"region_bus": "Prenotazione autobus",
|
||||
"region_lodging": "Prenotazione hotel",
|
||||
"region_rentalcar": "Noleggio auto",
|
||||
"region_event": "Prenotazione evento",
|
||||
"region_restaurant": "Prenotazione ristorante",
|
||||
"region_shipment": "Ordine e consegna",
|
||||
"status_cancelled": "Cancellata",
|
||||
"status_pending": "In attesa",
|
||||
"status_hold": "Opzionata",
|
||||
"order_status_payment_due": "Pagamento in sospeso",
|
||||
"order_status_processing": "In lavorazione",
|
||||
"order_status_problem": "Problema",
|
||||
"order_status_in_transit": "Spedito",
|
||||
"order_status_pickup_available": "Pronto per il ritiro",
|
||||
"order_status_returned": "Reso",
|
||||
"order_status_delivered": "Consegnato",
|
||||
"order_status_cancelled": "Annullato",
|
||||
"reference": "Codice di prenotazione",
|
||||
"order_number": "Numero d'ordine",
|
||||
"tracking_number": "Numero di tracciamento",
|
||||
"copy_reference": "Copia il codice di prenotazione {value}",
|
||||
"copy_order_number": "Copia il numero d'ordine {value}",
|
||||
"copy_tracking_number": "Copia il numero di tracciamento {value}",
|
||||
"copied_reference": "Codice di prenotazione copiato",
|
||||
"copied_order_number": "Numero d'ordine copiato",
|
||||
"copied_tracking_number": "Numero di tracciamento copiato",
|
||||
"copy_failed": "Impossibile copiare negli appunti",
|
||||
"passengers": "Passeggeri",
|
||||
"seats": "Posti",
|
||||
"tickets": "Biglietti",
|
||||
"link_check_in": "Check-in",
|
||||
"link_manage": "Gestisci prenotazione",
|
||||
"link_ticket": "Biglietto",
|
||||
"link_track": "Traccia",
|
||||
"link_view_order": "Vedi ordine",
|
||||
"check_in_label": "Check-in",
|
||||
"check_out_label": "Check-out",
|
||||
"from_time": "dalle {time}",
|
||||
"until_time": "entro le {time}",
|
||||
"nights": "{count, plural, one {# notte} other {# notti}}",
|
||||
"pickup": "Ritiro",
|
||||
"dropoff": "Riconsegna",
|
||||
"party_size": "{count, plural, one {Tavolo per # persona} other {Tavolo per # persone}}",
|
||||
"items_more": "e altri {count}",
|
||||
"items_count": "{count, plural, one {# articolo} other {# articoli}}",
|
||||
"total": "Totale {total}",
|
||||
"expected_by": "Arriva entro {date}",
|
||||
"local_time": "ora locale",
|
||||
"local_time_title": "Ora locale del luogo, non del tuo fuso orario",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} di {total}",
|
||||
"plugin_panel_show": "Mostra il pannello del plugin",
|
||||
"plugin_panel_hide": "Nascondi il pannello del plugin",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "コードをコピーしました",
|
||||
"copy_failed": "コードをコピーできませんでした"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "フライト",
|
||||
"kind_train": "列車",
|
||||
"kind_bus": "バス",
|
||||
"kind_lodging": "ホテル",
|
||||
"kind_rentalcar": "レンタカー",
|
||||
"kind_event": "イベント",
|
||||
"kind_restaurant": "レストラン",
|
||||
"kind_order": "注文",
|
||||
"kind_parcel": "荷物",
|
||||
"region_flight": "フライトの予約",
|
||||
"region_train": "列車の予約",
|
||||
"region_bus": "バスの予約",
|
||||
"region_lodging": "ホテルの予約",
|
||||
"region_rentalcar": "レンタカーの予約",
|
||||
"region_event": "イベントの予約",
|
||||
"region_restaurant": "レストランの予約",
|
||||
"region_shipment": "注文と配送",
|
||||
"status_cancelled": "キャンセル済み",
|
||||
"status_pending": "保留中",
|
||||
"status_hold": "仮押さえ",
|
||||
"order_status_payment_due": "支払い待ち",
|
||||
"order_status_processing": "準備中",
|
||||
"order_status_problem": "問題あり",
|
||||
"order_status_in_transit": "発送済み",
|
||||
"order_status_pickup_available": "受け取り可能",
|
||||
"order_status_returned": "返品済み",
|
||||
"order_status_delivered": "配達済み",
|
||||
"order_status_cancelled": "キャンセル済み",
|
||||
"reference": "予約番号",
|
||||
"order_number": "注文番号",
|
||||
"tracking_number": "追跡番号",
|
||||
"copy_reference": "予約番号 {value} をコピー",
|
||||
"copy_order_number": "注文番号 {value} をコピー",
|
||||
"copy_tracking_number": "追跡番号 {value} をコピー",
|
||||
"copied_reference": "予約番号をコピーしました",
|
||||
"copied_order_number": "注文番号をコピーしました",
|
||||
"copied_tracking_number": "追跡番号をコピーしました",
|
||||
"copy_failed": "クリップボードにコピーできませんでした",
|
||||
"passengers": "搭乗者",
|
||||
"seats": "座席",
|
||||
"tickets": "チケット",
|
||||
"link_check_in": "チェックイン",
|
||||
"link_manage": "予約を管理",
|
||||
"link_ticket": "チケット",
|
||||
"link_track": "追跡",
|
||||
"link_view_order": "注文を表示",
|
||||
"check_in_label": "チェックイン",
|
||||
"check_out_label": "チェックアウト",
|
||||
"from_time": "{time}以降",
|
||||
"until_time": "{time}まで",
|
||||
"nights": "{count, plural, other {#泊}}",
|
||||
"pickup": "貸出",
|
||||
"dropoff": "返却",
|
||||
"party_size": "{count, plural, other {#名様}}",
|
||||
"items_more": "ほか{count}点",
|
||||
"items_count": "{count, plural, other {#点}}",
|
||||
"total": "合計 {total}",
|
||||
"expected_by": "{date}までにお届け予定",
|
||||
"local_time": "現地時間",
|
||||
"local_time_title": "あなたのタイムゾーンではなく、その場所の現地時間です",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} / {total}",
|
||||
"plugin_panel_show": "プラグインパネルを表示",
|
||||
"plugin_panel_hide": "プラグインパネルを隠す",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "코드를 복사했습니다",
|
||||
"copy_failed": "코드를 복사할 수 없습니다"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "항공편",
|
||||
"kind_train": "기차",
|
||||
"kind_bus": "버스",
|
||||
"kind_lodging": "호텔",
|
||||
"kind_rentalcar": "렌터카",
|
||||
"kind_event": "이벤트",
|
||||
"kind_restaurant": "레스토랑",
|
||||
"kind_order": "주문",
|
||||
"kind_parcel": "택배",
|
||||
"region_flight": "항공편 예약",
|
||||
"region_train": "기차 예약",
|
||||
"region_bus": "버스 예약",
|
||||
"region_lodging": "호텔 예약",
|
||||
"region_rentalcar": "렌터카 예약",
|
||||
"region_event": "이벤트 예약",
|
||||
"region_restaurant": "레스토랑 예약",
|
||||
"region_shipment": "주문 및 배송",
|
||||
"status_cancelled": "취소됨",
|
||||
"status_pending": "대기 중",
|
||||
"status_hold": "임시 예약",
|
||||
"order_status_payment_due": "결제 대기",
|
||||
"order_status_processing": "처리 중",
|
||||
"order_status_problem": "문제 발생",
|
||||
"order_status_in_transit": "발송됨",
|
||||
"order_status_pickup_available": "수령 가능",
|
||||
"order_status_returned": "반품됨",
|
||||
"order_status_delivered": "배송 완료",
|
||||
"order_status_cancelled": "취소됨",
|
||||
"reference": "예약 번호",
|
||||
"order_number": "주문 번호",
|
||||
"tracking_number": "운송장 번호",
|
||||
"copy_reference": "예약 번호 {value} 복사",
|
||||
"copy_order_number": "주문 번호 {value} 복사",
|
||||
"copy_tracking_number": "운송장 번호 {value} 복사",
|
||||
"copied_reference": "예약 번호를 복사했습니다",
|
||||
"copied_order_number": "주문 번호를 복사했습니다",
|
||||
"copied_tracking_number": "운송장 번호를 복사했습니다",
|
||||
"copy_failed": "클립보드에 복사하지 못했습니다",
|
||||
"passengers": "탑승객",
|
||||
"seats": "좌석",
|
||||
"tickets": "티켓",
|
||||
"link_check_in": "체크인",
|
||||
"link_manage": "예약 관리",
|
||||
"link_ticket": "티켓",
|
||||
"link_track": "배송 조회",
|
||||
"link_view_order": "주문 보기",
|
||||
"check_in_label": "체크인",
|
||||
"check_out_label": "체크아웃",
|
||||
"from_time": "{time}부터",
|
||||
"until_time": "{time}까지",
|
||||
"nights": "{count, plural, other {#박}}",
|
||||
"pickup": "인수",
|
||||
"dropoff": "반납",
|
||||
"party_size": "{count, plural, other {#명 테이블}}",
|
||||
"items_more": "외 {count}개",
|
||||
"items_count": "{count, plural, other {상품 #개}}",
|
||||
"total": "합계 {total}",
|
||||
"expected_by": "{date}까지 도착 예정",
|
||||
"local_time": "현지 시간",
|
||||
"local_time_title": "사용자의 시간대가 아닌 해당 장소의 현지 시간입니다",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} / {total}",
|
||||
"plugin_panel_show": "플러그인 패널 표시",
|
||||
"plugin_panel_hide": "플러그인 패널 숨기기",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kods nokopēts",
|
||||
"copy_failed": "Neizdevās nokopēt kodu"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Lidojums",
|
||||
"kind_train": "Vilciens",
|
||||
"kind_bus": "Autobuss",
|
||||
"kind_lodging": "Viesnīca",
|
||||
"kind_rentalcar": "Nomas auto",
|
||||
"kind_event": "Pasākums",
|
||||
"kind_restaurant": "Restorāns",
|
||||
"kind_order": "Pasūtījums",
|
||||
"kind_parcel": "Sūtījums",
|
||||
"region_flight": "Lidojuma rezervācija",
|
||||
"region_train": "Vilciena rezervācija",
|
||||
"region_bus": "Autobusa rezervācija",
|
||||
"region_lodging": "Viesnīcas rezervācija",
|
||||
"region_rentalcar": "Nomas auto rezervācija",
|
||||
"region_event": "Pasākuma rezervācija",
|
||||
"region_restaurant": "Galdiņa rezervācija",
|
||||
"region_shipment": "Pasūtījums un piegāde",
|
||||
"status_cancelled": "Atcelts",
|
||||
"status_pending": "Gaida apstiprinājumu",
|
||||
"status_hold": "Rezervēts",
|
||||
"order_status_payment_due": "Gaida apmaksu",
|
||||
"order_status_processing": "Tiek apstrādāts",
|
||||
"order_status_problem": "Problēma",
|
||||
"order_status_in_transit": "Nosūtīts",
|
||||
"order_status_pickup_available": "Gatavs saņemšanai",
|
||||
"order_status_returned": "Atgriezts",
|
||||
"order_status_delivered": "Piegādāts",
|
||||
"order_status_cancelled": "Atcelts",
|
||||
"reference": "Rezervācijas numurs",
|
||||
"order_number": "Pasūtījuma numurs",
|
||||
"tracking_number": "Izsekošanas numurs",
|
||||
"copy_reference": "Kopēt rezervācijas numuru {value}",
|
||||
"copy_order_number": "Kopēt pasūtījuma numuru {value}",
|
||||
"copy_tracking_number": "Kopēt izsekošanas numuru {value}",
|
||||
"copied_reference": "Rezervācijas numurs nokopēts",
|
||||
"copied_order_number": "Pasūtījuma numurs nokopēts",
|
||||
"copied_tracking_number": "Izsekošanas numurs nokopēts",
|
||||
"copy_failed": "Neizdevās nokopēt starpliktuvē",
|
||||
"passengers": "Pasažieri",
|
||||
"seats": "Vietas",
|
||||
"tickets": "Biļetes",
|
||||
"link_check_in": "Reģistrēties",
|
||||
"link_manage": "Pārvaldīt rezervāciju",
|
||||
"link_ticket": "Biļete",
|
||||
"link_track": "Izsekot",
|
||||
"link_view_order": "Skatīt pasūtījumu",
|
||||
"check_in_label": "Reģistrēšanās",
|
||||
"check_out_label": "Izrakstīšanās",
|
||||
"from_time": "no {time}",
|
||||
"until_time": "līdz {time}",
|
||||
"nights": "{count, plural, zero {# naktis} one {# nakts} other {# naktis}}",
|
||||
"pickup": "Saņemšana",
|
||||
"dropoff": "Nodošana",
|
||||
"party_size": "{count, plural, zero {Galdiņš # personām} one {Galdiņš # personai} other {Galdiņš # personām}}",
|
||||
"items_more": "un vēl {count}",
|
||||
"items_count": "{count, plural, zero {# preču} one {# prece} other {# preces}}",
|
||||
"total": "Kopā {total}",
|
||||
"expected_by": "Piegāde līdz {date}",
|
||||
"local_time": "vietējais laiks",
|
||||
"local_time_title": "Vietējais laiks attiecīgajā vietā, nevis jūsu laika joslā",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} no {total}",
|
||||
"plugin_panel_show": "Rādīt spraudņa paneli",
|
||||
"plugin_panel_hide": "Slēpt spraudņa paneli",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Код хуулагдлаа",
|
||||
"copy_failed": "Кодыг хуулж чадсангүй"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Нислэг",
|
||||
"kind_train": "Галт тэрэг",
|
||||
"kind_bus": "Автобус",
|
||||
"kind_lodging": "Зочид буудал",
|
||||
"kind_rentalcar": "Түрээсийн машин",
|
||||
"kind_event": "Арга хэмжээ",
|
||||
"kind_restaurant": "Ресторан",
|
||||
"kind_order": "Захиалга",
|
||||
"kind_parcel": "Илгээмж",
|
||||
"region_flight": "Нислэгийн захиалга",
|
||||
"region_train": "Галт тэрэгний захиалга",
|
||||
"region_bus": "Автобусны захиалга",
|
||||
"region_lodging": "Зочид буудлын захиалга",
|
||||
"region_rentalcar": "Түрээсийн машины захиалга",
|
||||
"region_event": "Арга хэмжээний захиалга",
|
||||
"region_restaurant": "Ширээний захиалга",
|
||||
"region_shipment": "Захиалга ба хүргэлт",
|
||||
"status_cancelled": "Цуцлагдсан",
|
||||
"status_pending": "Хүлээгдэж байна",
|
||||
"status_hold": "Түр барьсан",
|
||||
"order_status_payment_due": "Төлбөр хүлээгдэж байна",
|
||||
"order_status_processing": "Боловсруулж байна",
|
||||
"order_status_problem": "Асуудалтай",
|
||||
"order_status_in_transit": "Илгээсэн",
|
||||
"order_status_pickup_available": "Авахад бэлэн",
|
||||
"order_status_returned": "Буцаасан",
|
||||
"order_status_delivered": "Хүргэгдсэн",
|
||||
"order_status_cancelled": "Цуцлагдсан",
|
||||
"reference": "Захиалгын код",
|
||||
"order_number": "Захиалгын дугаар",
|
||||
"tracking_number": "Мөрдөх дугаар",
|
||||
"copy_reference": "Захиалгын код {value}-г хуулах",
|
||||
"copy_order_number": "Захиалгын дугаар {value}-г хуулах",
|
||||
"copy_tracking_number": "Мөрдөх дугаар {value}-г хуулах",
|
||||
"copied_reference": "Захиалгын кодыг хуулсан",
|
||||
"copied_order_number": "Захиалгын дугаарыг хуулсан",
|
||||
"copied_tracking_number": "Мөрдөх дугаарыг хуулсан",
|
||||
"copy_failed": "Санах ой руу хуулж чадсангүй",
|
||||
"passengers": "Зорчигчид",
|
||||
"seats": "Суудал",
|
||||
"tickets": "Тасалбар",
|
||||
"link_check_in": "Бүртгүүлэх",
|
||||
"link_manage": "Захиалга удирдах",
|
||||
"link_ticket": "Тасалбар",
|
||||
"link_track": "Мөрдөх",
|
||||
"link_view_order": "Захиалга харах",
|
||||
"check_in_label": "Буудалд орох",
|
||||
"check_out_label": "Буудлаас гарах",
|
||||
"from_time": "{time}-аас",
|
||||
"until_time": "{time} хүртэл",
|
||||
"nights": "{count, plural, one {# шөнө} other {# шөнө}}",
|
||||
"pickup": "Авах",
|
||||
"dropoff": "Буцаах",
|
||||
"party_size": "{count, plural, one {# хүний ширээ} other {# хүний ширээ}}",
|
||||
"items_more": "болон өөр {count}",
|
||||
"items_count": "{count, plural, one {# бараа} other {# бараа}}",
|
||||
"total": "Нийт {total}",
|
||||
"expected_by": "{date}-ны дотор ирнэ",
|
||||
"local_time": "орон нутгийн цаг",
|
||||
"local_time_title": "Тухайн газрын орон нутгийн цаг, таны цагийн бүс биш",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{total}-с {index}",
|
||||
"plugin_panel_show": "Залгаасын самбарыг харуулах",
|
||||
"plugin_panel_hide": "Залгаасын самбарыг нуух",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kode kopiert",
|
||||
"copy_failed": "Kunne ikke kopiere koden"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Fly",
|
||||
"kind_train": "Tog",
|
||||
"kind_bus": "Buss",
|
||||
"kind_lodging": "Hotell",
|
||||
"kind_rentalcar": "Leiebil",
|
||||
"kind_event": "Arrangement",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Bestilling",
|
||||
"kind_parcel": "Pakke",
|
||||
"region_flight": "Flybestilling",
|
||||
"region_train": "Togbestilling",
|
||||
"region_bus": "Bussbestilling",
|
||||
"region_lodging": "Hotellbestilling",
|
||||
"region_rentalcar": "Leiebilbestilling",
|
||||
"region_event": "Arrangementsbestilling",
|
||||
"region_restaurant": "Bordreservasjon",
|
||||
"region_shipment": "Bestilling og levering",
|
||||
"status_cancelled": "Kansellert",
|
||||
"status_pending": "Venter",
|
||||
"status_hold": "Reservert",
|
||||
"order_status_payment_due": "Betaling mangler",
|
||||
"order_status_processing": "Behandles",
|
||||
"order_status_problem": "Problem",
|
||||
"order_status_in_transit": "Sendt",
|
||||
"order_status_pickup_available": "Klar til henting",
|
||||
"order_status_returned": "Returnert",
|
||||
"order_status_delivered": "Levert",
|
||||
"order_status_cancelled": "Kansellert",
|
||||
"reference": "Bestillingsnummer",
|
||||
"order_number": "Ordrenummer",
|
||||
"tracking_number": "Sporingsnummer",
|
||||
"copy_reference": "Kopier bestillingsnummer {value}",
|
||||
"copy_order_number": "Kopier ordrenummer {value}",
|
||||
"copy_tracking_number": "Kopier sporingsnummer {value}",
|
||||
"copied_reference": "Bestillingsnummer kopiert",
|
||||
"copied_order_number": "Ordrenummer kopiert",
|
||||
"copied_tracking_number": "Sporingsnummer kopiert",
|
||||
"copy_failed": "Kunne ikke kopiere til utklippstavlen",
|
||||
"passengers": "Reisende",
|
||||
"seats": "Seter",
|
||||
"tickets": "Billetter",
|
||||
"link_check_in": "Sjekk inn",
|
||||
"link_manage": "Administrer bestilling",
|
||||
"link_ticket": "Billett",
|
||||
"link_track": "Spor",
|
||||
"link_view_order": "Vis bestilling",
|
||||
"check_in_label": "Innsjekking",
|
||||
"check_out_label": "Utsjekking",
|
||||
"from_time": "fra kl. {time}",
|
||||
"until_time": "innen kl. {time}",
|
||||
"nights": "{count, plural, one {# natt} other {# netter}}",
|
||||
"pickup": "Henting",
|
||||
"dropoff": "Innlevering",
|
||||
"party_size": "{count, plural, one {Bord for # person} other {Bord for # personer}}",
|
||||
"items_more": "og {count} til",
|
||||
"items_count": "{count, plural, one {# vare} other {# varer}}",
|
||||
"total": "Totalt {total}",
|
||||
"expected_by": "Kommer innen {date}",
|
||||
"local_time": "lokal tid",
|
||||
"local_time_title": "Lokal tid på stedet, ikke din tidssone",
|
||||
"route": "{from} til {to}"
|
||||
},
|
||||
"mv_position": "{index} av {total}",
|
||||
"plugin_panel_show": "Vis tilleggspanel",
|
||||
"plugin_panel_hide": "Skjul tilleggspanel",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Code gekopieerd",
|
||||
"copy_failed": "Kan de code niet kopiëren"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Vlucht",
|
||||
"kind_train": "Trein",
|
||||
"kind_bus": "Bus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Huurauto",
|
||||
"kind_event": "Evenement",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Bestelling",
|
||||
"kind_parcel": "Pakket",
|
||||
"region_flight": "Vluchtboeking",
|
||||
"region_train": "Treinboeking",
|
||||
"region_bus": "Busboeking",
|
||||
"region_lodging": "Hotelboeking",
|
||||
"region_rentalcar": "Huurautoboeking",
|
||||
"region_event": "Evenementboeking",
|
||||
"region_restaurant": "Restaurantreservering",
|
||||
"region_shipment": "Bestelling en levering",
|
||||
"status_cancelled": "Geannuleerd",
|
||||
"status_pending": "In behandeling",
|
||||
"status_hold": "In optie",
|
||||
"order_status_payment_due": "Betaling open",
|
||||
"order_status_processing": "Wordt verwerkt",
|
||||
"order_status_problem": "Probleem",
|
||||
"order_status_in_transit": "Verzonden",
|
||||
"order_status_pickup_available": "Klaar om op te halen",
|
||||
"order_status_returned": "Geretourneerd",
|
||||
"order_status_delivered": "Bezorgd",
|
||||
"order_status_cancelled": "Geannuleerd",
|
||||
"reference": "Boekingsnummer",
|
||||
"order_number": "Bestelnummer",
|
||||
"tracking_number": "Trackingnummer",
|
||||
"copy_reference": "Boekingsnummer {value} kopiëren",
|
||||
"copy_order_number": "Bestelnummer {value} kopiëren",
|
||||
"copy_tracking_number": "Trackingnummer {value} kopiëren",
|
||||
"copied_reference": "Boekingsnummer gekopieerd",
|
||||
"copied_order_number": "Bestelnummer gekopieerd",
|
||||
"copied_tracking_number": "Trackingnummer gekopieerd",
|
||||
"copy_failed": "Kopiëren naar het klembord is mislukt",
|
||||
"passengers": "Reizigers",
|
||||
"seats": "Stoelen",
|
||||
"tickets": "Tickets",
|
||||
"link_check_in": "Inchecken",
|
||||
"link_manage": "Boeking beheren",
|
||||
"link_ticket": "Ticket",
|
||||
"link_track": "Volgen",
|
||||
"link_view_order": "Bestelling bekijken",
|
||||
"check_in_label": "Inchecken",
|
||||
"check_out_label": "Uitchecken",
|
||||
"from_time": "vanaf {time}",
|
||||
"until_time": "tot {time}",
|
||||
"nights": "{count, plural, one {# nacht} other {# nachten}}",
|
||||
"pickup": "Ophalen",
|
||||
"dropoff": "Inleveren",
|
||||
"party_size": "{count, plural, one {Tafel voor # persoon} other {Tafel voor # personen}}",
|
||||
"items_more": "en nog {count}",
|
||||
"items_count": "{count, plural, one {# artikel} other {# artikelen}}",
|
||||
"total": "Totaal {total}",
|
||||
"expected_by": "Verwacht uiterlijk {date}",
|
||||
"local_time": "lokale tijd",
|
||||
"local_time_title": "Lokale tijd ter plaatse, niet in je eigen tijdzone",
|
||||
"route": "{from} naar {to}"
|
||||
},
|
||||
"mv_position": "{index} van {total}",
|
||||
"plugin_panel_show": "Plug-inpaneel tonen",
|
||||
"plugin_panel_hide": "Plug-inpaneel verbergen",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Skopiowano kod",
|
||||
"copy_failed": "Nie udało się skopiować kodu"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Lot",
|
||||
"kind_train": "Pociąg",
|
||||
"kind_bus": "Autobus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Wynajem samochodu",
|
||||
"kind_event": "Wydarzenie",
|
||||
"kind_restaurant": "Restauracja",
|
||||
"kind_order": "Zamówienie",
|
||||
"kind_parcel": "Przesyłka",
|
||||
"region_flight": "Rezerwacja lotu",
|
||||
"region_train": "Rezerwacja pociągu",
|
||||
"region_bus": "Rezerwacja autobusu",
|
||||
"region_lodging": "Rezerwacja hotelu",
|
||||
"region_rentalcar": "Rezerwacja samochodu",
|
||||
"region_event": "Rezerwacja wydarzenia",
|
||||
"region_restaurant": "Rezerwacja stolika",
|
||||
"region_shipment": "Zamówienie i dostawa",
|
||||
"status_cancelled": "Anulowano",
|
||||
"status_pending": "Oczekuje",
|
||||
"status_hold": "Wstępna rezerwacja",
|
||||
"order_status_payment_due": "Oczekuje na płatność",
|
||||
"order_status_processing": "W realizacji",
|
||||
"order_status_problem": "Problem",
|
||||
"order_status_in_transit": "Wysłano",
|
||||
"order_status_pickup_available": "Gotowe do odbioru",
|
||||
"order_status_returned": "Zwrócono",
|
||||
"order_status_delivered": "Dostarczono",
|
||||
"order_status_cancelled": "Anulowano",
|
||||
"reference": "Numer rezerwacji",
|
||||
"order_number": "Numer zamówienia",
|
||||
"tracking_number": "Numer przesyłki",
|
||||
"copy_reference": "Kopiuj numer rezerwacji {value}",
|
||||
"copy_order_number": "Kopiuj numer zamówienia {value}",
|
||||
"copy_tracking_number": "Kopiuj numer przesyłki {value}",
|
||||
"copied_reference": "Skopiowano numer rezerwacji",
|
||||
"copied_order_number": "Skopiowano numer zamówienia",
|
||||
"copied_tracking_number": "Skopiowano numer przesyłki",
|
||||
"copy_failed": "Nie udało się skopiować do schowka",
|
||||
"passengers": "Pasażerowie",
|
||||
"seats": "Miejsca",
|
||||
"tickets": "Bilety",
|
||||
"link_check_in": "Odprawa",
|
||||
"link_manage": "Zarządzaj rezerwacją",
|
||||
"link_ticket": "Bilet",
|
||||
"link_track": "Śledź",
|
||||
"link_view_order": "Pokaż zamówienie",
|
||||
"check_in_label": "Zameldowanie",
|
||||
"check_out_label": "Wymeldowanie",
|
||||
"from_time": "od {time}",
|
||||
"until_time": "do {time}",
|
||||
"nights": "{count, plural, one {# noc} few {# noce} many {# nocy} other {# nocy}}",
|
||||
"pickup": "Odbiór",
|
||||
"dropoff": "Zwrot",
|
||||
"party_size": "{count, plural, one {Stolik dla # osoby} few {Stolik dla # osób} many {Stolik dla # osób} other {Stolik dla # osoby}}",
|
||||
"items_more": "i {count} więcej",
|
||||
"items_count": "{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}}",
|
||||
"total": "Razem {total}",
|
||||
"expected_by": "Dostawa do {date}",
|
||||
"local_time": "czas lokalny",
|
||||
"local_time_title": "Czas lokalny w danym miejscu, nie w Twojej strefie czasowej",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} z {total}",
|
||||
"plugin_panel_show": "Pokaż panel wtyczki",
|
||||
"plugin_panel_hide": "Ukryj panel wtyczki",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Código copiado",
|
||||
"copy_failed": "Não foi possível copiar o código"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Voo",
|
||||
"kind_train": "Comboio",
|
||||
"kind_bus": "Autocarro",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Carro alugado",
|
||||
"kind_event": "Evento",
|
||||
"kind_restaurant": "Restaurante",
|
||||
"kind_order": "Encomenda",
|
||||
"kind_parcel": "Encomenda postal",
|
||||
"region_flight": "Reserva de voo",
|
||||
"region_train": "Reserva de comboio",
|
||||
"region_bus": "Reserva de autocarro",
|
||||
"region_lodging": "Reserva de hotel",
|
||||
"region_rentalcar": "Reserva de carro alugado",
|
||||
"region_event": "Reserva de evento",
|
||||
"region_restaurant": "Reserva de restaurante",
|
||||
"region_shipment": "Encomenda e entrega",
|
||||
"status_cancelled": "Cancelada",
|
||||
"status_pending": "Pendente",
|
||||
"status_hold": "Em espera",
|
||||
"order_status_payment_due": "Pagamento pendente",
|
||||
"order_status_processing": "Em preparação",
|
||||
"order_status_problem": "Problema",
|
||||
"order_status_in_transit": "Enviada",
|
||||
"order_status_pickup_available": "Pronta para levantar",
|
||||
"order_status_returned": "Devolvida",
|
||||
"order_status_delivered": "Entregue",
|
||||
"order_status_cancelled": "Cancelada",
|
||||
"reference": "Código de reserva",
|
||||
"order_number": "Número da encomenda",
|
||||
"tracking_number": "Número de seguimento",
|
||||
"copy_reference": "Copiar o código de reserva {value}",
|
||||
"copy_order_number": "Copiar o número da encomenda {value}",
|
||||
"copy_tracking_number": "Copiar o número de seguimento {value}",
|
||||
"copied_reference": "Código de reserva copiado",
|
||||
"copied_order_number": "Número da encomenda copiado",
|
||||
"copied_tracking_number": "Número de seguimento copiado",
|
||||
"copy_failed": "Não foi possível copiar para a área de transferência",
|
||||
"passengers": "Passageiros",
|
||||
"seats": "Lugares",
|
||||
"tickets": "Bilhetes",
|
||||
"link_check_in": "Fazer check-in",
|
||||
"link_manage": "Gerir reserva",
|
||||
"link_ticket": "Bilhete",
|
||||
"link_track": "Seguir",
|
||||
"link_view_order": "Ver encomenda",
|
||||
"check_in_label": "Check-in",
|
||||
"check_out_label": "Check-out",
|
||||
"from_time": "a partir das {time}",
|
||||
"until_time": "até às {time}",
|
||||
"nights": "{count, plural, one {# noite} other {# noites}}",
|
||||
"pickup": "Levantamento",
|
||||
"dropoff": "Devolução",
|
||||
"party_size": "{count, plural, one {Mesa para # pessoa} other {Mesa para # pessoas}}",
|
||||
"items_more": "e mais {count}",
|
||||
"items_count": "{count, plural, one {# artigo} other {# artigos}}",
|
||||
"total": "Total {total}",
|
||||
"expected_by": "Chega até {date}",
|
||||
"local_time": "hora local",
|
||||
"local_time_title": "Hora local do lugar, não do seu fuso horário",
|
||||
"route": "{from} para {to}"
|
||||
},
|
||||
"mv_position": "{index} de {total}",
|
||||
"plugin_panel_show": "Mostrar o painel do plugin",
|
||||
"plugin_panel_hide": "Ocultar o painel do plugin",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Cod copiat",
|
||||
"copy_failed": "Codul nu a putut fi copiat"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Zbor",
|
||||
"kind_train": "Tren",
|
||||
"kind_bus": "Autobuz",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Mașină închiriată",
|
||||
"kind_event": "Eveniment",
|
||||
"kind_restaurant": "Restaurant",
|
||||
"kind_order": "Comandă",
|
||||
"kind_parcel": "Colet",
|
||||
"region_flight": "Rezervare zbor",
|
||||
"region_train": "Rezervare tren",
|
||||
"region_bus": "Rezervare autobuz",
|
||||
"region_lodging": "Rezervare hotel",
|
||||
"region_rentalcar": "Rezervare mașină închiriată",
|
||||
"region_event": "Rezervare eveniment",
|
||||
"region_restaurant": "Rezervare la restaurant",
|
||||
"region_shipment": "Comandă și livrare",
|
||||
"status_cancelled": "Anulată",
|
||||
"status_pending": "În așteptare",
|
||||
"status_hold": "Rezervată provizoriu",
|
||||
"order_status_payment_due": "Plată restantă",
|
||||
"order_status_processing": "În procesare",
|
||||
"order_status_problem": "Problemă",
|
||||
"order_status_in_transit": "Expediată",
|
||||
"order_status_pickup_available": "Gata de ridicare",
|
||||
"order_status_returned": "Returnată",
|
||||
"order_status_delivered": "Livrată",
|
||||
"order_status_cancelled": "Anulată",
|
||||
"reference": "Cod de rezervare",
|
||||
"order_number": "Număr comandă",
|
||||
"tracking_number": "Număr de urmărire",
|
||||
"copy_reference": "Copiază codul de rezervare {value}",
|
||||
"copy_order_number": "Copiază numărul comenzii {value}",
|
||||
"copy_tracking_number": "Copiază numărul de urmărire {value}",
|
||||
"copied_reference": "Codul de rezervare a fost copiat",
|
||||
"copied_order_number": "Numărul comenzii a fost copiat",
|
||||
"copied_tracking_number": "Numărul de urmărire a fost copiat",
|
||||
"copy_failed": "Nu s-a putut copia în clipboard",
|
||||
"passengers": "Pasageri",
|
||||
"seats": "Locuri",
|
||||
"tickets": "Bilete",
|
||||
"link_check_in": "Check-in",
|
||||
"link_manage": "Gestionează rezervarea",
|
||||
"link_ticket": "Bilet",
|
||||
"link_track": "Urmărește",
|
||||
"link_view_order": "Vezi comanda",
|
||||
"check_in_label": "Check-in",
|
||||
"check_out_label": "Check-out",
|
||||
"from_time": "de la {time}",
|
||||
"until_time": "până la {time}",
|
||||
"nights": "{count, plural, one {# noapte} few {# nopți} other {# de nopți}}",
|
||||
"pickup": "Preluare",
|
||||
"dropoff": "Predare",
|
||||
"party_size": "{count, plural, one {Masă pentru # persoană} few {Masă pentru # persoane} other {Masă pentru # de persoane}}",
|
||||
"items_more": "și încă {count}",
|
||||
"items_count": "{count, plural, one {# articol} few {# articole} other {# de articole}}",
|
||||
"total": "Total {total}",
|
||||
"expected_by": "Sosește până pe {date}",
|
||||
"local_time": "ora locală",
|
||||
"local_time_title": "Ora locală a locului, nu cea din fusul tău orar",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} din {total}",
|
||||
"plugin_panel_show": "Afișează panoul pluginului",
|
||||
"plugin_panel_hide": "Ascunde panoul pluginului",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Код скопирован",
|
||||
"copy_failed": "Не удалось скопировать код"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Авиарейс",
|
||||
"kind_train": "Поезд",
|
||||
"kind_bus": "Автобус",
|
||||
"kind_lodging": "Отель",
|
||||
"kind_rentalcar": "Аренда авто",
|
||||
"kind_event": "Мероприятие",
|
||||
"kind_restaurant": "Ресторан",
|
||||
"kind_order": "Заказ",
|
||||
"kind_parcel": "Посылка",
|
||||
"region_flight": "Бронирование авиабилета",
|
||||
"region_train": "Бронирование поезда",
|
||||
"region_bus": "Бронирование автобуса",
|
||||
"region_lodging": "Бронирование отеля",
|
||||
"region_rentalcar": "Бронирование автомобиля",
|
||||
"region_event": "Бронирование мероприятия",
|
||||
"region_restaurant": "Бронирование столика",
|
||||
"region_shipment": "Заказ и доставка",
|
||||
"status_cancelled": "Отменено",
|
||||
"status_pending": "Ожидает подтверждения",
|
||||
"status_hold": "Удерживается",
|
||||
"order_status_payment_due": "Ожидает оплаты",
|
||||
"order_status_processing": "Обрабатывается",
|
||||
"order_status_problem": "Проблема",
|
||||
"order_status_in_transit": "Отправлен",
|
||||
"order_status_pickup_available": "Готов к выдаче",
|
||||
"order_status_returned": "Возвращён",
|
||||
"order_status_delivered": "Доставлен",
|
||||
"order_status_cancelled": "Отменён",
|
||||
"reference": "Номер бронирования",
|
||||
"order_number": "Номер заказа",
|
||||
"tracking_number": "Трек-номер",
|
||||
"copy_reference": "Скопировать номер бронирования {value}",
|
||||
"copy_order_number": "Скопировать номер заказа {value}",
|
||||
"copy_tracking_number": "Скопировать трек-номер {value}",
|
||||
"copied_reference": "Номер бронирования скопирован",
|
||||
"copied_order_number": "Номер заказа скопирован",
|
||||
"copied_tracking_number": "Трек-номер скопирован",
|
||||
"copy_failed": "Не удалось скопировать в буфер обмена",
|
||||
"passengers": "Пассажиры",
|
||||
"seats": "Места",
|
||||
"tickets": "Билеты",
|
||||
"link_check_in": "Регистрация",
|
||||
"link_manage": "Управление бронированием",
|
||||
"link_ticket": "Билет",
|
||||
"link_track": "Отследить",
|
||||
"link_view_order": "Открыть заказ",
|
||||
"check_in_label": "Заезд",
|
||||
"check_out_label": "Выезд",
|
||||
"from_time": "с {time}",
|
||||
"until_time": "до {time}",
|
||||
"nights": "{count, plural, one {# ночь} few {# ночи} many {# ночей} other {# ночи}}",
|
||||
"pickup": "Получение",
|
||||
"dropoff": "Возврат",
|
||||
"party_size": "{count, plural, one {Столик на # человека} few {Столик на # человека} many {Столик на # человек} other {Столик на # человека}}",
|
||||
"items_more": "и ещё {count}",
|
||||
"items_count": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товара}}",
|
||||
"total": "Итого {total}",
|
||||
"expected_by": "Доставка до {date}",
|
||||
"local_time": "местное время",
|
||||
"local_time_title": "Местное время в этом месте, а не в вашем часовом поясе",
|
||||
"route": "{from} — {to}"
|
||||
},
|
||||
"mv_position": "{index} из {total}",
|
||||
"plugin_panel_show": "Показать панель плагина",
|
||||
"plugin_panel_hide": "Скрыть панель плагина",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kód skopírovaný",
|
||||
"copy_failed": "Kód sa nepodarilo skopírovať"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Let",
|
||||
"kind_train": "Vlak",
|
||||
"kind_bus": "Autobus",
|
||||
"kind_lodging": "Hotel",
|
||||
"kind_rentalcar": "Požičané auto",
|
||||
"kind_event": "Podujatie",
|
||||
"kind_restaurant": "Reštaurácia",
|
||||
"kind_order": "Objednávka",
|
||||
"kind_parcel": "Zásielka",
|
||||
"region_flight": "Rezervácia letu",
|
||||
"region_train": "Rezervácia vlaku",
|
||||
"region_bus": "Rezervácia autobusu",
|
||||
"region_lodging": "Rezervácia hotela",
|
||||
"region_rentalcar": "Rezervácia požičaného auta",
|
||||
"region_event": "Rezervácia podujatia",
|
||||
"region_restaurant": "Rezervácia stola",
|
||||
"region_shipment": "Objednávka a doručenie",
|
||||
"status_cancelled": "Zrušené",
|
||||
"status_pending": "Čaká na potvrdenie",
|
||||
"status_hold": "Blokované",
|
||||
"order_status_payment_due": "Čaká na platbu",
|
||||
"order_status_processing": "Spracúva sa",
|
||||
"order_status_problem": "Problém",
|
||||
"order_status_in_transit": "Odoslané",
|
||||
"order_status_pickup_available": "Pripravené na vyzdvihnutie",
|
||||
"order_status_returned": "Vrátené",
|
||||
"order_status_delivered": "Doručené",
|
||||
"order_status_cancelled": "Zrušené",
|
||||
"reference": "Číslo rezervácie",
|
||||
"order_number": "Číslo objednávky",
|
||||
"tracking_number": "Číslo zásielky",
|
||||
"copy_reference": "Kopírovať číslo rezervácie {value}",
|
||||
"copy_order_number": "Kopírovať číslo objednávky {value}",
|
||||
"copy_tracking_number": "Kopírovať číslo zásielky {value}",
|
||||
"copied_reference": "Číslo rezervácie skopírované",
|
||||
"copied_order_number": "Číslo objednávky skopírované",
|
||||
"copied_tracking_number": "Číslo zásielky skopírované",
|
||||
"copy_failed": "Kopírovanie do schránky zlyhalo",
|
||||
"passengers": "Cestujúci",
|
||||
"seats": "Miesta",
|
||||
"tickets": "Vstupenky",
|
||||
"link_check_in": "Odbaviť sa",
|
||||
"link_manage": "Spravovať rezerváciu",
|
||||
"link_ticket": "Vstupenka",
|
||||
"link_track": "Sledovať",
|
||||
"link_view_order": "Zobraziť objednávku",
|
||||
"check_in_label": "Príchod",
|
||||
"check_out_label": "Odchod",
|
||||
"from_time": "od {time}",
|
||||
"until_time": "do {time}",
|
||||
"nights": "{count, plural, one {# noc} few {# noci} many {# noci} other {# nocí}}",
|
||||
"pickup": "Vyzdvihnutie",
|
||||
"dropoff": "Vrátenie",
|
||||
"party_size": "{count, plural, one {Stôl pre # osobu} few {Stôl pre # osoby} many {Stôl pre # osoby} other {Stôl pre # osôb}}",
|
||||
"items_more": "a {count} ďalšie",
|
||||
"items_count": "{count, plural, one {# položka} few {# položky} many {# položky} other {# položiek}}",
|
||||
"total": "Spolu {total}",
|
||||
"expected_by": "Doručenie do {date}",
|
||||
"local_time": "miestny čas",
|
||||
"local_time_title": "Miestny čas na danom mieste, nie vo vašom časovom pásme",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} z {total}",
|
||||
"plugin_panel_show": "Zobraziť panel doplnku",
|
||||
"plugin_panel_hide": "Skryť panel doplnku",
|
||||
|
||||
@@ -1710,6 +1710,69 @@
|
||||
"copied": "คัดลอกรหัสแล้ว!",
|
||||
"copy_failed": "คัดลอกไม่สำเร็จ"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "เที่ยวบิน",
|
||||
"kind_train": "รถไฟ",
|
||||
"kind_bus": "รถบัส",
|
||||
"kind_lodging": "โรงแรม",
|
||||
"kind_rentalcar": "รถเช่า",
|
||||
"kind_event": "กิจกรรม",
|
||||
"kind_restaurant": "ร้านอาหาร",
|
||||
"kind_order": "คำสั่งซื้อ",
|
||||
"kind_parcel": "พัสดุ",
|
||||
"region_flight": "การจองเที่ยวบิน",
|
||||
"region_train": "การจองรถไฟ",
|
||||
"region_bus": "การจองรถบัส",
|
||||
"region_lodging": "การจองโรงแรม",
|
||||
"region_rentalcar": "การจองรถเช่า",
|
||||
"region_event": "การจองกิจกรรม",
|
||||
"region_restaurant": "การจองโต๊ะอาหาร",
|
||||
"region_shipment": "คำสั่งซื้อและการจัดส่ง",
|
||||
"status_cancelled": "ยกเลิกแล้ว",
|
||||
"status_pending": "รอการยืนยัน",
|
||||
"status_hold": "กันที่ไว้",
|
||||
"order_status_payment_due": "รอชำระเงิน",
|
||||
"order_status_processing": "กำลังดำเนินการ",
|
||||
"order_status_problem": "มีปัญหา",
|
||||
"order_status_in_transit": "จัดส่งแล้ว",
|
||||
"order_status_pickup_available": "พร้อมให้รับ",
|
||||
"order_status_returned": "ส่งคืนแล้ว",
|
||||
"order_status_delivered": "ส่งถึงแล้ว",
|
||||
"order_status_cancelled": "ยกเลิกแล้ว",
|
||||
"reference": "รหัสการจอง",
|
||||
"order_number": "หมายเลขคำสั่งซื้อ",
|
||||
"tracking_number": "หมายเลขพัสดุ",
|
||||
"copy_reference": "คัดลอกรหัสการจอง {value}",
|
||||
"copy_order_number": "คัดลอกหมายเลขคำสั่งซื้อ {value}",
|
||||
"copy_tracking_number": "คัดลอกหมายเลขพัสดุ {value}",
|
||||
"copied_reference": "คัดลอกรหัสการจองแล้ว",
|
||||
"copied_order_number": "คัดลอกหมายเลขคำสั่งซื้อแล้ว",
|
||||
"copied_tracking_number": "คัดลอกหมายเลขพัสดุแล้ว",
|
||||
"copy_failed": "คัดลอกไปยังคลิปบอร์ดไม่ได้",
|
||||
"passengers": "ผู้โดยสาร",
|
||||
"seats": "ที่นั่ง",
|
||||
"tickets": "ตั๋ว",
|
||||
"link_check_in": "เช็คอิน",
|
||||
"link_manage": "จัดการการจอง",
|
||||
"link_ticket": "ตั๋ว",
|
||||
"link_track": "ติดตามพัสดุ",
|
||||
"link_view_order": "ดูคำสั่งซื้อ",
|
||||
"check_in_label": "เช็คอิน",
|
||||
"check_out_label": "เช็คเอาท์",
|
||||
"from_time": "ตั้งแต่ {time}",
|
||||
"until_time": "ภายใน {time}",
|
||||
"nights": "{count, plural, other {# คืน}}",
|
||||
"pickup": "รับรถ",
|
||||
"dropoff": "คืนรถ",
|
||||
"party_size": "{count, plural, other {โต๊ะสำหรับ # คน}}",
|
||||
"items_more": "และอีก {count} รายการ",
|
||||
"items_count": "{count, plural, other {# รายการ}}",
|
||||
"total": "รวม {total}",
|
||||
"expected_by": "ถึงภายใน {date}",
|
||||
"local_time": "เวลาท้องถิ่น",
|
||||
"local_time_title": "เวลาท้องถิ่นของสถานที่ ไม่ใช่เขตเวลาของคุณ",
|
||||
"route": "{from} ไป {to}"
|
||||
},
|
||||
"mv_position": "{index} จาก {total}",
|
||||
"copy": "คัดลอก",
|
||||
"contact_added": "เพิ่มผู้ติดต่อแล้ว",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Kod kopyalandı",
|
||||
"copy_failed": "Kod kopyalanamadı"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Uçuş",
|
||||
"kind_train": "Tren",
|
||||
"kind_bus": "Otobüs",
|
||||
"kind_lodging": "Otel",
|
||||
"kind_rentalcar": "Kiralık araç",
|
||||
"kind_event": "Etkinlik",
|
||||
"kind_restaurant": "Restoran",
|
||||
"kind_order": "Sipariş",
|
||||
"kind_parcel": "Kargo",
|
||||
"region_flight": "Uçuş rezervasyonu",
|
||||
"region_train": "Tren rezervasyonu",
|
||||
"region_bus": "Otobüs rezervasyonu",
|
||||
"region_lodging": "Otel rezervasyonu",
|
||||
"region_rentalcar": "Araç kiralama rezervasyonu",
|
||||
"region_event": "Etkinlik rezervasyonu",
|
||||
"region_restaurant": "Restoran rezervasyonu",
|
||||
"region_shipment": "Sipariş ve teslimat",
|
||||
"status_cancelled": "İptal edildi",
|
||||
"status_pending": "Beklemede",
|
||||
"status_hold": "Opsiyonlu",
|
||||
"order_status_payment_due": "Ödeme bekleniyor",
|
||||
"order_status_processing": "Hazırlanıyor",
|
||||
"order_status_problem": "Sorun var",
|
||||
"order_status_in_transit": "Kargoya verildi",
|
||||
"order_status_pickup_available": "Teslim almaya hazır",
|
||||
"order_status_returned": "İade edildi",
|
||||
"order_status_delivered": "Teslim edildi",
|
||||
"order_status_cancelled": "İptal edildi",
|
||||
"reference": "Rezervasyon kodu",
|
||||
"order_number": "Sipariş numarası",
|
||||
"tracking_number": "Takip numarası",
|
||||
"copy_reference": "{value} rezervasyon kodunu kopyala",
|
||||
"copy_order_number": "{value} sipariş numarasını kopyala",
|
||||
"copy_tracking_number": "{value} takip numarasını kopyala",
|
||||
"copied_reference": "Rezervasyon kodu kopyalandı",
|
||||
"copied_order_number": "Sipariş numarası kopyalandı",
|
||||
"copied_tracking_number": "Takip numarası kopyalandı",
|
||||
"copy_failed": "Panoya kopyalanamadı",
|
||||
"passengers": "Yolcular",
|
||||
"seats": "Koltuklar",
|
||||
"tickets": "Biletler",
|
||||
"link_check_in": "Check-in yap",
|
||||
"link_manage": "Rezervasyonu yönet",
|
||||
"link_ticket": "Bilet",
|
||||
"link_track": "Takip et",
|
||||
"link_view_order": "Siparişi görüntüle",
|
||||
"check_in_label": "Giriş",
|
||||
"check_out_label": "Çıkış",
|
||||
"from_time": "{time} itibarıyla",
|
||||
"until_time": "en geç {time}",
|
||||
"nights": "{count, plural, one {# gece} other {# gece}}",
|
||||
"pickup": "Teslim alma",
|
||||
"dropoff": "Teslim etme",
|
||||
"party_size": "{count, plural, one {# kişilik masa} other {# kişilik masa}}",
|
||||
"items_more": "ve {count} tane daha",
|
||||
"items_count": "{count, plural, one {# ürün} other {# ürün}}",
|
||||
"total": "Toplam {total}",
|
||||
"expected_by": "En geç {date} tarihinde gelir",
|
||||
"local_time": "yerel saat",
|
||||
"local_time_title": "Yerin yerel saati, sizin saat diliminiz değil",
|
||||
"route": "{from} – {to}"
|
||||
},
|
||||
"mv_position": "{index} / {total}",
|
||||
"plugin_panel_show": "Eklenti panelini göster",
|
||||
"plugin_panel_hide": "Eklenti panelini gizle",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "Код скопійовано",
|
||||
"copy_failed": "Не вдалося скопіювати код"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "Авіарейс",
|
||||
"kind_train": "Потяг",
|
||||
"kind_bus": "Автобус",
|
||||
"kind_lodging": "Готель",
|
||||
"kind_rentalcar": "Оренда авто",
|
||||
"kind_event": "Подія",
|
||||
"kind_restaurant": "Ресторан",
|
||||
"kind_order": "Замовлення",
|
||||
"kind_parcel": "Посилка",
|
||||
"region_flight": "Бронювання авіаквитка",
|
||||
"region_train": "Бронювання потяга",
|
||||
"region_bus": "Бронювання автобуса",
|
||||
"region_lodging": "Бронювання готелю",
|
||||
"region_rentalcar": "Бронювання авто",
|
||||
"region_event": "Бронювання події",
|
||||
"region_restaurant": "Бронювання столика",
|
||||
"region_shipment": "Замовлення і доставка",
|
||||
"status_cancelled": "Скасовано",
|
||||
"status_pending": "Очікує підтвердження",
|
||||
"status_hold": "Утримується",
|
||||
"order_status_payment_due": "Очікує оплати",
|
||||
"order_status_processing": "Обробляється",
|
||||
"order_status_problem": "Проблема",
|
||||
"order_status_in_transit": "Відправлено",
|
||||
"order_status_pickup_available": "Готове до видачі",
|
||||
"order_status_returned": "Повернено",
|
||||
"order_status_delivered": "Доставлено",
|
||||
"order_status_cancelled": "Скасовано",
|
||||
"reference": "Номер бронювання",
|
||||
"order_number": "Номер замовлення",
|
||||
"tracking_number": "Трек-номер",
|
||||
"copy_reference": "Скопіювати номер бронювання {value}",
|
||||
"copy_order_number": "Скопіювати номер замовлення {value}",
|
||||
"copy_tracking_number": "Скопіювати трек-номер {value}",
|
||||
"copied_reference": "Номер бронювання скопійовано",
|
||||
"copied_order_number": "Номер замовлення скопійовано",
|
||||
"copied_tracking_number": "Трек-номер скопійовано",
|
||||
"copy_failed": "Не вдалося скопіювати в буфер обміну",
|
||||
"passengers": "Пасажири",
|
||||
"seats": "Місця",
|
||||
"tickets": "Квитки",
|
||||
"link_check_in": "Реєстрація",
|
||||
"link_manage": "Керувати бронюванням",
|
||||
"link_ticket": "Квиток",
|
||||
"link_track": "Відстежити",
|
||||
"link_view_order": "Переглянути замовлення",
|
||||
"check_in_label": "Заїзд",
|
||||
"check_out_label": "Виїзд",
|
||||
"from_time": "з {time}",
|
||||
"until_time": "до {time}",
|
||||
"nights": "{count, plural, one {# ніч} few {# ночі} many {# ночей} other {# ночі}}",
|
||||
"pickup": "Отримання",
|
||||
"dropoff": "Повернення",
|
||||
"party_size": "{count, plural, one {Столик на # особу} few {Столик на # особи} many {Столик на # осіб} other {Столик на # особи}}",
|
||||
"items_more": "і ще {count}",
|
||||
"items_count": "{count, plural, one {# товар} few {# товари} many {# товарів} other {# товару}}",
|
||||
"total": "Разом {total}",
|
||||
"expected_by": "Доставка до {date}",
|
||||
"local_time": "місцевий час",
|
||||
"local_time_title": "Місцевий час у цьому місці, а не у вашому часовому поясі",
|
||||
"route": "{from} — {to}"
|
||||
},
|
||||
"mv_position": "{index} з {total}",
|
||||
"plugin_panel_show": "Показати панель плагіна",
|
||||
"plugin_panel_hide": "Сховати панель плагіна",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "驗證碼已複製",
|
||||
"copy_failed": "無法複製驗證碼"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "航班",
|
||||
"kind_train": "火車",
|
||||
"kind_bus": "巴士",
|
||||
"kind_lodging": "飯店",
|
||||
"kind_rentalcar": "租車",
|
||||
"kind_event": "活動",
|
||||
"kind_restaurant": "餐廳",
|
||||
"kind_order": "訂單",
|
||||
"kind_parcel": "包裹",
|
||||
"region_flight": "航班訂位",
|
||||
"region_train": "火車訂票",
|
||||
"region_bus": "巴士訂票",
|
||||
"region_lodging": "飯店訂房",
|
||||
"region_rentalcar": "租車預約",
|
||||
"region_event": "活動預約",
|
||||
"region_restaurant": "餐廳訂位",
|
||||
"region_shipment": "訂單與配送",
|
||||
"status_cancelled": "已取消",
|
||||
"status_pending": "待確認",
|
||||
"status_hold": "已保留",
|
||||
"order_status_payment_due": "待付款",
|
||||
"order_status_processing": "處理中",
|
||||
"order_status_problem": "有問題",
|
||||
"order_status_in_transit": "已出貨",
|
||||
"order_status_pickup_available": "可取貨",
|
||||
"order_status_returned": "已退回",
|
||||
"order_status_delivered": "已送達",
|
||||
"order_status_cancelled": "已取消",
|
||||
"reference": "訂位代號",
|
||||
"order_number": "訂單編號",
|
||||
"tracking_number": "貨件追蹤號碼",
|
||||
"copy_reference": "複製訂位代號 {value}",
|
||||
"copy_order_number": "複製訂單編號 {value}",
|
||||
"copy_tracking_number": "複製貨件追蹤號碼 {value}",
|
||||
"copied_reference": "已複製訂位代號",
|
||||
"copied_order_number": "已複製訂單編號",
|
||||
"copied_tracking_number": "已複製貨件追蹤號碼",
|
||||
"copy_failed": "無法複製到剪貼簿",
|
||||
"passengers": "乘客",
|
||||
"seats": "座位",
|
||||
"tickets": "票券",
|
||||
"link_check_in": "報到",
|
||||
"link_manage": "管理訂位",
|
||||
"link_ticket": "票券",
|
||||
"link_track": "追蹤貨件",
|
||||
"link_view_order": "查看訂單",
|
||||
"check_in_label": "入住",
|
||||
"check_out_label": "退房",
|
||||
"from_time": "{time} 起",
|
||||
"until_time": "{time} 前",
|
||||
"nights": "{count, plural, other {# 晚}}",
|
||||
"pickup": "取車",
|
||||
"dropoff": "還車",
|
||||
"party_size": "{count, plural, other {# 人桌}}",
|
||||
"items_more": "及其他 {count} 件",
|
||||
"items_count": "{count, plural, other {# 件商品}}",
|
||||
"total": "總計 {total}",
|
||||
"expected_by": "預計 {date} 前送達",
|
||||
"local_time": "當地時間",
|
||||
"local_time_title": "這是該地點的當地時間,而非你所在時區的時間",
|
||||
"route": "{from} 至 {to}"
|
||||
},
|
||||
"mv_position": "第 {index} 封,共 {total} 封",
|
||||
"plugin_panel_show": "顯示外掛程式面板",
|
||||
"plugin_panel_hide": "隱藏外掛程式面板",
|
||||
|
||||
@@ -775,6 +775,69 @@
|
||||
"copied": "验证码已复制",
|
||||
"copy_failed": "无法复制验证码"
|
||||
},
|
||||
"structured_mail": {
|
||||
"kind_flight": "航班",
|
||||
"kind_train": "火车",
|
||||
"kind_bus": "巴士",
|
||||
"kind_lodging": "酒店",
|
||||
"kind_rentalcar": "租车",
|
||||
"kind_event": "活动",
|
||||
"kind_restaurant": "餐厅",
|
||||
"kind_order": "订单",
|
||||
"kind_parcel": "包裹",
|
||||
"region_flight": "航班预订",
|
||||
"region_train": "火车预订",
|
||||
"region_bus": "巴士预订",
|
||||
"region_lodging": "酒店预订",
|
||||
"region_rentalcar": "租车预订",
|
||||
"region_event": "活动预订",
|
||||
"region_restaurant": "餐厅预订",
|
||||
"region_shipment": "订单和配送",
|
||||
"status_cancelled": "已取消",
|
||||
"status_pending": "待确认",
|
||||
"status_hold": "已预留",
|
||||
"order_status_payment_due": "待付款",
|
||||
"order_status_processing": "处理中",
|
||||
"order_status_problem": "有问题",
|
||||
"order_status_in_transit": "已发货",
|
||||
"order_status_pickup_available": "待取件",
|
||||
"order_status_returned": "已退回",
|
||||
"order_status_delivered": "已送达",
|
||||
"order_status_cancelled": "已取消",
|
||||
"reference": "预订编号",
|
||||
"order_number": "订单号",
|
||||
"tracking_number": "运单号",
|
||||
"copy_reference": "复制预订编号 {value}",
|
||||
"copy_order_number": "复制订单号 {value}",
|
||||
"copy_tracking_number": "复制运单号 {value}",
|
||||
"copied_reference": "已复制预订编号",
|
||||
"copied_order_number": "已复制订单号",
|
||||
"copied_tracking_number": "已复制运单号",
|
||||
"copy_failed": "无法复制到剪贴板",
|
||||
"passengers": "乘客",
|
||||
"seats": "座位",
|
||||
"tickets": "门票",
|
||||
"link_check_in": "值机",
|
||||
"link_manage": "管理预订",
|
||||
"link_ticket": "门票",
|
||||
"link_track": "查询物流",
|
||||
"link_view_order": "查看订单",
|
||||
"check_in_label": "入住",
|
||||
"check_out_label": "退房",
|
||||
"from_time": "{time} 起",
|
||||
"until_time": "{time} 前",
|
||||
"nights": "{count, plural, other {# 晚}}",
|
||||
"pickup": "取车",
|
||||
"dropoff": "还车",
|
||||
"party_size": "{count, plural, other {# 人桌}}",
|
||||
"items_more": "等另外 {count} 件",
|
||||
"items_count": "{count, plural, other {# 件商品}}",
|
||||
"total": "合计 {total}",
|
||||
"expected_by": "预计 {date} 前送达",
|
||||
"local_time": "当地时间",
|
||||
"local_time_title": "这是该地点的当地时间,而不是你所在时区的时间",
|
||||
"route": "{from} 至 {to}"
|
||||
},
|
||||
"mv_position": "第 {index} 封,共 {total} 封",
|
||||
"plugin_panel_show": "显示插件面板",
|
||||
"plugin_panel_hide": "隐藏插件面板",
|
||||
|
||||
Reference in New Issue
Block a user