@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/server/index.ts +52 -0
- package/src/types/models.ts +151 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/TicketTransfer.tsx +69 -40
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- 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" &&
|
|
143
|
-
|
|
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
|
-
|
|
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({
|
|
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’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’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
|