@tribe-nest/forge 3.21.0 → 3.23.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 (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -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
+ }
@@ -4,6 +4,7 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
4
  import { getCurrencySymbol } from "../format/useFormatCurrency";
5
5
  import { summarizeTaxQuote } from "../format/PriceDisplay";
6
6
  import { Loading } from "./Loading";
7
+ import { PaystackPayButton } from "./PaystackPayButton";
7
8
 
8
9
  export interface InvoicePaymentProps {
9
10
  invoiceId: string;
@@ -21,14 +22,14 @@ const formatDate = (value: string | number | Date): string =>
21
22
  /**
22
23
  * Themed invoice view + payment surface, built on `useInvoicePayment` and the
23
24
  * app-registered payment renderer (`usePaymentRenderer`). Renders line items,
24
- * a PDF download, and when unpaid the Stripe payment surface.
25
+ * a PDF download, and (when unpaid) the payment surface: Stripe's card form,
26
+ * or Paystack's inline modal behind a pay button.
25
27
  */
26
28
  export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoicePaymentProps) {
27
29
  const theme = useForgeTheme();
28
30
  const renderPayment = usePaymentRenderer();
29
- const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote } = useInvoicePayment(invoiceId, {
30
- returnPath,
31
- });
31
+ const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote, isPaystack, canOpenPaystack, openPaystackCheckout } =
32
+ useInvoicePayment(invoiceId, { returnPath });
32
33
 
33
34
  if (invoice.isLoading) return <Loading fullPage />;
34
35
  if (invoice.error || !invoice.data) return <p style={{ color: theme.colors.text }}>Invoice not found.</p>;
@@ -172,7 +173,15 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
172
173
  <div>
173
174
  <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
174
175
  {error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
175
- {clientSecret ? (
176
+ {/* Paystack brings its own modal, so it is checked before the Stripe
177
+ renderer and needs none of it. */}
178
+ {isPaystack ? (
179
+ canOpenPaystack ? (
180
+ <PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(inv.total))}`} />
181
+ ) : (
182
+ <Loading label="Preparing payment…" size={22} />
183
+ )
184
+ ) : clientSecret ? (
176
185
  renderPayment?.({
177
186
  clientSecret,
178
187
  returnUrl,
@@ -7,6 +7,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
7
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
8
8
  import { readableTextOn } from "../theme/contrast";
9
9
  import { Loading } from "./Loading";
10
+ import { PaystackPayButton } from "./PaystackPayButton";
10
11
 
11
12
  export interface MembershipCheckoutProps {
12
13
  /** Pre-selected tier (e.g. from a `?membershipTierId=` param). */
@@ -88,23 +89,31 @@ export function MembershipCheckout({
88
89
  );
89
90
  }
90
91
 
91
- // Payment step — render the registered payment UI (Stripe) in manual mode.
92
- if (checkout.step === "payment" && checkout.clientSecret) {
92
+ // Payment step: the registered payment UI (Stripe) in manual mode, or the
93
+ // Paystack modal behind a pay button. The Paystack branch is also what a
94
+ // member who closed the popup comes back to, so it must render whether or not
95
+ // a Stripe secret was ever issued.
96
+ if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
93
97
  return (
94
98
  <div className={className} style={card}>
95
99
  <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>Payment</h1>
96
100
  <p style={{ opacity: 0.75, marginBottom: 24 }}>
97
- {tier.name} {fmt(checkout.amount)} / {checkout.billingCycle}
101
+ {tier.name} · {fmt(checkout.amount)} / {checkout.billingCycle}
98
102
  </p>
99
103
  <div style={box}>
100
- {renderPayment({
101
- clientSecret: checkout.clientSecret,
102
- returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
103
- amount: checkout.amount,
104
- mode: "manual",
105
- onSucceeded: () => onComplete?.(),
106
- onFailed: () => {},
107
- })}
104
+ {checkout.isPaystack ? (
105
+ <PaystackPayButton onPay={checkout.openPaystackCheckout} label={`Pay ${fmt(checkout.amount)}`} />
106
+ ) : (
107
+ checkout.clientSecret &&
108
+ renderPayment({
109
+ clientSecret: checkout.clientSecret,
110
+ returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
111
+ amount: checkout.amount,
112
+ mode: "manual",
113
+ onSucceeded: () => onComplete?.(),
114
+ onFailed: () => {},
115
+ })
116
+ )}
108
117
  </div>
109
118
  <button
110
119
  onClick={() => checkout.setStep("select")}
@@ -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
  ) : (
@@ -121,7 +131,7 @@ function OfferBody({
121
131
  >
122
132
  {c.variants.map((v) => (
123
133
  <option key={v.id} value={v.id}>
124
- {v.title || [v.color, v.size].filter(Boolean).join(" / ") || "Default"} {fmt(v.price)}
134
+ {v.title || [v.color, v.size].filter(Boolean).join(" / ") || "Default"} · {fmt(v.price)}
125
135
  </option>
126
136
  ))}
127
137
  </select>
@@ -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
@@ -4,6 +4,7 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { summarizeTaxQuote } from "../format/PriceDisplay";
6
6
  import { Loading } from "./Loading";
7
+ import { PaystackPayButton } from "./PaystackPayButton";
7
8
 
8
9
  export interface PaymentLinkPaymentProps {
9
10
  paymentLinkId: string;
@@ -22,9 +23,8 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
22
23
  const theme = useForgeTheme();
23
24
  const fmt = useAmountFormatter(formatAmount);
24
25
  const renderPayment = usePaymentRenderer();
25
- const { link, clientSecret, returnUrl, isStarting, error, taxQuote } = usePaymentLinkPayment(paymentLinkId, {
26
- returnPath,
27
- });
26
+ const { link, clientSecret, returnUrl, isStarting, error, taxQuote, isPaystack, canOpenPaystack, openPaystackCheckout } =
27
+ usePaymentLinkPayment(paymentLinkId, { returnPath });
28
28
 
29
29
  if (link.isLoading) return <Loading fullPage />;
30
30
  if (link.error || !link.data) return <p style={{ color: theme.colors.text }}>Payment link not found.</p>;
@@ -68,7 +68,15 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
68
68
  <div>
69
69
  <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
70
70
  {error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
71
- {clientSecret ? (
71
+ {/* Paystack brings its own modal, so it is checked before the Stripe
72
+ renderer and needs none of it. */}
73
+ {isPaystack ? (
74
+ canOpenPaystack ? (
75
+ <PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(data.amount))}`} />
76
+ ) : (
77
+ <Loading label="Preparing payment…" size={22} />
78
+ )
79
+ ) : clientSecret ? (
72
80
  renderPayment?.({
73
81
  clientSecret,
74
82
  returnUrl,
@@ -0,0 +1,66 @@
1
+ import { useState } from "react";
2
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { readableTextOn } from "../theme/contrast";
4
+ import { useFormatCurrency } from "../format/useFormatCurrency";
5
+ import type { PaystackCheckoutOutcome } from "../../utils/paystackCheckout";
6
+
7
+ export interface PaystackPayButtonProps {
8
+ /** Opens the modal. Every pillar hook exposes one under this name. */
9
+ onPay: () => Promise<PaystackCheckoutOutcome> | void;
10
+ /** Charged total, MAJOR units. Rendered on the button when set. */
11
+ amount?: number;
12
+ /** ISO currency of `amount`. */
13
+ currency?: string;
14
+ /** Overrides the generated "Pay X" label. */
15
+ label?: string;
16
+ disabled?: boolean;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * The Paystack pay button: the fan's way into the inline modal, and their way
22
+ * BACK into it.
23
+ *
24
+ * Two jobs, and the second is the one worth stating: a fan who closes the popup
25
+ * without paying has not failed, and this button is what lets them try again on
26
+ * the same charge. There is no other route back to a session that has already
27
+ * been started, so a surface that renders nothing for Paystack strands them.
28
+ *
29
+ * The button re-enables itself on every outcome except a success, because a
30
+ * success is already navigating away.
31
+ */
32
+ export function PaystackPayButton({ onPay, amount, currency, label, disabled, className }: PaystackPayButtonProps) {
33
+ const theme = useForgeTheme();
34
+ const { formatCurrency } = useFormatCurrency();
35
+ const [isOpening, setIsOpening] = useState(false);
36
+
37
+ const text = label ?? (amount ? `Pay ${formatCurrency(amount, currency || undefined)}` : "Pay now");
38
+
39
+ return (
40
+ <button
41
+ type="button"
42
+ disabled={disabled || isOpening}
43
+ onClick={async () => {
44
+ setIsOpening(true);
45
+ try {
46
+ const outcome = await onPay();
47
+ // A success is mid-navigation; anything else means the fan is still
48
+ // here and the button has to work again.
49
+ if (outcome !== "succeeded") setIsOpening(false);
50
+ } catch {
51
+ setIsOpening(false);
52
+ }
53
+ }}
54
+ className={className ?? "w-full px-4 py-3 font-semibold"}
55
+ style={{
56
+ backgroundColor: theme.colors.primary,
57
+ color: readableTextOn(theme.colors.primary),
58
+ borderRadius: `${theme.cornerRadius}px`,
59
+ opacity: disabled || isOpening ? 0.7 : 1,
60
+ cursor: disabled || isOpening ? "not-allowed" : "pointer",
61
+ }}
62
+ >
63
+ {isOpening ? "Opening payment…" : text}
64
+ </button>
65
+ );
66
+ }