Merge branch 'feat/structured-mail-cards' into feat/structured-mail

This commit is contained in:
Linus Rath
2026-10-30 05:40:44 +01:00
40 changed files with 3624 additions and 1 deletions
@@ -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);
});
+21 -1
View File
@@ -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');
});
});
+260
View File
@@ -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;
}
}
+63
View File
@@ -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": "إخفاء لوحة الإضافة",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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": "پنهان کردن پنل افزونه",
+63
View File
@@ -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",
+63
View File
@@ -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": "הסתרת חלונית התוסף",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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": "プラグインパネルを隠す",
+63
View File
@@ -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": "플러그인 패널 숨기기",
+63
View File
@@ -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",
+63
View File
@@ -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": "Залгаасын самбарыг нуух",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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",
+63
View File
@@ -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": "Скрыть панель плагина",
+63
View File
@@ -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",
+63
View File
@@ -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": "เพิ่มผู้ติดต่อแล้ว",
+63
View File
@@ -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",
+63
View File
@@ -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": "Сховати панель плагіна",
+63
View File
@@ -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": "隱藏外掛程式面板",
+63
View File
@@ -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": "隐藏插件面板",