@tribe-nest/forge 3.21.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useEvents.ts +66 -4
  4. package/src/data/queries/useMembership.ts +8 -2
  5. package/src/data/queries/useMyBookings.ts +12 -0
  6. package/src/data/queries/useMyTickets.ts +112 -0
  7. package/src/data/queries/useOrders.ts +10 -0
  8. package/src/data/queries/usePassTransfers.ts +73 -13
  9. package/src/server/index.ts +52 -0
  10. package/src/types/models.ts +151 -0
  11. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  12. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  13. package/src/ui/format/attendees.ts +187 -0
  14. package/src/ui/format/membershipGate.ts +209 -0
  15. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  16. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  17. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  18. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  19. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  20. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  22. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  23. package/src/ui/headless/index.ts +25 -0
  24. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  25. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  26. package/src/ui/index.ts +42 -0
  27. package/src/ui/styled/AccountDashboard.tsx +70 -8
  28. package/src/ui/styled/AddToCalendar.tsx +34 -10
  29. package/src/ui/styled/Checkout.tsx +18 -1
  30. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  31. package/src/ui/styled/CourseDetail.tsx +30 -1
  32. package/src/ui/styled/EventConfirmation.tsx +2 -0
  33. package/src/ui/styled/EventDetail.tsx +53 -22
  34. package/src/ui/styled/EventTickets.tsx +156 -5
  35. package/src/ui/styled/HoldNotice.tsx +192 -0
  36. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  37. package/src/ui/styled/OfferButton.tsx +23 -0
  38. package/src/ui/styled/PresaleCode.tsx +174 -0
  39. package/src/ui/styled/ProductDetail.tsx +75 -5
  40. package/src/ui/styled/ProductGrid.tsx +26 -0
  41. package/src/ui/styled/TicketTransfer.tsx +69 -40
  42. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  43. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  44. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  45. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  46. package/src/utils/_tests/structuredData.spec.ts +275 -0
  47. package/src/utils/presaleCode.ts +96 -0
  48. package/src/utils/structuredData.ts +361 -27
@@ -1,6 +1,6 @@
1
1
  import { Calendar, MapPin } from "lucide-react";
2
2
  import type { IEvent } from "../../types/models";
3
- import { useEvent } from "../../data/queries/useEvents";
3
+ import { usePresaleCode } from "../headless/event/usePresaleCode";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -11,6 +11,7 @@ import { EventTickets } from "./EventTickets";
11
11
  import { EventCountdown } from "./EventCountdown";
12
12
  import { EventWaitlist } from "./EventWaitlist";
13
13
  import { AddToCalendar } from "./AddToCalendar";
14
+ import { PresaleCodeField } from "./PresaleCode";
14
15
 
15
16
  export interface EventDetailProps {
16
17
  /** Event slug (resolves the event). */
@@ -59,7 +60,11 @@ export function EventDetail({
59
60
  const t = useThemeTokens();
60
61
  const fmt = useAmountFormatter(formatAmount);
61
62
  const pricesIncludeTax = usePricesIncludeTax();
62
- const { data: event, isLoading } = useEvent(slug, { initialData: initialEvent });
63
+ // Read the event THROUGH the presale field: it is the same query, fetched
64
+ // with whatever code this buyer has applied, so `tickets` below already
65
+ // contains the tiers that code unlocked.
66
+ const presale = usePresaleCode(slug, { initialEvent });
67
+ const { event, isLoading } = presale;
63
68
 
64
69
  if (isLoading) return <Loading fullPage />;
65
70
  if (!event) return <p>Event not found.</p>;
@@ -116,6 +121,11 @@ export function EventDetail({
116
121
  end={event.endDateTime}
117
122
  description={event.description}
118
123
  location={locationText}
124
+ // The platform's own `.ics`, which is what makes an Apple option
125
+ // possible at all. Absent on an older API build, in which case
126
+ // the control quietly falls back to the three template providers.
127
+ icsUrl={event.calendarUrl}
128
+ webcalUrl={event.calendarWebcalUrl}
119
129
  style={{ marginTop: 16 }}
120
130
  />
121
131
  )}
@@ -133,7 +143,12 @@ export function EventDetail({
133
143
  <EventWaitlist event={event} style={{ marginTop: 24 }} />
134
144
  </div>
135
145
 
136
- {event.tickets.length > 0 && (
146
+ {/* The card also appears for an event with NO visible tickets when a
147
+ presale is the only thing on sale (1.2). Every tier hidden behind a
148
+ code makes `tickets` empty, so gating the card on the tier list alone
149
+ left a presale-only event with no box, no button and nothing to do —
150
+ which is the shape most presales are announced in. */}
151
+ {(event.tickets.length > 0 || presale.visible) && (
137
152
  <aside
138
153
  className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
139
154
  style={{
@@ -143,27 +158,43 @@ export function EventDetail({
143
158
  background: t.surface,
144
159
  }}
145
160
  >
146
- <span style={{ fontWeight: 600 }}>
147
- From{" "}
148
- <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
149
- </span>
150
- {/* The cheapest tier is the number a buyer anchors on, and until now
151
- it was the whole story they were given — the artist's booking fee
152
- was added at checkout and named nowhere. There is no selection
153
- here, so there is no amount to quote; the RULE is what makes the
154
- "From" figure honest. Renders nothing when no fee is charged. */}
155
- {feeNote && (
156
- <span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
157
- {feeNote}
161
+ {/* Above the price, because on a presale-only event it is the only
162
+ way to reach one. Renders nothing on an event with no coded
163
+ tier. */}
164
+ <PresaleCodeField presale={presale} />
165
+ {event.tickets.length === 0 && (
166
+ <span style={{ fontSize: 13, opacity: 0.7 }}>
167
+ Tickets for this event are presale only.
158
168
  </span>
159
169
  )}
160
- <EventTickets
161
- slug={slug}
162
- triggerText="Buy tickets"
163
- formatAmount={fmt}
164
- finalisePath={finalisePath}
165
- onComplete={onComplete}
166
- />
170
+ {event.tickets.length > 0 && (
171
+ <>
172
+ <span style={{ fontWeight: 600 }}>
173
+ From{" "}
174
+ <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
175
+ </span>
176
+ {/* The cheapest tier is the number a buyer anchors on, and until
177
+ now it was the whole story they were given — the artist's
178
+ booking fee was added at checkout and named nowhere. There is
179
+ no selection here, so there is no amount to quote; the RULE is
180
+ what makes the "From" figure honest. Renders nothing when no
181
+ fee is charged, and nothing at all with no price to qualify. */}
182
+ {feeNote && (
183
+ <span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
184
+ {feeNote}
185
+ </span>
186
+ )}
187
+ </>
188
+ )}
189
+ {event.tickets.length > 0 && (
190
+ <EventTickets
191
+ slug={slug}
192
+ triggerText="Buy tickets"
193
+ formatAmount={fmt}
194
+ finalisePath={finalisePath}
195
+ onComplete={onComplete}
196
+ />
197
+ )}
167
198
  </aside>
168
199
  )}
169
200
  </div>
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
2
- import { AlertCircle, Clock, X } from "lucide-react";
2
+ import { AlertCircle, Clock, KeyRound, X } from "lucide-react";
3
3
  import type { IEvent, ITicket } from "../../types/models";
4
4
  import { useEventCheckout } from "../headless/event/useEventCheckout";
5
5
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
@@ -10,7 +10,10 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
10
10
  import { Loading } from "./Loading";
11
11
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
12
12
  import { buttonStyle } from "./Button";
13
+ import { MembershipGateNotice } from "./MembershipGateNotice";
13
14
  import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
15
+ import { PresaleCodeField } from "./PresaleCode";
16
+ import { HoldNotice } from "./HoldNotice";
14
17
  import {
15
18
  DialogRoot,
16
19
  DialogTrigger,
@@ -34,6 +37,13 @@ export interface EventTicketsProps {
34
37
  finalisePath?: (slug: string, orderId: string) => string;
35
38
  /** Host-owned navigation on FREE completion (e.g. router navigate). */
36
39
  onComplete?: (info: { slug: string; orderId: string }) => void;
40
+ /**
41
+ * Where a members-only tier sends a signed-OUT visitor. Default `/login`;
42
+ * code sites mount theirs under `/i`. The sign-in returns them here.
43
+ */
44
+ loginPath?: string;
45
+ /** Where a members-only tier sends a signed-in NON-member — the tier listing. */
46
+ membershipPath?: string;
37
47
  }
38
48
 
39
49
  /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
@@ -51,6 +61,8 @@ export function EventTickets({
51
61
  renderPayment,
52
62
  finalisePath,
53
63
  onComplete,
64
+ loginPath,
65
+ membershipPath,
54
66
  }: EventTicketsProps) {
55
67
  const theme = useForgeTheme();
56
68
  const registered = usePaymentRenderer();
@@ -109,6 +121,8 @@ export function EventTickets({
109
121
  event={c.event}
110
122
  fmt={fmt}
111
123
  renderPayment={renderPay}
124
+ loginPath={loginPath}
125
+ membershipPath={membershipPath}
112
126
  // Selection is safe in the cart now — close so the buyer can go
113
127
  // browse the add-ons.
114
128
  onAddedToCart={() => setOpen(false)}
@@ -128,19 +142,34 @@ function EventTicketsBody({
128
142
  fmt,
129
143
  renderPayment,
130
144
  onAddedToCart,
145
+ loginPath,
146
+ membershipPath,
131
147
  }: {
132
148
  c: Checkout;
133
149
  event: IEvent;
134
150
  fmt: (n: number) => string;
135
151
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
136
152
  onAddedToCart?: () => void;
153
+ loginPath?: string;
154
+ membershipPath?: string;
137
155
  }) {
138
156
  const stepIndex = c.step === "tickets" ? 0 : c.step === "details" ? 1 : 2;
139
157
  return (
140
158
  <div style={{ width: "100%" }}>
141
159
  <Progress current={stepIndex} />
142
- {c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} onAddedToCart={onAddedToCart} />}
143
- {c.step === "details" && <DetailsStep c={c} event={event} fmt={fmt} />}
160
+ {c.step === "tickets" && (
161
+ <TicketStep
162
+ c={c}
163
+ event={event}
164
+ fmt={fmt}
165
+ onAddedToCart={onAddedToCart}
166
+ loginPath={loginPath}
167
+ membershipPath={membershipPath}
168
+ />
169
+ )}
170
+ {c.step === "details" && (
171
+ <DetailsStep c={c} event={event} fmt={fmt} loginPath={loginPath} membershipPath={membershipPath} />
172
+ )}
144
173
  {c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
145
174
  </div>
146
175
  );
@@ -193,11 +222,15 @@ function TicketStep({
193
222
  event,
194
223
  fmt,
195
224
  onAddedToCart,
225
+ loginPath,
226
+ membershipPath,
196
227
  }: {
197
228
  c: Checkout;
198
229
  event: IEvent;
199
230
  fmt: (n: number) => string;
200
231
  onAddedToCart?: () => void;
232
+ loginPath?: string;
233
+ membershipPath?: string;
201
234
  }) {
202
235
  const theme = useForgeTheme();
203
236
  // Inclusive stores caption ticket prices as tax-inclusive (display only).
@@ -233,6 +266,11 @@ function TicketStep({
233
266
  return (
234
267
  <div>
235
268
  <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Select Tickets</h2>
269
+ {/* Renders nothing on an event with no coded tier (1.2). Here as well as
270
+ on the page because this modal is reachable on its own — from an offer,
271
+ and from the broadcast-pass prompt — where there is no page-level box.
272
+ Both read the SAME stored code, so unlocking in one unlocks the other. */}
273
+ <PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
236
274
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
237
275
  {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
238
276
  {event.tickets.map((ticket) => {
@@ -242,6 +280,17 @@ function TicketStep({
242
280
  const isSoldOut = remaining <= 0;
243
281
  const u = urgency(ticket.expiresAt);
244
282
  const isExpired = !!u?.expired;
283
+ /**
284
+ * A members-only tier the buyer cannot have (S.4).
285
+ *
286
+ * The event-level gate applies to EVERY tier — "no ticket to this show
287
+ * sells to a non-member" — so it is folded in here rather than drawn
288
+ * once at the top: a buyer reading one tier row must be able to tell
289
+ * from that row whether they can buy it.
290
+ */
291
+ const gate =
292
+ event.membershipGate && !event.membershipGate.allowed ? event.membershipGate : ticket.membershipGate;
293
+ const isGated = !!gate && !gate.allowed;
245
294
  const borderColor = isExpired ? "#ef4444" : u && !u.expired ? a(u.color, 0.5) : a(theme.colors.primary, 0.25);
246
295
  return (
247
296
  <div
@@ -257,6 +306,32 @@ function TicketStep({
257
306
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}>
258
307
  <div style={{ flex: 1 }}>
259
308
  <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
309
+ {/* A hidden tier is only ever on screen because a code
310
+ revealed it — so say so. Without the badge, a code that
311
+ unlocked a tier the buyer had not seen listed looks like it
312
+ did nothing. */}
313
+ {ticket.isHidden && (
314
+ <div
315
+ data-testid="presale-tier-badge"
316
+ style={{
317
+ display: "inline-flex",
318
+ alignItems: "center",
319
+ gap: 4,
320
+ padding: "2px 8px",
321
+ borderRadius: 6,
322
+ fontSize: 12,
323
+ fontWeight: 700,
324
+ marginBottom: 6,
325
+ marginRight: 6,
326
+ background: a(theme.colors.primary, 0.1),
327
+ color: theme.colors.primary,
328
+ border: `1px solid ${a(theme.colors.primary, 0.3)}`,
329
+ }}
330
+ >
331
+ <KeyRound size={12} />
332
+ Presale
333
+ </div>
334
+ )}
260
335
  {u && !u.expired && (
261
336
  <div
262
337
  style={{
@@ -305,7 +380,12 @@ function TicketStep({
305
380
  </span>
306
381
  <button
307
382
  onClick={() => setQty(ticket, selectedQty + 1)}
308
- disabled={selectedQty >= maxAllowed || isSoldOut || isExpired}
383
+ // A gated tier cannot be added. Not because the client is
384
+ // the authority — `createOrder` refuses it regardless — but
385
+ // because letting a buyer build a cart the server will not
386
+ // sell is precisely the "discoverable only by failing"
387
+ // journey this change exists to end.
388
+ disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated}
309
389
  style={stepperStyle(theme, true)}
310
390
  >
311
391
  +
@@ -329,6 +409,17 @@ function TicketStep({
329
409
  {ticket.description && (
330
410
  <p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
331
411
  )}
412
+ {/* Named on the TIER, before the buyer has typed anything. The
413
+ tier stays in the list on purpose — hiding it kills the upsell
414
+ — so this is what turns "visible but mysteriously unbuyable"
415
+ into an offer with a way in. */}
416
+ <MembershipGateNotice
417
+ gate={gate}
418
+ variant="badge"
419
+ itemLabel={event.membershipGate && !event.membershipGate.allowed ? "This event" : "This ticket"}
420
+ loginPath={loginPath}
421
+ membershipPath={membershipPath}
422
+ />
332
423
  {isExpired && (
333
424
  <div
334
425
  style={{
@@ -375,7 +466,19 @@ function TicketStep({
375
466
  }
376
467
 
377
468
  // ── Step 2: buyer details + questionnaire ─────────────────────────────────────
378
- function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: number) => string }) {
469
+ function DetailsStep({
470
+ c,
471
+ event,
472
+ fmt,
473
+ loginPath,
474
+ membershipPath,
475
+ }: {
476
+ c: Checkout;
477
+ event: IEvent;
478
+ fmt: (n: number) => string;
479
+ loginPath?: string;
480
+ membershipPath?: string;
481
+ }) {
379
482
  const theme = useForgeTheme();
380
483
  const [confirmEmail, setConfirmEmail] = useState("");
381
484
  const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -451,6 +554,50 @@ function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: n
451
554
  </div>
452
555
  )}
453
556
 
557
+ {/* The refusal path. It should be unreachable now that the tier itself
558
+ is badged, but a membership can lapse mid-checkout and a page can be
559
+ served from cache — and when it happens the buyer gets the tier's
560
+ name and a way in, not a red line reading "Something went wrong."
561
+ `c.error` is deliberately not set for this case, so the two cannot
562
+ both draw. */}
563
+ <MembershipGateNotice
564
+ refusal={c.gateRefusal}
565
+ variant="card"
566
+ itemLabel="This ticket"
567
+ loginPath={loginPath}
568
+ membershipPath={membershipPath}
569
+ style={{ marginTop: 4 }}
570
+ />
571
+
572
+ {c.attendeeSlots.length > 0 && (
573
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
574
+ <div>
575
+ <h3 style={{ fontSize: 16, fontWeight: 700 }}>Who is coming?</h3>
576
+ <p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
577
+ This event prints each attendee&rsquo;s name on their own pass, so the door list matches who
578
+ actually turns up.
579
+ </p>
580
+ </div>
581
+ {c.attendeeSlots.map((slot) => (
582
+ <label
583
+ key={`${slot.ticketId}-${slot.index}`}
584
+ style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}
585
+ >
586
+ <span>
587
+ {slot.label}
588
+ {slot.isBuyer && <span style={{ opacity: 0.6 }}> — that&rsquo;s you</span>}
589
+ </span>
590
+ <input
591
+ placeholder="Full name"
592
+ value={slot.value}
593
+ onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
594
+ style={inputStyle(theme)}
595
+ />
596
+ </label>
597
+ ))}
598
+ </div>
599
+ )}
600
+
454
601
  {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
455
602
  </div>
456
603
  <ActionBar
@@ -484,6 +631,10 @@ function PaymentStep({
484
631
  <div>
485
632
  <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
486
633
  <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your tickets.</p>
634
+ {/* The reservation clock. Event ticket holds are taken unconditionally —
635
+ no feature switch — so this is the one checkout where the countdown
636
+ fires for every profile, and it was the one that did not render it. */}
637
+ <HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
487
638
  <div
488
639
  style={{
489
640
  padding: 20,
@@ -0,0 +1,192 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { Clock, TimerOff } from "lucide-react";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { readableTextOn } from "../theme/contrast";
5
+ import type { InventoryHoldState } from "../headless/checkout/useInventoryHold";
6
+
7
+ /** Append an alpha to a 6-digit hex colour (mirrors the other styled blocks). */
8
+ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
9
+
10
+ export interface HoldNoticeProps {
11
+ /** The countdown state from `useInventoryHold`. */
12
+ hold: InventoryHoldState;
13
+ /**
14
+ * The server's own words for a 409 `INVENTORY_HOLD_EXPIRED`. Empty/undefined
15
+ * while nothing has been refused. When set it OUTRANKS the countdown: the
16
+ * server has spoken and the clock on this device is no longer the story.
17
+ */
18
+ expiredMessage?: string;
19
+ /** Re-take the reservation on the same order. Renders the primary action. */
20
+ onRetry?: () => void;
21
+ /** True while a retry is in flight. */
22
+ isRetrying?: boolean;
23
+ /** Optional escape hatch ("Change selection", "Back to cart"). */
24
+ secondaryAction?: { label: string; onClick: () => void };
25
+ /** What is being held, for the copy. Defaults to "items". */
26
+ noun?: string;
27
+ style?: CSSProperties;
28
+ }
29
+
30
+ /**
31
+ * The reservation, told to the buyer.
32
+ *
33
+ * Three states, and they are three different facts:
34
+ *
35
+ * 1. **Running** — "Reserved for you · 8:14 left". Quiet by default, insistent
36
+ * under two minutes. Only drawn when a hold actually exists; a profile with
37
+ * inventory holds switched off, a digital-only cart and an already-settled
38
+ * free order all report no hold and get nothing, which is today's page.
39
+ * 2. **Lapsed on this device** — the clock reached zero. It does not sit on
40
+ * "0:00": it changes what it says, because a timer that finishes and then
41
+ * says nothing has raised an alarm and refused to explain it. It also does
42
+ * not claim the items are gone — this device's clock does not know that.
43
+ * 3. **Refused by the server** (409) — the reservation is definitively over.
44
+ * The message is the API's own, already localised, and it is distinct from
45
+ * sold-out on purpose: the units are very often still there, and the retry
46
+ * below re-acquires exactly the same ones.
47
+ *
48
+ * Every colour comes from the theme. The urgent/expired states use the theme's
49
+ * `secondary` (falling back to `primary`) rather than a hardcoded red, because
50
+ * an artist's site is their brand and a stock crimson is wrong on most of them.
51
+ */
52
+ export function HoldNotice({
53
+ hold,
54
+ expiredMessage,
55
+ onRetry,
56
+ isRetrying,
57
+ secondaryAction,
58
+ noun = "items",
59
+ style,
60
+ }: HoldNoticeProps): ReactNode {
61
+ const theme = useForgeTheme();
62
+ const accent = theme.colors.secondary ?? theme.colors.primary;
63
+
64
+ const isExpired = !!expiredMessage || hold.hasLapsed;
65
+ if (!isExpired && !hold.isHeld) return null;
66
+
67
+ const shellStyle: CSSProperties = {
68
+ position: "relative",
69
+ display: "flex",
70
+ gap: 12,
71
+ alignItems: "flex-start",
72
+ padding: "12px 14px",
73
+ marginBottom: 16,
74
+ border: `1px solid ${a(isExpired || hold.isUrgent ? accent : theme.colors.primary, isExpired ? 0.5 : 0.28)}`,
75
+ backgroundColor: a(isExpired || hold.isUrgent ? accent : theme.colors.primary, isExpired ? 0.1 : 0.06),
76
+ borderRadius: theme.cornerRadius,
77
+ color: theme.colors.text,
78
+ fontFamily: theme.fontFamily,
79
+ ...style,
80
+ };
81
+
82
+ if (isExpired) {
83
+ return (
84
+ <div data-testid="hold-expired" role="alert" style={shellStyle}>
85
+ <TimerOff size={18} style={{ marginTop: 2, flexShrink: 0, color: accent }} aria-hidden />
86
+ <div style={{ flex: 1, minWidth: 0 }}>
87
+ <p style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>
88
+ {expiredMessage ? "Your reservation expired" : "Your reservation has ended"}
89
+ </p>
90
+ <p style={{ fontSize: 13, opacity: 0.8, margin: "4px 0 0" }}>
91
+ {/* The server's message names the right noun and the right verb
92
+ tense; ours only stands in before it has spoken. */}
93
+ {expiredMessage ??
94
+ `These ${noun} are no longer held for you. Try again to reserve them — they may well still be available.`}
95
+ </p>
96
+ {(onRetry || secondaryAction) && (
97
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
98
+ {onRetry && (
99
+ <button
100
+ type="button"
101
+ data-testid="hold-retry"
102
+ onClick={onRetry}
103
+ disabled={isRetrying}
104
+ style={{
105
+ padding: "8px 14px",
106
+ fontSize: 14,
107
+ fontWeight: 600,
108
+ cursor: isRetrying ? "default" : "pointer",
109
+ opacity: isRetrying ? 0.6 : 1,
110
+ border: "none",
111
+ backgroundColor: theme.colors.primary,
112
+ color: theme.colors.textPrimary ?? readableTextOn(theme.colors.primary),
113
+ borderRadius: theme.cornerRadius,
114
+ fontFamily: theme.fontFamily,
115
+ }}
116
+ >
117
+ {isRetrying ? "Reserving…" : "Try again"}
118
+ </button>
119
+ )}
120
+ {secondaryAction && (
121
+ <button
122
+ type="button"
123
+ data-testid="hold-secondary"
124
+ onClick={secondaryAction.onClick}
125
+ style={{
126
+ padding: "8px 14px",
127
+ fontSize: 14,
128
+ fontWeight: 600,
129
+ cursor: "pointer",
130
+ background: "transparent",
131
+ border: `1px solid ${theme.colors.primary}`,
132
+ color: theme.colors.primary,
133
+ borderRadius: theme.cornerRadius,
134
+ fontFamily: theme.fontFamily,
135
+ }}
136
+ >
137
+ {secondaryAction.label}
138
+ </button>
139
+ )}
140
+ </div>
141
+ )}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ return (
148
+ <div
149
+ data-testid="hold-countdown"
150
+ // Polite, not assertive: this updates every second and an assertive live
151
+ // region would have a screen reader interrupt itself all the way down.
152
+ // The number is re-read only when it is worth hearing (see below).
153
+ style={shellStyle}
154
+ >
155
+ <Clock size={18} style={{ marginTop: 2, flexShrink: 0, color: hold.isUrgent ? accent : theme.colors.primary }} aria-hidden />
156
+ <div style={{ flex: 1, minWidth: 0 }}>
157
+ <p style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>
158
+ {`Reserved for you · `}
159
+ <span data-testid="hold-countdown-value" style={{ fontVariantNumeric: "tabular-nums" }}>
160
+ {hold.label}
161
+ </span>
162
+ {` left`}
163
+ </p>
164
+ <p style={{ fontSize: 13, opacity: 0.75, margin: "4px 0 0" }}>
165
+ {hold.isUrgent
166
+ ? `Complete payment soon — after this your ${noun} go back on sale.`
167
+ : `We're holding your ${noun} while you pay.`}
168
+ </p>
169
+ </div>
170
+ {/* A screen reader gets a stable sentence rather than a per-second
171
+ barrage: the visible digits tick, this announces the minute. */}
172
+ <span
173
+ aria-live="polite"
174
+ style={{
175
+ position: "absolute",
176
+ width: 1,
177
+ height: 1,
178
+ padding: 0,
179
+ margin: -1,
180
+ overflow: "hidden",
181
+ clip: "rect(0 0 0 0)",
182
+ whiteSpace: "nowrap",
183
+ border: 0,
184
+ }}
185
+ >
186
+ {hold.secondsRemaining != null && hold.secondsRemaining % 60 === 0
187
+ ? `${Math.round(hold.secondsRemaining / 60)} minutes left to complete payment`
188
+ : ""}
189
+ </span>
190
+ </div>
191
+ );
192
+ }