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] 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": "显示插件面板",