fix: keep routes and booking numbers in reading order in right-to-left languages

This commit is contained in:
Linus Rath
2026-10-30 05:40:17 +01:00
parent b429f25360
commit b0782af2d1
35 changed files with 246 additions and 107 deletions
@@ -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<string, unknown>) => (params ? `${key}${JSON.stringify(params)}` : key),
useTranslations: () => (key: string, params?: Record<string, unknown>) => {
if (key === 'route') return `${params!.from} to ${params!.to}`;
return params ? `${key}${JSON.stringify(params)}` : key;
},
useLocale: () => 'en',
}));
@@ -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<TransportBooking>('train-multiple-types');
train.legs[0].departure.place = { name: 'תל אביב סבידור מרכז', address: { locality: 'תל אביב', country: 'IL' } };
train.legs[0].arrival!.place = { name: 'חיפה מרכז השמונה', address: { locality: 'חיפה', country: 'IL' } };
return train;
};
it.each(['rtl', 'ltr'] as const)('isolates each stop and draws the arrow as a mirrored icon (%s UI)', (dir) => {
render(
<div dir={dir}>
<StructuredMailCards cards={[hebrewTrain()]} email={{ id: 'm1' }} allowRemoteImages={false} />
</div>,
);
const region = screen.getByRole('region', { name: 'region_train' });
// Spoken in reading order, whatever the script.
expect(within(region).getByRole('heading', { name: 'תל אביב to חיפה' })).toBeInTheDocument();
const stops = within(region).getAllByTestId('structured-mail-stop');
expect(stops.map((el) => el.tagName)).toEqual(['BDI', 'BDI']);
expect(stops.map((el) => el.textContent)).toEqual(['תל אביב', 'חיפה']);
const visual = within(region).getByTestId('structured-mail-title-visual');
// No arrow character the bidi algorithm could turn around: an icon between the stops.
expect(visual.textContent).not.toMatch(/[→←]/);
const arrow = visual.querySelector('svg');
expect(arrow).toHaveClass('tabler-icon-arrow-right');
expect(arrow!.compareDocumentPosition(stops[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(stops[0].compareDocumentPosition(arrow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// Station names in the leg rows are isolated too.
const leg = within(region).getByTestId('structured-mail-leg');
expect([...leg.querySelectorAll('bdi')].map((el) => el.textContent)).toEqual(expect.arrayContaining(['תל אביב סבידור מרכז', 'חיפה מרכז השמונה']));
});
it('relies on the global mirror for that arrow in RTL', async () => {
const { readFileSync } = await import('node:fs');
const css = readFileSync(`${process.cwd()}/app/globals.css`, 'utf8');
expect(css).toMatch(/\[dir="rtl"\] \.tabler-icon-arrow-right/);
});
it('keeps references, tracking and order numbers, seats and tickets left to right', () => {
render(
<div dir="rtl">
<StructuredMailCards
cards={[card('flight-two-passengers-two-legs'), card('ahoi-event'), card('order-and-parcel')]}
email={{ id: 'm1' }}
allowRemoteImages={false}
/>
</div>,
);
for (const value of ['QX7K2M', 'AB12CD', 'SO-449120', '00340434161234567890']) {
expect(screen.getAllByText(value)[0]).toHaveAttribute('dir', 'ltr');
}
const seats = screen.getAllByTestId('structured-mail-seat');
expect(seats.map((el) => el.textContent)).toEqual(['12A', '12B', '31K', '31J']);
seats.forEach((el) => expect(el).toHaveAttribute('dir', 'ltr'));
expect(screen.getByTestId('structured-mail-tickets')).toHaveAttribute('dir', 'ltr');
expect(screen.getByTestId('structured-mail-tickets')).toHaveTextContent('AB12CD-1');
});
});
it('shows no action row when a card has nothing it would put there', () => {
const restaurant = card<Booking>('restaurant-cafe');
delete restaurant.reference;
// A restaurant card shows no ticket link, so this alone gives no row.
restaurant.links = { ticket: { url: 'https://tables.example.com/t/1', host: 'tables.example.com' } };
show([restaurant]);
const region = screen.getByRole('region', { name: 'region_restaurant' });
expect(within(region).queryByTestId('structured-mail-actions')).toBeNull();
expect(within(region).queryByRole('link')).toBeNull();
});
it('shows one Track button per tracking page', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
const shipment = card<Shipment>('order-and-parcel');
const parcel = shipment.parcels[0];
// The same parcel twice (same number and page), and a second one with its own page.
shipment.parcels = [parcel, { ...parcel }, { ...parcel, trackingNumber: '00340434169999999999', trackingUrl: { url: 'https://www.dhl.de/x?piececode=00340434169999999999', host: 'dhl.de' } }];
show([shipment]);
const region = screen.getByRole('region', { name: 'region_shipment' });
const tracks = within(region).getAllByRole('link', { name: /link_track/ });
expect(tracks.map((a) => a.getAttribute('href'))).toEqual([parcel.trackingUrl!.url, 'https://www.dhl.de/x?piececode=00340434169999999999']);
expect(errors.mock.calls.flat().join(' ')).not.toMatch(/same key/);
});
it('lists three items and says how many more there are', () => {
const shipment: Shipment = {
kind: 'shipment', key: 's', merchant: 'Shop', orderNumber: 'A-1', source: 'json-ld', parcels: [],
@@ -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', () => {
@@ -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<B extends Booking = Booking> {
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<BookingCardProps, "emailId"> & { 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 (
<BookingActions booking={booking} calendarActions={calendarActions}>
{shown.map((l) => <CardLink key={l.label} link={l.link} label={l.label} emailId={emailId} />)}
</BookingActions>
);
}
function BookingActions({ booking, calendarActions, children }: Pick<BookingCardProps, "booking" | "calendarActions"> & { children: ReactNode }) {
const t = useTranslations("email_viewer.structured_mail");
return (
<>
@@ -63,17 +83,12 @@ function BookingActions({ booking, calendarActions, links }: Omit<BookingCardPro
className={ACTION_CHIP_CLASS}
/>
)}
{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 (
<div className="space-y-0.5 min-w-0" data-testid="structured-mail-leg" data-cancelled={cancelled || undefined}>
{(service || (cancelled && !bookingCancelled)) && (
@@ -109,29 +124,59 @@ function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: Transpo
)}
<MetaRow icon={Clock}>
<When at={leg.departure.at} struck={cancelled} />
<span className={cn(cancelled && "line-through")}> · {legPlaceLabel(leg.departure.place, kind)}</span>
<span className={cn(cancelled && "line-through")}> · <bdi>{legPlaceLabel(leg.departure.place, kind)}</bdi></span>
</MetaRow>
{arrival && (
<MetaRow icon={ArrowRight}>
{arrival.at && <When at={arrival.at} timeOnly={sameLocalDate(arrival.at, leg.departure.at)} struck={cancelled} />}
<span className={cn(cancelled && "line-through")}>{arrival.at ? " · " : ""}{legPlaceLabel(arrival.place, kind)}</span>
<span className={cn(cancelled && "line-through")}>{arrival.at ? " · " : ""}<bdi>{legPlaceLabel(arrival.place, kind)}</bdi></span>
</MetaRow>
)}
{seats.length > 0 && (
{leg.seats.length > 0 && (
<MetaRow icon={Ticket}>
<span>{t("seats")}: </span>
{seats.join(", ")}
{leg.seats.map((seat, i) => (
<Fragment key={`${seat.passenger ?? ""}-${seat.seat}-${i}`}>
{i > 0 && ", "}
{seat.passenger && <><bdi>{seat.passenger}</bdi> </>}
<bdi dir="ltr" data-testid="structured-mail-seat">{seat.seat}</bdi>
</Fragment>
))}
</MetaRow>
)}
</div>
);
}
/**
* "FRA → JFK → FRA" as parts: every stop isolated in a <bdi>, and ArrowRight,
* which globals.css mirrors under [dir=rtl], between them, so the arrows
* point the reading direction whatever script the names are in.
*/
function RouteTitle({ runs }: { runs: string[][] }) {
return (
<>
{runs.map((run, r) => (
<Fragment key={r}>
{r > 0 && ", "}
{run.map((stop, i) => (
<Fragment key={i}>
{i > 0 && <ArrowRight aria-hidden="true" className="inline-block w-3.5 h-3.5 mx-1 align-[-0.125em]" />}
<bdi data-testid="structured-mail-stop">{stop}</bdi>
</Fragment>
))}
</Fragment>
))}
</>
);
}
export function TransportCard({ booking, emailId, calendarActions }: BookingCardProps<TransportBooking>) {
const t = useTranslations("email_viewer.structured_mail");
const kindLabel = booking.kind === "flight" ? t("kind_flight") : booking.kind === "train" ? t("kind_train") : t("kind_bus");
const name = booking.kind === "flight" ? t("region_flight") : booking.kind === "train" ? t("region_train") : t("region_bus");
const title = routeTitle(booking);
const runs = routeRuns(booking);
const cancelled = booking.status === "cancelled";
return (
<CardShell
@@ -140,19 +185,10 @@ export function TransportCard({ booking, emailId, calendarActions }: BookingCard
icon={TRANSPORT_ICON[booking.kind]}
cancelled={cancelled}
eyebrow={eyebrowText(kindLabel, booking.provider, title)}
title={title}
title={<RouteTitle runs={runs} />}
titleLabel={runs.map((run) => run.reduce((from, to) => t("route", { from, to }))).join(", ")}
pills={booking.status && booking.status !== "confirmed" ? <BookingStatusPill status={booking.status} /> : undefined}
actions={hasActions(booking, calendarActions) ? (
<BookingActions
booking={booking}
calendarActions={calendarActions}
links={<>
<CardLink link={booking.links.checkin} label={t("link_check_in")} emailId={emailId} />
<CardLink link={booking.links.manage} label={t("link_manage")} emailId={emailId} />
<CardLink link={booking.links.ticket} label={t("link_ticket")} emailId={emailId} />
</>}
/>
) : undefined}
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.checkin, label: t("link_check_in") }, { link: booking.links.manage, label: t("link_manage") }, { link: booking.links.ticket, label: t("link_ticket") }])}
>
<div className="flex flex-col gap-2 min-w-0">
{booking.legs.map((leg, i) => (
@@ -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" ? <BookingStatusPill status={booking.status} /> : undefined}
actions={hasActions(booking, calendarActions) ? (
<BookingActions
booking={booking}
calendarActions={calendarActions}
links={<>
<CardLink link={booking.links.manage} label={t("link_manage")} emailId={emailId} />
<CardLink link={booking.links.checkin} label={t("link_check_in")} emailId={emailId} />
</>}
/>
) : undefined}
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }, { link: booking.links.checkin, label: t("link_check_in") }])}
>
{address && <MetaRow icon={MapPin}>{address}</MetaRow>}
<StayEnd label={t("check_in_label")} at={booking.checkin} edge="from" />
@@ -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" ? <BookingStatusPill status={booking.status} /> : undefined}
actions={hasActions(booking, calendarActions) ? (
<BookingActions
booking={booking}
calendarActions={calendarActions}
links={<CardLink link={booking.links.manage} label={t("link_manage")} emailId={emailId} />}
/>
) : undefined}
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }])}
>
<MetaRow icon={Clock}>
<span>{t("pickup")} </span>
@@ -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" ? <BookingStatusPill status={booking.status} /> : undefined}
actions={hasActions(booking, calendarActions) ? (
<BookingActions
booking={booking}
calendarActions={calendarActions}
links={<>
<CardLink link={booking.links.ticket} label={t("link_ticket")} emailId={emailId} />
<CardLink link={booking.links.manage} label={t("link_manage")} emailId={emailId} />
</>}
/>
) : undefined}
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.ticket, label: t("link_ticket") }, { link: booking.links.manage, label: t("link_manage") }])}
>
<MetaRow icon={Clock}>
<When at={booking.start} />
@@ -310,7 +322,7 @@ export function EventCard({ booking, emailId, calendarActions }: BookingCardProp
{booking.tickets.length > 0 && (
<MetaRow icon={Ticket}>
<span>{t("tickets")}: </span>
<span className="font-mono break-all">{booking.tickets.join(", ")}</span>
<bdi dir="ltr" className="font-mono break-all" data-testid="structured-mail-tickets">{booking.tickets.join(", ")}</bdi>
</MetaRow>
)}
<PassengersRow names={booking.passengers} />
@@ -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" ? <BookingStatusPill status={booking.status} /> : undefined}
actions={hasActions(booking, calendarActions) ? (
<BookingActions
booking={booking}
calendarActions={calendarActions}
links={<CardLink link={booking.links.manage} label={t("link_manage")} emailId={emailId} />}
/>
) : undefined}
actions={bookingActions(booking, calendarActions, emailId, [{ link: booking.links.manage, label: t("link_manage") }])}
>
<MetaRow icon={Clock}><When at={booking.start} /></MetaRow>
{address && <MetaRow icon={MapPin}>{address}</MetaRow>}
@@ -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 (
<section aria-label={name} className="flex items-start gap-3 min-w-0" data-testid="structured-mail-card" data-kind={kind}>
<div
@@ -136,12 +138,17 @@ export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title,
<div className="min-w-0">
<div className="text-[0.6875rem] font-semibold text-muted-foreground break-words">{eyebrow}</div>
<h3 className={cn("text-sm font-semibold leading-snug break-words", cancelled ? "line-through text-muted-foreground" : "text-foreground")}>
{title}
{titleLabel ? (
<>
<span className="sr-only">{titleLabel}</span>
<span aria-hidden="true" data-testid="structured-mail-title-visual">{title}</span>
</>
) : title}
</h3>
</div>
{pills && <div className="flex flex-wrap items-center gap-1.5">{pills}</div>}
{children && <div className="flex flex-col gap-1 text-sm text-muted-foreground">{children}</div>}
{actions && <div className="flex flex-wrap items-center gap-1.5 pt-0.5">{actions}</div>}
{actions && <div className="flex flex-wrap items-center gap-1.5 pt-0.5" data-testid="structured-mail-actions">{actions}</div>}
</div>
</section>
);
@@ -52,7 +52,8 @@ export function CopyValueChip({ value, copyLabel, copiedMessage, label, classNam
>
<Icon aria-hidden="true" className={cn(CHIP_ICON_CLASS, copied ? "text-success" : "text-muted-foreground")} />
{label && <span className="font-normal text-muted-foreground">{label}</span>}
<span className="min-w-0 break-all font-mono tracking-wide">{value}</span>
{/* Digit groups keep their order in a right-to-left UI. */}
<bdi dir="ltr" className="min-w-0 break-all font-mono tracking-wide">{value}</bdi>
</button>
);
}
@@ -47,7 +47,7 @@ function ItemImages({ items }: { items: Item[] }) {
<img
key={`${item.image!.url}-${i}`}
src={item.image!.url}
alt={item.name}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
className="h-10 w-10 rounded-md border border-border bg-background object-cover"
@@ -102,7 +102,9 @@ export function ShipmentCard({ shipment, emailId, allowRemoteImages }: ShipmentC
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));
// One Track button per distinct tracking page.
const trackLinks = shipment.parcels.map((p) => p.trackingUrl)
.filter((l, i, all): l is NonNullable<typeof l> => isWebLink(l) && all.findIndex((o) => o?.url === l!.url) === i);
const hasOrderLink = isWebLink(shipment.orderUrl);
const cancelled = shipment.status === "cancelled";
@@ -118,7 +120,7 @@ export function ShipmentCard({ shipment, emailId, allowRemoteImages }: ShipmentC
actions={trackLinks.length > 0 || hasOrderLink ? (
<>
{trackLinks.map((link, i) => (
<CardLink key={`${link!.url}-${i}`} link={link} label={t("link_track")} icon={Truck} emailId={emailId} />
<CardLink key={`${link.url}-${i}`} link={link} label={t("link_track")} icon={Truck} emailId={emailId} />
))}
<CardLink link={shipment.orderUrl} label={t("link_view_order")} icon={ExternalLink} emailId={emailId} />
</>
@@ -149,7 +151,7 @@ export function ShipmentCard({ shipment, emailId, allowRemoteImages }: ShipmentC
.filter(Boolean).join(" · ")}
</div>
)}
{shipment.parcels.map((parcel, i) => <ParcelRows key={parcel.trackingNumber ?? i} parcel={parcel} showExpected={!ARRIVED_OR_STOPPED.has(shipment.status ?? "processing")} />)}
{shipment.parcels.map((parcel, i) => <ParcelRows key={`${parcel.trackingNumber ?? ""}-${i}`} parcel={parcel} showExpected={!ARRIVED_OR_STOPPED.has(shipment.status ?? "processing")} />)}
</CardShell>
);
}
+10 -5
View File
@@ -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. */
+2 -1
View File
@@ -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": "إظهار لوحة الإضافة",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "نمایش پنل افزونه",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "הצגת חלונית התוסף",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "プラグインパネルを表示",
+2 -1
View File
@@ -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": "플러그인 패널 표시",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "Залгаасын самбарыг харуулах",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "Показать панель плагина",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "คัดลอก",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "Показати панель плагіна",
+2 -1
View File
@@ -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": "顯示外掛程式面板",
+2 -1
View File
@@ -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": "显示插件面板",