@tribe-nest/forge 3.20.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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useAccountSettings.ts +15 -2
  4. package/src/data/queries/useAuthActions.ts +51 -7
  5. package/src/data/queries/useEvents.ts +66 -4
  6. package/src/data/queries/useMembership.ts +8 -2
  7. package/src/data/queries/useMyBookings.ts +12 -0
  8. package/src/data/queries/useMyTickets.ts +112 -0
  9. package/src/data/queries/useOrders.ts +10 -0
  10. package/src/data/queries/usePassTransfers.ts +73 -13
  11. package/src/server/index.ts +52 -0
  12. package/src/types/models.ts +151 -0
  13. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  14. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  15. package/src/ui/format/attendees.ts +187 -0
  16. package/src/ui/format/membershipGate.ts +209 -0
  17. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  18. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  19. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  20. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  22. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  23. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  24. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  25. package/src/ui/headless/index.ts +25 -0
  26. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  27. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  28. package/src/ui/index.ts +42 -0
  29. package/src/ui/styled/AccountDashboard.tsx +70 -8
  30. package/src/ui/styled/AddToCalendar.tsx +34 -10
  31. package/src/ui/styled/Checkout.tsx +18 -1
  32. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  33. package/src/ui/styled/CourseDetail.tsx +30 -1
  34. package/src/ui/styled/EventConfirmation.tsx +2 -0
  35. package/src/ui/styled/EventDetail.tsx +53 -22
  36. package/src/ui/styled/EventTickets.tsx +156 -5
  37. package/src/ui/styled/HoldNotice.tsx +192 -0
  38. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  39. package/src/ui/styled/OfferButton.tsx +23 -0
  40. package/src/ui/styled/PresaleCode.tsx +174 -0
  41. package/src/ui/styled/ProductDetail.tsx +75 -5
  42. package/src/ui/styled/ProductGrid.tsx +26 -0
  43. package/src/ui/styled/TicketTransfer.tsx +69 -40
  44. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  45. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  46. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  47. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  48. package/src/utils/_tests/structuredData.spec.ts +275 -0
  49. package/src/utils/presaleCode.ts +96 -0
  50. package/src/utils/structuredData.ts +361 -27
@@ -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
+ }
@@ -0,0 +1,159 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Lock } from "lucide-react";
3
+ import type { PublicMembershipGate } from "../../types/models";
4
+ import type { MembershipGateRefusal } from "../format/membershipGate";
5
+ import { useMembershipGateNotice } from "../headless/membership/useMembershipGateNotice";
6
+ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
7
+ import { readableTextOn } from "../theme/contrast";
8
+
9
+ export interface MembershipGateNoticeProps {
10
+ /** The announced gate from the read. */
11
+ gate?: PublicMembershipGate | null;
12
+ /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
13
+ refusal?: MembershipGateRefusal | null;
14
+ /** What the thing is, in the buyer's words. Default "This". */
15
+ itemLabel?: string;
16
+ /**
17
+ * `badge` is the one-line form for a row in a list of things (a ticket tier);
18
+ * `card` is the block form for a page about ONE thing (a course, a product).
19
+ */
20
+ variant?: "badge" | "card";
21
+ loginPath?: string;
22
+ membershipPath?: string;
23
+ style?: CSSProperties;
24
+ }
25
+
26
+ /**
27
+ * "Members only" — said BEFORE the buyer pays, with the action that resolves it.
28
+ *
29
+ * ## What was wrong
30
+ *
31
+ * Members-only tickets were enforced at checkout and announced on no surface.
32
+ * A logged-out buyer picked a seat, filled in a two-step form and was refused on
33
+ * the last screen with a 400 — the restriction was discoverable only by failing
34
+ * it, and the "members save X, sign in" funnel the feature was sold on existed
35
+ * nowhere. Products and courses published the same gate and drew it just as
36
+ * little.
37
+ *
38
+ * ## Why the button is the point, not the badge
39
+ *
40
+ * A badge alone converts a silent failure into a visible dead end. The two
41
+ * reasons need genuinely different exits — `sign_in_required` gets a sign-in
42
+ * that RETURNS here, `membership_required` gets the tier — and offering the
43
+ * wrong one is worse than offering none: a signed-in member sent to sign in
44
+ * again learns only that the site is broken. `buildMembershipGateNotice` owns
45
+ * that choice so both rendering stacks make it identically.
46
+ *
47
+ * Renders nothing when nothing is gated, which is also every storefront with the
48
+ * gates switch off.
49
+ */
50
+ export function MembershipGateNotice({
51
+ gate,
52
+ refusal,
53
+ itemLabel,
54
+ variant = "card",
55
+ loginPath,
56
+ membershipPath,
57
+ style,
58
+ }: MembershipGateNoticeProps) {
59
+ const theme = useForgeTheme();
60
+ const notice = useMembershipGateNotice({ gate, refusal, itemLabel, loginPath, membershipPath });
61
+ if (!notice) return null;
62
+
63
+ if (variant === "badge") {
64
+ return (
65
+ <div
66
+ data-testid="membership-gate-badge"
67
+ data-gate-reason={notice.reason}
68
+ style={{
69
+ display: "flex",
70
+ flexWrap: "wrap",
71
+ alignItems: "center",
72
+ gap: 8,
73
+ marginTop: 8,
74
+ fontFamily: theme.fontFamily,
75
+ ...style,
76
+ }}
77
+ >
78
+ <span style={pillStyle(theme)}>
79
+ <Lock size={12} aria-hidden />
80
+ {notice.badge}
81
+ </span>
82
+ <span style={{ fontSize: 13, opacity: 0.8, color: theme.colors.text }}>{notice.body}</span>
83
+ {notice.actionHref && (
84
+ <a data-testid="membership-gate-action" href={notice.actionHref} style={linkStyle(theme)}>
85
+ {notice.actionLabel}
86
+ </a>
87
+ )}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <div
94
+ data-testid="membership-gate-card"
95
+ data-gate-reason={notice.reason}
96
+ style={{
97
+ border: `1px solid ${alpha(theme.colors.primary, 0.3)}`,
98
+ borderRadius: theme.cornerRadius,
99
+ padding: 20,
100
+ color: theme.colors.text,
101
+ background: theme.colors.background,
102
+ fontFamily: theme.fontFamily,
103
+ ...style,
104
+ }}
105
+ >
106
+ <span style={pillStyle(theme)}>
107
+ <Lock size={12} aria-hidden />
108
+ {notice.badge}
109
+ </span>
110
+ <h3 style={{ fontWeight: 600, fontSize: 17, margin: "12px 0 6px" }}>{notice.title}</h3>
111
+ <p style={{ opacity: 0.8, fontSize: 14, marginBottom: notice.actionHref ? 16 : 0 }}>{notice.body}</p>
112
+ {notice.actionHref && (
113
+ <a
114
+ data-testid="membership-gate-action"
115
+ href={notice.actionHref}
116
+ style={{
117
+ display: "inline-block",
118
+ background: theme.colors.primary,
119
+ color: readableTextOn(theme.colors.primary),
120
+ borderRadius: theme.cornerRadius,
121
+ padding: "10px 18px",
122
+ fontWeight: 600,
123
+ fontSize: 14,
124
+ textDecoration: "none",
125
+ }}
126
+ >
127
+ {notice.actionLabel}
128
+ </a>
129
+ )}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /** Append an alpha to a 6-digit hex colour (mirrors frontend-shared addAlphaToHexCode). */
135
+ const alpha = (hex: string, value: number) => hex + Math.round(value * 255).toString(16).padStart(2, "0");
136
+
137
+ // Everything is drawn from the artist's own palette. A members-only badge in a
138
+ // hardcoded gold or indigo is a brand bug on every site that is not that colour.
139
+ const pillStyle = (t: ForgeTheme): CSSProperties => ({
140
+ display: "inline-flex",
141
+ alignItems: "center",
142
+ gap: 4,
143
+ padding: "2px 8px",
144
+ borderRadius: 6,
145
+ fontSize: 12,
146
+ fontWeight: 700,
147
+ background: alpha(t.colors.primary, 0.1),
148
+ color: t.colors.primary,
149
+ border: `1px solid ${alpha(t.colors.primary, 0.3)}`,
150
+ whiteSpace: "nowrap",
151
+ });
152
+
153
+ const linkStyle = (t: ForgeTheme): CSSProperties => ({
154
+ fontSize: 13,
155
+ fontWeight: 600,
156
+ color: t.colors.primary,
157
+ textDecoration: "underline",
158
+ whiteSpace: "nowrap",
159
+ });
@@ -5,6 +5,7 @@ import { Loading } from "./Loading";
5
5
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
6
  import { readableTextOn } from "../theme/contrast";
7
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
8
+ import { HoldNotice } from "./HoldNotice";
8
9
 
9
10
  export interface OfferButtonProps {
10
11
  productId: string;
@@ -97,6 +98,15 @@ function OfferBody({
97
98
  <span>Total</span>
98
99
  <strong style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</strong>
99
100
  </div>
101
+ {/* The reservation, counted down while the buyer pays. Nothing is drawn
102
+ for a digital item or a profile with holds off. */}
103
+ <HoldNotice
104
+ hold={c.hold}
105
+ expiredMessage={c.holdExpiredError ?? undefined}
106
+ onRetry={c.retryHold}
107
+ isRetrying={c.isRetryingHold}
108
+ noun="item"
109
+ />
100
110
  {renderPayment ? (
101
111
  <Offer.PaymentStep>{(p) => renderPayment(p)}</Offer.PaymentStep>
102
112
  ) : (
@@ -157,6 +167,19 @@ function OfferBody({
157
167
  <span style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</span>
158
168
  </div>
159
169
 
170
+ {/* A reservation that lapsed on the way to the card form never reached
171
+ the payment step, so it is answered here — distinctly from the error
172
+ line, because "your hold ran out" is not "this is gone". */}
173
+ {c.holdExpiredError && (
174
+ <HoldNotice
175
+ hold={c.hold}
176
+ expiredMessage={c.holdExpiredError}
177
+ onRetry={c.retryHold}
178
+ isRetrying={c.isRetryingHold}
179
+ noun="item"
180
+ />
181
+ )}
182
+
160
183
  {c.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.error}</p>}
161
184
 
162
185
  <button