From 972e5f6217127acacd526063b46a2d60b06557c3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:06:17 +0100 Subject: [PATCH 1/5] feat: add cards for booking, parcel and order mail --- .../__tests__/structured-mail-cards.test.tsx | 260 +++++++++++++ .../email/structured-mail/booking-cards.tsx | 347 ++++++++++++++++++ .../email/structured-mail/card-parts.tsx | 153 ++++++++ .../email/structured-mail/copy-value-chip.tsx | 58 +++ .../email/structured-mail/shipment-card.tsx | 150 ++++++++ .../structured-mail/structured-mail-cards.tsx | 48 +++ lib/structured-mail/__tests__/format.test.ts | 188 ++++++++++ lib/structured-mail/format.ts | 255 +++++++++++++ locales/ar/common.json | 62 ++++ locales/ca/common.json | 62 ++++ locales/cs/common.json | 62 ++++ locales/da/common.json | 62 ++++ locales/de/common.json | 62 ++++ locales/en/common.json | 62 ++++ locales/es/common.json | 62 ++++ locales/et/common.json | 62 ++++ locales/fa/common.json | 62 ++++ locales/fr/common.json | 62 ++++ locales/he/common.json | 62 ++++ locales/hu/common.json | 62 ++++ locales/it/common.json | 62 ++++ locales/ja/common.json | 62 ++++ locales/ko/common.json | 62 ++++ locales/lv/common.json | 62 ++++ locales/mn/common.json | 62 ++++ locales/nb/common.json | 62 ++++ locales/nl/common.json | 62 ++++ locales/pl/common.json | 62 ++++ locales/pt/common.json | 62 ++++ locales/ro/common.json | 62 ++++ locales/ru/common.json | 62 ++++ locales/sk/common.json | 62 ++++ locales/th/common.json | 62 ++++ locales/tr/common.json | 62 ++++ locales/uk/common.json | 62 ++++ locales/zh-TW/common.json | 62 ++++ locales/zh/common.json | 62 ++++ 37 files changed, 3257 insertions(+) create mode 100644 components/email/structured-mail/__tests__/structured-mail-cards.test.tsx create mode 100644 components/email/structured-mail/booking-cards.tsx create mode 100644 components/email/structured-mail/card-parts.tsx create mode 100644 components/email/structured-mail/copy-value-chip.tsx create mode 100644 components/email/structured-mail/shipment-card.tsx create mode 100644 components/email/structured-mail/structured-mail-cards.tsx create mode 100644 lib/structured-mail/__tests__/format.test.ts create mode 100644 lib/structured-mail/format.ts diff --git a/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx b/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx new file mode 100644 index 000000000..a87b51475 --- /dev/null +++ b/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx @@ -0,0 +1,260 @@ +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) => (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; +const card = (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[0]> = {}) { + return render(); +} + +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 → JFK → FRA' })).toBeInTheDocument(); + 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('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('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('train-multiple-types'); + pending.status = 'pending'; + const hold = card('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 → 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('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'); + }); + + it('gives each booking to the calendar slot, and none for a shipment', () => { + const slot = vi.fn((booking: Booking) => ); + 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); + }); +}); diff --git a/components/email/structured-mail/booking-cards.tsx b/components/email/structured-mail/booking-cards.tsx new file mode 100644 index 000000000..5558fc708 --- /dev/null +++ b/components/email/structured-mail/booking-cards.tsx @@ -0,0 +1,347 @@ +"use client"; + +import 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, + routeTitle, + sameLocalDate, +} from "@/lib/structured-mail/format"; +import type { + Booking, + EventBooking, + 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 { + 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; + +/** Reference chip, the booking's links and the calendar slot (render only when hasActions). */ +function BookingActions({ booking, calendarActions, links }: Omit & { links: ReactNode }) { + const t = useTranslations("email_viewer.structured_mail"); + return ( + <> + {booking.reference && ( + + )} + {links} + {calendarActions?.(booking)} + + ); +} + +function hasActions(booking: Booking, calendarActions: unknown): boolean { + return !!booking.reference || !!calendarActions + || [booking.links.checkin, booking.links.manage, booking.links.ticket].some(isWebLink); +} + +function PassengersRow({ names }: { names: string[] }) { + const t = useTranslations("email_viewer.structured_mail"); + if (names.length === 0) return null; + return ( + + {t("passengers")}: + {names.join(", ")} + + ); +} + +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; + const seats = leg.seats.map((s) => (s.passenger ? `${s.passenger} ${s.seat}` : s.seat)); + return ( +
+ {(service || (cancelled && !bookingCancelled)) && ( +
+ {service && ( + + + )} + {cancelled && !bookingCancelled && ( + {t("status_cancelled")} + )} +
+ )} + + + · {legPlaceLabel(leg.departure.place, kind)} + + {arrival && ( + + {arrival.at && } + {arrival.at ? " · " : ""}{legPlaceLabel(arrival.place, kind)} + + )} + {seats.length > 0 && ( + + {t("seats")}: + {seats.join(", ")} + + )} +
+ ); +} + +export function TransportCard({ booking, emailId, calendarActions }: BookingCardProps) { + 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 cancelled = booking.status === "cancelled"; + return ( + : undefined} + actions={hasActions(booking, calendarActions) ? ( + + + + + } + /> + ) : undefined} + > + {booking.legs.map((leg, i) => ( + + ))} + + + ); +} + +/** "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 ( + + {label} + {formatMomentDate(at, opts)} + {time && ( + <> + , + {edge === "from" ? t("from_time", { time }) : t("until_time", { time })} + + + )} + + ); +} + +/** 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) ? ( + {t("local_time")} + ) : null; +} + +export function LodgingCard({ booking, emailId, calendarActions }: BookingCardProps) { + 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 ( + : undefined} + actions={hasActions(booking, calendarActions) ? ( + + + + } + /> + ) : undefined} + > + {address && {address}} + + + {nights !== undefined && {t("nights", { count: nights })}} + {booking.phone && {booking.phone}} + + + ); +} + +export function RentalCarCard({ booking, emailId, calendarActions }: BookingCardProps) { + 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 ( + : undefined} + actions={hasActions(booking, calendarActions) ? ( + } + /> + ) : undefined} + > + + {t("pickup")} + + {pickupPlace && · {pickupPlace}} + + + {t("dropoff")} + + {dropoffPlace && · {dropoffPlace}} + + {booking.car && {booking.car}} + + + ); +} + +export function EventCard({ booking, emailId, calendarActions }: BookingCardProps) { + 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 ( + : undefined} + actions={hasActions(booking, calendarActions) ? ( + + + + } + /> + ) : undefined} + > + + + {end && (end.time || !sameLocalDate(end, booking.start)) && ( + <> + – + + + )} + + {venue && {venue}} + {booking.tickets.length > 0 && ( + + {t("tickets")}: + {booking.tickets.join(", ")} + + )} + + + ); +} + +export function RestaurantCard({ booking, emailId, calendarActions }: BookingCardProps) { + const t = useTranslations("email_viewer.structured_mail"); + const { opts } = useCardFormat(); + const address = formatAddress(booking.address, opts.locale); + return ( + : undefined} + actions={hasActions(booking, calendarActions) ? ( + } + /> + ) : undefined} + > + + {address && {address}} + {booking.partySize !== undefined && booking.partySize > 0 && ( + {t("party_size", { count: booking.partySize })} + )} + + ); +} diff --git a/components/email/structured-mail/card-parts.tsx b/components/email/structured-mail/card-parts.tsx new file mode 100644 index 000000000..870a1efb6 --- /dev/null +++ b/components/email/structured-mail/card-parts.tsx @@ -0,0 +1,153 @@ +"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 ( + <> + {text} + {other && ( + + {t("local_time")} + + )} + + ); +} + +/** 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 ( +
+
+ ); +} + +const STATUS_TONE: Record, 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 {label}; +} + +/** + * 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 ( + { + e.preventDefault(); + e.stopPropagation(); + const target = await resolveExternalLink({ href: link.url, target: "_blank", emailId }); + if (!target) return; + window.open(target, "_blank", "noopener,noreferrer"); + }} + > + + ); +} + +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: 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, pills, children, actions, kind }: CardShellProps) { + return ( +
+ +
+
+
{eyebrow}
+

+ {title} +

+
+ {pills &&
{pills}
} + {children &&
{children}
} + {actions &&
{actions}
} +
+
+ ); +} + +/** "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; +} diff --git a/components/email/structured-mail/copy-value-chip.tsx b/components/email/structured-mail/copy-value-chip.tsx new file mode 100644 index 000000000..f4c190ecd --- /dev/null +++ b/components/email/structured-mail/copy-value-chip.tsx @@ -0,0 +1,58 @@ +"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 ( + + ); +} diff --git a/components/email/structured-mail/shipment-card.tsx b/components/email/structured-mail/shipment-card.tsx new file mode 100644 index 000000000..4cf46b2a1 --- /dev/null +++ b/components/email/structured-mail/shipment-card.tsx @@ -0,0 +1,150 @@ +"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 = { + 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", +}; + +function OrderStatusPill({ status }: { status: OrderStatus }) { + const t = useTranslations("email_viewer.structured_mail"); + const labels: Record = { + 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 {labels[status]}; +} + +/** 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 ( +
+ {shown.map((item, i) => ( + {item.name} + ))} +
+ ); +} + +function ParcelRows({ parcel }: { parcel: Parcel }) { + const t = useTranslations("email_viewer.structured_mail"); + const { opts } = useCardFormat(); + if (!parcel.carrier && !parcel.trackingNumber && !parcel.expectedBy) return null; + return ( + <> + {(parcel.carrier || parcel.trackingNumber) && ( + + + {parcel.carrier && {parcel.carrier}} + {!parcel.carrier && parcel.trackingNumber && {t("tracking_number")}} + {parcel.trackingNumber && } + + + )} + {parcel.expectedBy && ( + {t("expected_by", { date: formatMomentDate(parcel.expectedBy, opts) })} + )} + + ); +} + +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; + const trackLinks = shipment.parcels.map((p) => p.trackingUrl).filter((l) => isWebLink(l)); + const hasOrderLink = isWebLink(shipment.orderUrl); + const cancelled = shipment.status === "cancelled"; + + return ( + : undefined} + actions={trackLinks.length > 0 || hasOrderLink ? ( + <> + {trackLinks.map((link, i) => ( + + ))} + + + ) : undefined} + > + {shipment.orderNumber && ( + + + {t("order_number")} + + + + )} + {shown.length > 0 && ( + + {shown.map((item) => item.name).join(", ")} + {more > 0 && {t("items_more", { count: more })}} + {allowRemoteImages && } + + )} + {(items.length > 0 || total) && ( +
+ {[items.length > 0 ? t("items_count", { count }) : undefined, total ? t("total", { total }) : undefined] + .filter(Boolean).join(" · ")} +
+ )} + {shipment.parcels.map((parcel, i) => )} +
+ ); +} diff --git a/components/email/structured-mail/structured-mail-cards.tsx b/components/email/structured-mail/structured-mail-cards.tsx new file mode 100644 index 000000000..ea25a68db --- /dev/null +++ b/components/email/structured-mail/structured-mail-cards.tsx @@ -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; + /** 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 ( +
+ {cards.map((card, i) => { + const props = { emailId: email.id, calendarActions }; + switch (card.kind) { + case "flight": + case "train": + case "bus": + return ; + case "lodging": + return ; + case "rentalcar": + return ; + case "event": + return ; + case "restaurant": + return ; + case "shipment": + return ; + default: + return null; + } + })} +
+ ); +} diff --git a/lib/structured-mail/__tests__/format.test.ts b/lib/structured-mail/__tests__/format.test.ts new file mode 100644 index 000000000..cbaca2eb1 --- /dev/null +++ b/lib/structured-mail/__tests__/format.test.ts @@ -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 => ({ + 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, b: ReturnType): 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'); + }); +}); diff --git a/lib/structured-mail/format.ts b/lib/structured-mail/format.ts new file mode 100644 index 000000000..40eb56be2 --- /dev/null +++ b/lib/structured-mail/format.ts @@ -0,0 +1,255 @@ +/** + * 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 route as a title: "FRA → JFK → FRA" for flights with codes, city or + * station names otherwise. Legs that don't connect start a new run, + * separated by a comma. + */ +export function routeTitle(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.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(); + 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; + } +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5011d5623..a83b48b0e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -775,6 +775,68 @@ "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": "بالتوقيت المحلي للمكان، وليس بمنطقتك الزمنية" + }, "mv_position": "{index} من {total}", "plugin_panel_show": "إظهار لوحة الإضافة", "plugin_panel_hide": "إخفاء لوحة الإضافة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5fd11fc39..a1205deb8 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} de {total}", "plugin_panel_show": "Mostra el tauler del connector", "plugin_panel_hide": "Amaga el tauler del connector", diff --git a/locales/cs/common.json b/locales/cs/common.json index 4312bdf79..3aa45311e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} z {total}", "plugin_panel_show": "Zobrazit panel pluginu", "plugin_panel_hide": "Skrýt panel pluginu", diff --git a/locales/da/common.json b/locales/da/common.json index d2a51aabe..a6e6fc70f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} af {total}", "plugin_panel_show": "Vis pluginpanel", "plugin_panel_hide": "Skjul pluginpanel", diff --git a/locales/de/common.json b/locales/de/common.json index 3d0b12f25..17eb2ac87 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} von {total}", "plugin_panel_show": "Plugin-Bereich einblenden", "plugin_panel_hide": "Plugin-Bereich ausblenden", diff --git a/locales/en/common.json b/locales/en/common.json index ac2cef4e6..908772642 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} of {total}", "copy": "Copy", "contact_added": "Contact added", diff --git a/locales/es/common.json b/locales/es/common.json index d86594ef8..8235bfbca 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} de {total}", "plugin_panel_show": "Mostrar el panel del complemento", "plugin_panel_hide": "Ocultar el panel del complemento", diff --git a/locales/et/common.json b/locales/et/common.json index 80c687eb0..eef9796ce 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} / {total}", "plugin_panel_show": "Näita pistikprogrammi paneeli", "plugin_panel_hide": "Peida pistikprogrammi paneel", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9952d95f1..71dd73e10 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -775,6 +775,68 @@ "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": "به وقت محلی همان مکان، نه منطقه زمانی شما" + }, "mv_position": "{index} از {total}", "plugin_panel_show": "نمایش پنل افزونه", "plugin_panel_hide": "پنهان کردن پنل افزونه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 0b7c3ca2b..947a69273 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} sur {total}", "plugin_panel_show": "Afficher le panneau du plugin", "plugin_panel_hide": "Masquer le panneau du plugin", diff --git a/locales/he/common.json b/locales/he/common.json index a2e191de9..d27ac0ba7 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -740,6 +740,68 @@ "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": "השעה המקומית במקום, לא באזור הזמן שלך" + }, "mv_position": "{index} מתוך {total}", "plugin_panel_show": "הצגת חלונית התוסף", "plugin_panel_hide": "הסתרת חלונית התוסף", diff --git a/locales/hu/common.json b/locales/hu/common.json index dbe28e232..17f90d0f0 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} / {total}", "plugin_panel_show": "Bővítménypanel megjelenítése", "plugin_panel_hide": "Bővítménypanel elrejtése", diff --git a/locales/it/common.json b/locales/it/common.json index 7a4063840..ca77a08bb 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} di {total}", "plugin_panel_show": "Mostra il pannello del plugin", "plugin_panel_hide": "Nascondi il pannello del plugin", diff --git a/locales/ja/common.json b/locales/ja/common.json index 8eff3d2ce..7dd7a8d73 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -775,6 +775,68 @@ "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": "あなたのタイムゾーンではなく、その場所の現地時間です" + }, "mv_position": "{index} / {total}", "plugin_panel_show": "プラグインパネルを表示", "plugin_panel_hide": "プラグインパネルを隠す", diff --git a/locales/ko/common.json b/locales/ko/common.json index c39ef6888..a81d52b58 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -775,6 +775,68 @@ "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": "사용자의 시간대가 아닌 해당 장소의 현지 시간입니다" + }, "mv_position": "{index} / {total}", "plugin_panel_show": "플러그인 패널 표시", "plugin_panel_hide": "플러그인 패널 숨기기", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3e306355d..f753b6cd9 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -775,6 +775,68 @@ "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ā" + }, "mv_position": "{index} no {total}", "plugin_panel_show": "Rādīt spraudņa paneli", "plugin_panel_hide": "Slēpt spraudņa paneli", diff --git a/locales/mn/common.json b/locales/mn/common.json index a4b8666a7..b84057eee 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -775,6 +775,68 @@ "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": "Тухайн газрын орон нутгийн цаг, таны цагийн бүс биш" + }, "mv_position": "{total}-с {index}", "plugin_panel_show": "Залгаасын самбарыг харуулах", "plugin_panel_hide": "Залгаасын самбарыг нуух", diff --git a/locales/nb/common.json b/locales/nb/common.json index f205d3a22..5b43ab0d8 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} av {total}", "plugin_panel_show": "Vis tilleggspanel", "plugin_panel_hide": "Skjul tilleggspanel", diff --git a/locales/nl/common.json b/locales/nl/common.json index 83bb922d8..aec84cda0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} van {total}", "plugin_panel_show": "Plug-inpaneel tonen", "plugin_panel_hide": "Plug-inpaneel verbergen", diff --git a/locales/pl/common.json b/locales/pl/common.json index ca617443d..dd96830fa 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} z {total}", "plugin_panel_show": "Pokaż panel wtyczki", "plugin_panel_hide": "Ukryj panel wtyczki", diff --git a/locales/pt/common.json b/locales/pt/common.json index bdbf61dc3..38ddb49f6 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} de {total}", "plugin_panel_show": "Mostrar o painel do plugin", "plugin_panel_hide": "Ocultar o painel do plugin", diff --git a/locales/ro/common.json b/locales/ro/common.json index e037c4e25..9bdab6c70 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} din {total}", "plugin_panel_show": "Afișează panoul pluginului", "plugin_panel_hide": "Ascunde panoul pluginului", diff --git a/locales/ru/common.json b/locales/ru/common.json index 358e3a6f6..0ed93d211 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -775,6 +775,68 @@ "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": "Местное время в этом месте, а не в вашем часовом поясе" + }, "mv_position": "{index} из {total}", "plugin_panel_show": "Показать панель плагина", "plugin_panel_hide": "Скрыть панель плагина", diff --git a/locales/sk/common.json b/locales/sk/common.json index f8b7d5eb5..5832729d1 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} z {total}", "plugin_panel_show": "Zobraziť panel doplnku", "plugin_panel_hide": "Skryť panel doplnku", diff --git a/locales/th/common.json b/locales/th/common.json index 59ecfb5c1..94d7ba68a 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1710,6 +1710,68 @@ "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": "เวลาท้องถิ่นของสถานที่ ไม่ใช่เขตเวลาของคุณ" + }, "mv_position": "{index} จาก {total}", "copy": "คัดลอก", "contact_added": "เพิ่มผู้ติดต่อแล้ว", diff --git a/locales/tr/common.json b/locales/tr/common.json index cdf7a8505..07db673e6 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -775,6 +775,68 @@ "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" + }, "mv_position": "{index} / {total}", "plugin_panel_show": "Eklenti panelini göster", "plugin_panel_hide": "Eklenti panelini gizle", diff --git a/locales/uk/common.json b/locales/uk/common.json index e645074a5..b97ae5562 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -775,6 +775,68 @@ "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": "Місцевий час у цьому місці, а не у вашому часовому поясі" + }, "mv_position": "{index} з {total}", "plugin_panel_show": "Показати панель плагіна", "plugin_panel_hide": "Сховати панель плагіна", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4d628a87d..39c66d779 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -775,6 +775,68 @@ "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": "這是該地點的當地時間,而非你所在時區的時間" + }, "mv_position": "第 {index} 封,共 {total} 封", "plugin_panel_show": "顯示外掛程式面板", "plugin_panel_hide": "隱藏外掛程式面板", diff --git a/locales/zh/common.json b/locales/zh/common.json index 80744b109..6870bcb76 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -775,6 +775,68 @@ "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": "这是该地点的当地时间,而不是你所在时区的时间" + }, "mv_position": "第 {index} 封,共 {total} 封", "plugin_panel_show": "显示插件面板", "plugin_panel_hide": "隐藏插件面板", From eb1859dbe3abdce02609a507547c74d32ef3c9b1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:10:59 +0100 Subject: [PATCH 2/5] feat: show booking, parcel and order cards in the reader --- .../__tests__/structured-mail-reader.test.tsx | 167 ++++++++++++++++++ components/email/email-viewer.tsx | 22 ++- components/email/thread-conversation-view.tsx | 12 ++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 components/email/__tests__/structured-mail-reader.test.tsx diff --git a/components/email/__tests__/structured-mail-reader.test.tsx b/components/email/__tests__/structured-mail-reader.test.tsx new file mode 100644 index 000000000..77f49d069 --- /dev/null +++ b/components/email/__tests__/structured-mail-reader.test.tsx @@ -0,0 +1,167 @@ +// 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 = `

Thanks for your order.

`; +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 { + 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(); + + 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(['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( + {}} />, + ); + + 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('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(['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( + {}} + />, + ); + // 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); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cc7d6537b..d2d950d0e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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) && (
@@ -5859,6 +5867,18 @@ export function EmailViewer({
)} + + {/* Booking, parcel and order cards (structured mail) */} + {structuredCards.length > 0 && ( +
+ +
+ )}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index f078f8d06..71e8432e6 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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,14 @@ export function ConversationEmailBody({ )} + {structuredCards.length > 0 && ( + // Aligned with the body: its own padding on the phone card, the + // caller's (className) in Mountain View. +
+ +
+ )} + {/* Email Body */}
{emailContent.isHtml ? ( From 551d8a79d54a2463ca0d0b7e4d95d0b32ab01d56 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:24:10 +0100 Subject: [PATCH 3/5] fix: keep link hosts whole and drop stale arrival dates on order cards --- .../__tests__/structured-mail-cards.test.tsx | 11 +++++++++++ .../email/structured-mail/booking-cards.tsx | 8 +++++--- components/email/structured-mail/card-parts.tsx | 5 +++-- .../email/structured-mail/shipment-card.tsx | 15 ++++++++++----- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx b/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx index a87b51475..26fd99dec 100644 --- a/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx +++ b/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx @@ -218,6 +218,17 @@ describe('StructuredMailCards', () => { await waitFor(() => expect(writeText).toHaveBeenCalledWith('SO-449120')); }); + it('drops the expected date once the order is delivered', () => { + const shipment = card('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'); + }); + 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: [], diff --git a/components/email/structured-mail/booking-cards.tsx b/components/email/structured-mail/booking-cards.tsx index 5558fc708..60a0ee27c 100644 --- a/components/email/structured-mail/booking-cards.tsx +++ b/components/email/structured-mail/booking-cards.tsx @@ -154,9 +154,11 @@ export function TransportCard({ booking, emailId, calendarActions }: BookingCard /> ) : undefined} > - {booking.legs.map((leg, i) => ( - - ))} +
+ {booking.legs.map((leg, i) => ( + + ))} +
); diff --git a/components/email/structured-mail/card-parts.tsx b/components/email/structured-mail/card-parts.tsx index 870a1efb6..79245c680 100644 --- a/components/email/structured-mail/card-parts.tsx +++ b/components/email/structured-mail/card-parts.tsx @@ -87,7 +87,7 @@ export function CardLink({ link, label, icon: Icon = ExternalLink, emailId }: { href={link.url} target="_blank" rel="noopener noreferrer" - className={ACTION_CLASS} + className={cn(ACTION_CLASS, "flex-wrap gap-y-0")} data-testid="structured-mail-link" onClick={async (e) => { e.preventDefault(); @@ -99,7 +99,8 @@ export function CardLink({ link, label, icon: Icon = ExternalLink, emailId }: { >
)} - {shipment.parcels.map((parcel, i) => )} + {shipment.parcels.map((parcel, i) => )} ); } From b429f253608a21e0969141789e16c5e7a5d89b12 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:40:16 +0100 Subject: [PATCH 4/5] fix: load no product images on conversation cards in plain-text mode --- .../email/__tests__/structured-mail-reader.test.tsx | 8 ++++++++ components/email/thread-conversation-view.tsx | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/structured-mail-reader.test.tsx b/components/email/__tests__/structured-mail-reader.test.tsx index 77f49d069..164c0d463 100644 --- a/components/email/__tests__/structured-mail-reader.test.tsx +++ b/components/email/__tests__/structured-mail-reader.test.tsx @@ -126,6 +126,14 @@ describe('cards in the conversation body', () => { 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')); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 71e8432e6..99ff0cc68 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -937,7 +937,8 @@ export function ConversationEmailBody({ // Aligned with the body: its own padding on the phone card, the // caller's (className) in Mountain View.
- + {/* No product images when the mail is shown as plain text. */} +
)} From b0782af2d1e8bfe4d0a38eeda404008df8834979 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:40:17 +0100 Subject: [PATCH 5/5] fix: keep routes and booking numbers in reading order in right-to-left languages --- .../__tests__/structured-mail-cards.test.tsx | 95 ++++++++++++- .../email/structured-mail/booking-cards.tsx | 128 +++++++++--------- .../email/structured-mail/card-parts.tsx | 15 +- .../email/structured-mail/copy-value-chip.tsx | 3 +- .../email/structured-mail/shipment-card.tsx | 10 +- lib/structured-mail/format.ts | 15 +- locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 35 files changed, 246 insertions(+), 107 deletions(-) diff --git a/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx b/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx index 26fd99dec..137abbf26 100644 --- a/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx +++ b/components/email/structured-mail/__tests__/structured-mail-cards.test.tsx @@ -3,7 +3,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } // 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) => (params ? `${key}${JSON.stringify(params)}` : key), + useTranslations: () => (key: string, params?: Record) => { + if (key === 'route') return `${params!.from} to ${params!.to}`; + return params ? `${key}${JSON.stringify(params)}` : key; + }, useLocale: () => 'en', })); @@ -53,7 +56,8 @@ describe('StructuredMailCards', () => { 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 → JFK → FRA' })).toBeInTheDocument(); + 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); @@ -143,7 +147,7 @@ describe('StructuredMailCards', () => { 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 → München' })).toBeInTheDocument(); + 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'); @@ -229,6 +233,89 @@ describe('StructuredMailCards', () => { expect(region).toHaveTextContent('00340434161234567890'); }); + describe('right-to-left place names and values', () => { + const hebrewTrain = (): TransportBooking => { + const train = card('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( +
+ +
, + ); + 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( +
+ +
, + ); + 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('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('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: [], @@ -251,6 +338,8 @@ describe('StructuredMailCards', () => { 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', () => { diff --git a/components/email/structured-mail/booking-cards.tsx b/components/email/structured-mail/booking-cards.tsx index 60a0ee27c..df211c8d1 100644 --- a/components/email/structured-mail/booking-cards.tsx +++ b/components/email/structured-mail/booking-cards.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +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 { @@ -13,12 +13,14 @@ import { legPlaceLabel, legService, nightsBetween, + routeRuns, routeTitle, sameLocalDate, } from "@/lib/structured-mail/format"; import type { Booking, EventBooking, + Link, LodgingBooking, Moment, RentalCarBooking, @@ -49,8 +51,26 @@ export interface BookingCardProps { const TRANSPORT_ICON = { flight: Plane, train: Train, bus: Bus } as const; -/** Reference chip, the booking's links and the calendar slot (render only when hasActions). */ -function BookingActions({ booking, calendarActions, links }: Omit & { links: ReactNode }) { +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 ( + + {shown.map((l) => )} + + ); +} + +function BookingActions({ booking, calendarActions, children }: Pick & { children: ReactNode }) { const t = useTranslations("email_viewer.structured_mail"); return ( <> @@ -63,17 +83,12 @@ function BookingActions({ booking, calendarActions, links }: Omit )} - {links} + {children} {calendarActions?.(booking)} ); } -function hasActions(booking: Booking, calendarActions: unknown): boolean { - return !!booking.reference || !!calendarActions - || [booking.links.checkin, booking.links.manage, booking.links.ticket].some(isWebLink); -} - function PassengersRow({ names }: { names: string[] }) { const t = useTranslations("email_viewer.structured_mail"); if (names.length === 0) return null; @@ -91,7 +106,7 @@ function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: Transpo const service = legService(leg); const Icon = TRANSPORT_ICON[kind]; const arrival = leg.arrival; - const seats = leg.seats.map((s) => (s.passenger ? `${s.passenger} ${s.seat}` : s.seat)); + return (
{(service || (cancelled && !bookingCancelled)) && ( @@ -109,29 +124,59 @@ function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: Transpo )} - · {legPlaceLabel(leg.departure.place, kind)} + · {legPlaceLabel(leg.departure.place, kind)} {arrival && ( {arrival.at && } - {arrival.at ? " · " : ""}{legPlaceLabel(arrival.place, kind)} + {arrival.at ? " · " : ""}{legPlaceLabel(arrival.place, kind)} )} - {seats.length > 0 && ( + {leg.seats.length > 0 && ( {t("seats")}: - {seats.join(", ")} + {leg.seats.map((seat, i) => ( + + {i > 0 && ", "} + {seat.passenger && <>{seat.passenger} } + {seat.seat} + + ))} )}
); } +/** + * "FRA → JFK → FRA" as parts: every stop isolated in a , 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) => ( + + {r > 0 && ", "} + {run.map((stop, i) => ( + + {i > 0 && + ))} + + ))} + + ); +} + export function TransportCard({ booking, emailId, calendarActions }: BookingCardProps) { 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 ( } + titleLabel={runs.map((run) => run.reduce((from, to) => t("route", { from, to }))).join(", ")} pills={booking.status && booking.status !== "confirmed" ? : undefined} - actions={hasActions(booking, calendarActions) ? ( - - - - - } - /> - ) : 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") }])} >
{booking.legs.map((leg, i) => ( @@ -208,16 +244,7 @@ export function LodgingCard({ booking, emailId, calendarActions }: BookingCardPr eyebrow={eyebrowText(t("kind_lodging"), booking.provider, booking.name)} title={booking.name} pills={booking.status && booking.status !== "confirmed" ? : undefined} - actions={hasActions(booking, calendarActions) ? ( - - - - } - /> - ) : undefined} + actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }, { link: booking.links.checkin, label: t("link_check_in") }])} > {address && {address}} @@ -243,13 +270,7 @@ export function RentalCarCard({ booking, emailId, calendarActions }: BookingCard eyebrow={eyebrowText(t("kind_rentalcar"), booking.provider, title)} title={title} pills={booking.status && booking.status !== "confirmed" ? : undefined} - actions={hasActions(booking, calendarActions) ? ( - } - /> - ) : undefined} + actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }])} > {t("pickup")} @@ -286,16 +307,7 @@ export function EventCard({ booking, emailId, calendarActions }: BookingCardProp eyebrow={eyebrowText(t("kind_event"), booking.provider, booking.name)} title={booking.name} pills={booking.status && booking.status !== "confirmed" ? : undefined} - actions={hasActions(booking, calendarActions) ? ( - - - - } - /> - ) : undefined} + actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.ticket, label: t("link_ticket") }, { link: booking.links.manage, label: t("link_manage") }])} > @@ -310,7 +322,7 @@ export function EventCard({ booking, emailId, calendarActions }: BookingCardProp {booking.tickets.length > 0 && ( {t("tickets")}: - {booking.tickets.join(", ")} + {booking.tickets.join(", ")} )} @@ -331,13 +343,7 @@ export function RestaurantCard({ booking, emailId, calendarActions }: BookingCar eyebrow={eyebrowText(t("kind_restaurant"), booking.provider, booking.name)} title={booking.name} pills={booking.status && booking.status !== "confirmed" ? : undefined} - actions={hasActions(booking, calendarActions) ? ( - } - /> - ) : undefined} + actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }])} > {address && {address}} diff --git a/components/email/structured-mail/card-parts.tsx b/components/email/structured-mail/card-parts.tsx index 79245c680..4c3b30323 100644 --- a/components/email/structured-mail/card-parts.tsx +++ b/components/email/structured-mail/card-parts.tsx @@ -112,7 +112,9 @@ interface CardShellProps { /** Destructive tone and a struck-through title. */ cancelled?: boolean; eyebrow: string; - title: 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; @@ -120,7 +122,7 @@ interface CardShellProps { } /** The banner layout every card shares (CalendarInvitationBanner's look). */ -export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title, pills, children, actions, kind }: CardShellProps) { +export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title, titleLabel, pills, children, actions, kind }: CardShellProps) { return (
{eyebrow}

- {title} + {titleLabel ? ( + <> + {titleLabel} + + + ) : title}

{pills &&
{pills}
} {children &&
{children}
} - {actions &&
{actions}
} + {actions &&
{actions}
}
); diff --git a/components/email/structured-mail/copy-value-chip.tsx b/components/email/structured-mail/copy-value-chip.tsx index f4c190ecd..b56c66587 100644 --- a/components/email/structured-mail/copy-value-chip.tsx +++ b/components/email/structured-mail/copy-value-chip.tsx @@ -52,7 +52,8 @@ export function CopyValueChip({ value, copyLabel, copiedMessage, label, classNam >
); } diff --git a/lib/structured-mail/format.ts b/lib/structured-mail/format.ts index 40eb56be2..bd2b6203c 100644 --- a/lib/structured-mail/format.ts +++ b/lib/structured-mail/format.ts @@ -194,11 +194,11 @@ export function legService(leg: TransportLeg): string | undefined { } /** - * The route as a title: "FRA → JFK → FRA" for flights with codes, city or - * station names otherwise. Legs that don't connect start a new run, - * separated by a comma. + * 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 routeTitle(booking: TransportBooking): string { +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()); @@ -220,7 +220,12 @@ export function routeTitle(booking: TransportBooking): string { runs.push(to ? [from, to] : [from]); } } - return runs.map((run) => run.join(' → ')).join(', '); + 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. */ diff --git a/locales/ar/common.json b/locales/ar/common.json index a83b48b0e..5e23c851f 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -835,7 +835,8 @@ "total": "الإجمالي {total}", "expected_by": "يصل بحلول {date}", "local_time": "بالتوقيت المحلي", - "local_time_title": "بالتوقيت المحلي للمكان، وليس بمنطقتك الزمنية" + "local_time_title": "بالتوقيت المحلي للمكان، وليس بمنطقتك الزمنية", + "route": "{from} إلى {to}" }, "mv_position": "{index} من {total}", "plugin_panel_show": "إظهار لوحة الإضافة", diff --git a/locales/ca/common.json b/locales/ca/common.json index a1205deb8..011f70296 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 3aa45311e..f8142705c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/da/common.json b/locales/da/common.json index a6e6fc70f..42abc69ce 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -835,7 +835,8 @@ "total": "I alt {total}", "expected_by": "Ankommer senest {date}", "local_time": "lokal tid", - "local_time_title": "Lokal tid på stedet, ikke din tidszone" + "local_time_title": "Lokal tid på stedet, ikke din tidszone", + "route": "{from} til {to}" }, "mv_position": "{index} af {total}", "plugin_panel_show": "Vis pluginpanel", diff --git a/locales/de/common.json b/locales/de/common.json index 17eb2ac87..fb71f758c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -835,7 +835,8 @@ "total": "Gesamt {total}", "expected_by": "Ankunft bis {date}", "local_time": "Ortszeit", - "local_time_title": "Uhrzeit vor Ort, nicht in Ihrer Zeitzone" + "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", diff --git a/locales/en/common.json b/locales/en/common.json index 908772642..794a4ceae 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -835,7 +835,8 @@ "total": "Total {total}", "expected_by": "Arrives by {date}", "local_time": "local time", - "local_time_title": "Local time at the place, not your time zone" + "local_time_title": "Local time at the place, not your time zone", + "route": "{from} to {to}" }, "mv_position": "{index} of {total}", "copy": "Copy", diff --git a/locales/es/common.json b/locales/es/common.json index 8235bfbca..1f1c4dc57 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/et/common.json b/locales/et/common.json index eef9796ce..2796ae65d 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -835,7 +835,8 @@ "total": "Kokku {total}", "expected_by": "Saabub hiljemalt {date}", "local_time": "kohalik aeg", - "local_time_title": "Kohalik aeg sihtkohas, mitte sinu ajavööndis" + "local_time_title": "Kohalik aeg sihtkohas, mitte sinu ajavööndis", + "route": "{from} – {to}" }, "mv_position": "{index} / {total}", "plugin_panel_show": "Näita pistikprogrammi paneeli", diff --git a/locales/fa/common.json b/locales/fa/common.json index 71dd73e10..eb9c8e662 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -835,7 +835,8 @@ "total": "مجموع {total}", "expected_by": "رسیدن تا {date}", "local_time": "به وقت محلی", - "local_time_title": "به وقت محلی همان مکان، نه منطقه زمانی شما" + "local_time_title": "به وقت محلی همان مکان، نه منطقه زمانی شما", + "route": "{from} به {to}" }, "mv_position": "{index} از {total}", "plugin_panel_show": "نمایش پنل افزونه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 947a69273..f1864ba6b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/he/common.json b/locales/he/common.json index d27ac0ba7..4613cc593 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -800,7 +800,8 @@ "total": "סה\"כ {total}", "expected_by": "יגיע עד {date}", "local_time": "שעון מקומי", - "local_time_title": "השעה המקומית במקום, לא באזור הזמן שלך" + "local_time_title": "השעה המקומית במקום, לא באזור הזמן שלך", + "route": "{from} – {to}" }, "mv_position": "{index} מתוך {total}", "plugin_panel_show": "הצגת חלונית התוסף", diff --git a/locales/hu/common.json b/locales/hu/common.json index 17f90d0f0..9ccb95bce 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/it/common.json b/locales/it/common.json index ca77a08bb..e773253db 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -835,7 +835,8 @@ "total": "Totale {total}", "expected_by": "Arriva entro {date}", "local_time": "ora locale", - "local_time_title": "Ora locale del luogo, non del tuo fuso orario" + "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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7dd7a8d73..3de4341b6 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -835,7 +835,8 @@ "total": "合計 {total}", "expected_by": "{date}までにお届け予定", "local_time": "現地時間", - "local_time_title": "あなたのタイムゾーンではなく、その場所の現地時間です" + "local_time_title": "あなたのタイムゾーンではなく、その場所の現地時間です", + "route": "{from} – {to}" }, "mv_position": "{index} / {total}", "plugin_panel_show": "プラグインパネルを表示", diff --git a/locales/ko/common.json b/locales/ko/common.json index a81d52b58..e162dfe8d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -835,7 +835,8 @@ "total": "합계 {total}", "expected_by": "{date}까지 도착 예정", "local_time": "현지 시간", - "local_time_title": "사용자의 시간대가 아닌 해당 장소의 현지 시간입니다" + "local_time_title": "사용자의 시간대가 아닌 해당 장소의 현지 시간입니다", + "route": "{from} – {to}" }, "mv_position": "{index} / {total}", "plugin_panel_show": "플러그인 패널 표시", diff --git a/locales/lv/common.json b/locales/lv/common.json index f753b6cd9..6b791f8d3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -835,7 +835,8 @@ "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ā" + "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", diff --git a/locales/mn/common.json b/locales/mn/common.json index b84057eee..8424ca6b9 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -835,7 +835,8 @@ "total": "Нийт {total}", "expected_by": "{date}-ны дотор ирнэ", "local_time": "орон нутгийн цаг", - "local_time_title": "Тухайн газрын орон нутгийн цаг, таны цагийн бүс биш" + "local_time_title": "Тухайн газрын орон нутгийн цаг, таны цагийн бүс биш", + "route": "{from} – {to}" }, "mv_position": "{total}-с {index}", "plugin_panel_show": "Залгаасын самбарыг харуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 5b43ab0d8..02c12350a 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -835,7 +835,8 @@ "total": "Totalt {total}", "expected_by": "Kommer innen {date}", "local_time": "lokal tid", - "local_time_title": "Lokal tid på stedet, ikke din tidssone" + "local_time_title": "Lokal tid på stedet, ikke din tidssone", + "route": "{from} til {to}" }, "mv_position": "{index} av {total}", "plugin_panel_show": "Vis tilleggspanel", diff --git a/locales/nl/common.json b/locales/nl/common.json index aec84cda0..328683689 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -835,7 +835,8 @@ "total": "Totaal {total}", "expected_by": "Verwacht uiterlijk {date}", "local_time": "lokale tijd", - "local_time_title": "Lokale tijd ter plaatse, niet in je eigen tijdzone" + "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", diff --git a/locales/pl/common.json b/locales/pl/common.json index dd96830fa..34098e164 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 38ddb49f6..e34121053 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 9bdab6c70..ab19c8d1c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 0ed93d211..0f8c7d9ef 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -835,7 +835,8 @@ "total": "Итого {total}", "expected_by": "Доставка до {date}", "local_time": "местное время", - "local_time_title": "Местное время в этом месте, а не в вашем часовом поясе" + "local_time_title": "Местное время в этом месте, а не в вашем часовом поясе", + "route": "{from} — {to}" }, "mv_position": "{index} из {total}", "plugin_panel_show": "Показать панель плагина", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5832729d1..040b3e152 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/th/common.json b/locales/th/common.json index 94d7ba68a..0482f0086 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1770,7 +1770,8 @@ "total": "รวม {total}", "expected_by": "ถึงภายใน {date}", "local_time": "เวลาท้องถิ่น", - "local_time_title": "เวลาท้องถิ่นของสถานที่ ไม่ใช่เขตเวลาของคุณ" + "local_time_title": "เวลาท้องถิ่นของสถานที่ ไม่ใช่เขตเวลาของคุณ", + "route": "{from} ไป {to}" }, "mv_position": "{index} จาก {total}", "copy": "คัดลอก", diff --git a/locales/tr/common.json b/locales/tr/common.json index 07db673e6..8e2a42bc5 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -835,7 +835,8 @@ "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" + "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", diff --git a/locales/uk/common.json b/locales/uk/common.json index b97ae5562..0f76178d0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -835,7 +835,8 @@ "total": "Разом {total}", "expected_by": "Доставка до {date}", "local_time": "місцевий час", - "local_time_title": "Місцевий час у цьому місці, а не у вашому часовому поясі" + "local_time_title": "Місцевий час у цьому місці, а не у вашому часовому поясі", + "route": "{from} — {to}" }, "mv_position": "{index} з {total}", "plugin_panel_show": "Показати панель плагіна", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 39c66d779..aba8e8a9c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -835,7 +835,8 @@ "total": "總計 {total}", "expected_by": "預計 {date} 前送達", "local_time": "當地時間", - "local_time_title": "這是該地點的當地時間,而非你所在時區的時間" + "local_time_title": "這是該地點的當地時間,而非你所在時區的時間", + "route": "{from} 至 {to}" }, "mv_position": "第 {index} 封,共 {total} 封", "plugin_panel_show": "顯示外掛程式面板", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6870bcb76..1edf0891f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -835,7 +835,8 @@ "total": "合计 {total}", "expected_by": "预计 {date} 前送达", "local_time": "当地时间", - "local_time_title": "这是该地点的当地时间,而不是你所在时区的时间" + "local_time_title": "这是该地点的当地时间,而不是你所在时区的时间", + "route": "{from} 至 {to}" }, "mv_position": "第 {index} 封,共 {total} 封", "plugin_panel_show": "显示插件面板",