mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep routes and booking numbers in reading order in right-to-left languages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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": "إظهار لوحة الإضافة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "نمایش پنل افزونه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "הצגת חלונית התוסף",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プラグインパネルを表示",
|
||||
|
||||
@@ -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": "플러그인 패널 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Залгаасын самбарыг харуулах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показать панель плагина",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "คัดลอก",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показати панель плагіна",
|
||||
|
||||
@@ -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": "顯示外掛程式面板",
|
||||
|
||||
@@ -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": "显示插件面板",
|
||||
|
||||
Reference in New Issue
Block a user