@tribe-nest/forge 3.22.0 → 3.24.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 (74) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/useAnalytics.ts +59 -8
  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 +16 -0
  8. package/src/data/queries/useMusicLink.ts +28 -0
  9. package/src/data/queries/useMyBookings.ts +25 -0
  10. package/src/data/queries/usePaymentFlow.ts +84 -18
  11. package/src/data/queries/useShipping.ts +5 -0
  12. package/src/data/queries/useSubscriptions.ts +50 -4
  13. package/src/index.ts +6 -0
  14. package/src/server/index.ts +51 -0
  15. package/src/server/platform.ts +1 -1
  16. package/src/server/platformEvents.generated.ts +33 -33
  17. package/src/server/pwa.ts +2 -2
  18. package/src/types/models.ts +94 -5
  19. package/src/types/paystack-inline.d.ts +45 -0
  20. package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
  21. package/src/ui/format/_tests/attendees.spec.ts +4 -4
  22. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  23. package/src/ui/format/attendees.ts +2 -2
  24. package/src/ui/format/ticketAvailability.ts +226 -0
  25. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  26. package/src/ui/headless/checkout/useCheckout.ts +72 -8
  27. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  29. package/src/ui/headless/donation/Donation.tsx +10 -0
  30. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  31. package/src/ui/headless/event/useEventCheckout.ts +70 -0
  32. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  33. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  34. package/src/ui/headless/offer/Offer.tsx +19 -1
  35. package/src/ui/headless/offer/OfferContext.tsx +14 -1
  36. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  37. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  38. package/src/ui/index.ts +23 -0
  39. package/src/ui/shell/diagnosticsGating.ts +3 -3
  40. package/src/ui/styled/AccountDashboard.tsx +73 -4
  41. package/src/ui/styled/AudioPlayer.tsx +1 -1
  42. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  43. package/src/ui/styled/Checkout.tsx +25 -32
  44. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  45. package/src/ui/styled/CoachingBooking.tsx +11 -2
  46. package/src/ui/styled/CoachingConfirmation.tsx +3 -3
  47. package/src/ui/styled/CohortPage.tsx +1 -1
  48. package/src/ui/styled/ContactForm.tsx +1 -1
  49. package/src/ui/styled/CourseCheckout.tsx +10 -1
  50. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  51. package/src/ui/styled/DiscountCode.tsx +1 -1
  52. package/src/ui/styled/EmailListForm.tsx +1 -1
  53. package/src/ui/styled/EventConfirmation.tsx +1 -1
  54. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  55. package/src/ui/styled/EventTickets.tsx +135 -21
  56. package/src/ui/styled/EventWaitlist.tsx +23 -3
  57. package/src/ui/styled/HoldNotice.tsx +2 -2
  58. package/src/ui/styled/InvoicePayment.tsx +14 -5
  59. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  60. package/src/ui/styled/MusicLinkPage.tsx +365 -0
  61. package/src/ui/styled/OfferButton.tsx +1 -1
  62. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  63. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  64. package/src/ui/styled/PresaleCode.tsx +1 -1
  65. package/src/ui/styled/ReviewForm.tsx +2 -2
  66. package/src/ui/styled/TicketTransfer.tsx +3 -3
  67. package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
  68. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  69. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  70. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  71. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  72. package/src/utils/membershipAccess.ts +3 -3
  73. package/src/utils/paystackCheckout.ts +277 -0
  74. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -1,13 +1,23 @@
1
1
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
2
- import { AlertCircle, Clock, KeyRound, X } from "lucide-react";
2
+ import { AlertCircle, CalendarClock, 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";
6
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
7
  import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
8
8
  import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
9
+ import {
10
+ maxAddableTickets,
11
+ onSaleBadge,
12
+ onSaleNotice,
13
+ ticketSaleWindow,
14
+ ticketSeatLabel,
15
+ ticketSeatNotice,
16
+ ticketSeatState,
17
+ } from "../format/ticketAvailability";
9
18
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
10
19
  import { Loading } from "./Loading";
20
+ import { PaystackPayButton } from "./PaystackPayButton";
11
21
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
12
22
  import { buttonStyle } from "./Button";
13
23
  import { MembershipGateNotice } from "./MembershipGateNotice";
@@ -258,9 +268,17 @@ function TicketStep({
258
268
  };
259
269
 
260
270
  const setQty = (ticket: ITicket, qty: number) => {
261
- const remaining = ticket.quantity - ticket.sold;
262
- const maxAllowed = Math.min(remaining, ticket.maxPerPerson ?? remaining);
263
- c.setTicketQty(ticket.id, Math.max(0, Math.min(qty, maxAllowed)));
271
+ // `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
272
+ // on-sale the same two facts the server sells on. Computed off `now` so
273
+ // the cap opens up on the tick the on-sale time passes, with no reload.
274
+ //
275
+ // 2.6b adds back the seats held for THIS visitor by a waitlist offer.
276
+ // Without it the offeree's own reservation makes the tier read sold out to
277
+ // them and the claim link is unusable.
278
+ c.setTicketQty(
279
+ ticket.id,
280
+ Math.max(0, Math.min(qty, maxAddableTickets(ticket, new Date(now), c.reservedForYou[ticket.id] ?? 0))),
281
+ );
264
282
  };
265
283
 
266
284
  return (
@@ -275,9 +293,27 @@ function TicketStep({
275
293
  {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
276
294
  {event.tickets.map((ticket) => {
277
295
  const selectedQty = c.selectedTickets[ticket.id] ?? 0;
278
- const remaining = ticket.quantity - ticket.sold;
279
- const maxAllowed = Math.min(remaining, ticket.maxPerPerson ?? remaining);
280
- const isSoldOut = remaining <= 0;
296
+ /**
297
+ * `quantity - sold - HELD`, which is what the server sells on. The
298
+ * old `quantity - sold` showed seats sitting in other buyers' live
299
+ * checkouts as available, so a fan in a rush was told "3 left",
300
+ * picked two, filled in the form and was refused at reserve.
301
+ */
302
+ // 2.6b — the offeree's own reservation is not "somebody else's
303
+ // checkout"; it is the seat they were emailed a deadline for.
304
+ const reserved = c.reservedForYou[ticket.id] ?? 0;
305
+ const seatState = ticketSeatState(ticket, reserved);
306
+ const maxAllowed = maxAddableTickets(ticket, new Date(now), reserved);
307
+ const isSoldOut = seatState !== "available";
308
+ const seatLabel = ticketSeatLabel(seatState);
309
+ const seatNotice = ticketSeatNotice(seatState);
310
+ /**
311
+ * Scheduled on-sale. Enforced since 1.2 and drawn on no storefront,
312
+ * so a tier going on sale Friday was clickable on Wednesday and the
313
+ * `ticket_not_yet_on_sale` refusal arrived on the payment screen.
314
+ */
315
+ const saleWindow = ticketSaleWindow(ticket, new Date(now));
316
+ const isScheduled = saleWindow.status === "scheduled";
281
317
  const u = urgency(ticket.expiresAt);
282
318
  const isExpired = !!u?.expired;
283
319
  /**
@@ -332,6 +368,33 @@ function TicketStep({
332
368
  Presale
333
369
  </div>
334
370
  )}
371
+ {/* A tier that is announced but not yet buyable says WHEN,
372
+ in the same badge idiom as the presale and expiry ones.
373
+ Deliberately not dimmed and deliberately not hidden: the
374
+ whole point of scheduling an on-sale is that fans can see
375
+ it coming. */}
376
+ {isScheduled && (
377
+ <div
378
+ data-testid="ticket-on-sale-badge"
379
+ style={{
380
+ display: "inline-flex",
381
+ alignItems: "center",
382
+ gap: 4,
383
+ padding: "2px 8px",
384
+ borderRadius: 6,
385
+ fontSize: 12,
386
+ fontWeight: 700,
387
+ marginBottom: 6,
388
+ marginRight: 6,
389
+ background: a(theme.colors.primary, 0.1),
390
+ color: theme.colors.primary,
391
+ border: `1px solid ${a(theme.colors.primary, 0.3)}`,
392
+ }}
393
+ >
394
+ <CalendarClock size={12} />
395
+ {onSaleBadge(saleWindow.startsAt, new Date(now))}
396
+ </div>
397
+ )}
335
398
  {u && !u.expired && (
336
399
  <div
337
400
  style={{
@@ -370,22 +433,28 @@ function TicketStep({
370
433
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
371
434
  <button
372
435
  onClick={() => setQty(ticket, selectedQty - 1)}
373
- disabled={selectedQty === 0 || isSoldOut}
436
+ // Never disabled by availability only by there being
437
+ // nothing to remove. A buyer holding two seats on a tier
438
+ // that has just gone unavailable must still be able to put
439
+ // them back; disabling this would trap the cart.
440
+ disabled={selectedQty === 0}
374
441
  style={stepperStyle(theme, false)}
375
442
  >
376
443
 
377
444
  </button>
378
445
  <span style={{ fontSize: 18, fontWeight: 700, minWidth: 32, textAlign: "center" }}>
379
- {isSoldOut ? "SOLD OUT" : selectedQty}
446
+ {seatLabel ?? selectedQty}
380
447
  </span>
381
448
  <button
382
449
  onClick={() => setQty(ticket, selectedQty + 1)}
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"
450
+ // A gated tier cannot be added, nor one whose on-sale time
451
+ // has not arrived, nor one whose remaining seats are inside
452
+ // somebody else's checkout. Not because the client is the
453
+ // authority `createOrder` refuses all three regardless
454
+ // but because letting a buyer build a cart the server will
455
+ // not sell is precisely the "discoverable only by failing"
387
456
  // journey this change exists to end.
388
- disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated}
457
+ disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled}
389
458
  style={stepperStyle(theme, true)}
390
459
  >
391
460
  +
@@ -396,7 +465,7 @@ function TicketStep({
396
465
  before that there is nothing to price, and a row of empty
397
466
  inputs down the tier list reads as a form to fill in rather
398
467
  than a choice to make. */}
399
- {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
468
+ {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
400
469
  <PwywAmountField
401
470
  ticket={ticket}
402
471
  quantity={selectedQty}
@@ -420,6 +489,24 @@ function TicketStep({
420
489
  loginPath={loginPath}
421
490
  membershipPath={membershipPath}
422
491
  />
492
+ {/* The badge says WHEN in two words; this says it in full, with
493
+ the exact time, for a fan deciding whether to set an alarm. */}
494
+ {isScheduled && (
495
+ <div data-testid="ticket-on-sale-notice" style={infoNoticeStyle(theme.colors.primary, theme)}>
496
+ <CalendarClock size={16} />
497
+ {onSaleNotice(saleWindow.startsAt)}
498
+ </div>
499
+ )}
500
+ {/* "ON HOLD" in the stepper is two words; this is the sentence
501
+ that stops it reading as a dead end. Seats inside somebody
502
+ else's checkout come back if that checkout lapses, which a
503
+ fan told only "SOLD OUT" would never wait for. */}
504
+ {seatNotice && (
505
+ <div data-testid="ticket-on-hold-notice" style={infoNoticeStyle("#f59e0b", theme)}>
506
+ <Clock size={16} />
507
+ {seatNotice}
508
+ </div>
509
+ )}
423
510
  {isExpired && (
424
511
  <div
425
512
  style={{
@@ -585,7 +672,7 @@ function DetailsStep({
585
672
  >
586
673
  <span>
587
674
  {slot.label}
588
- {slot.isBuyer && <span style={{ opacity: 0.6 }}> that&rsquo;s you</span>}
675
+ {slot.isBuyer && <span style={{ opacity: 0.6 }}> (that&rsquo;s you)</span>}
589
676
  </span>
590
677
  <input
591
678
  placeholder="Full name"
@@ -695,7 +782,15 @@ function PaymentStep({
695
782
  </span>
696
783
  </div>
697
784
  </div>
698
- {!renderPayment ? (
785
+ {/* Paystack needs no registered renderer at all (it has its own modal),
786
+ so it is checked BEFORE the "not configured" branch. */}
787
+ {c.isPaystack ? (
788
+ c.canOpenPaystack ? (
789
+ <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(c.totalAmount)}`} />
790
+ ) : (
791
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
792
+ )
793
+ ) : !renderPayment ? (
699
794
  <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
700
795
  ) : c.clientSecret ? (
701
796
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
@@ -905,8 +1000,8 @@ function PwywAmountField({
905
1000
  Name your price
906
1001
  <span style={{ fontWeight: 400, opacity: 0.7 }}>
907
1002
  {" "}
908
- minimum {fmt(floor)}
909
- {maximum != null ? `, up to ${fmt(maximum)}` : ""}
1003
+ (minimum {fmt(floor)}
1004
+ {maximum != null ? `, up to ${fmt(maximum)}` : ""})
910
1005
  </span>
911
1006
  </label>
912
1007
  <input
@@ -923,9 +1018,9 @@ function PwywAmountField({
923
1018
  />
924
1019
  <p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
925
1020
  {belowFloor
926
- ? `The minimum is ${fmt(floor)} we'll use that.`
1021
+ ? `The minimum is ${fmt(floor)}, so we'll use that.`
927
1022
  : aboveMax
928
- ? `The most you can pay is ${fmt(maximum!)} we'll use that.`
1023
+ ? `The most you can pay is ${fmt(maximum!)}, so we'll use that.`
929
1024
  : quantity > 1
930
1025
  ? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
931
1026
  : "Per ticket."}
@@ -934,6 +1029,25 @@ function PwywAmountField({
934
1029
  );
935
1030
  }
936
1031
 
1032
+ /**
1033
+ * The inline banner under a tier row — same shape as the expired one already
1034
+ * below it, so "why can't I buy this?" always answers itself in one place and
1035
+ * one visual language, whichever the reason.
1036
+ */
1037
+ const infoNoticeStyle = (color: string, t: ForgeTheme): CSSProperties => ({
1038
+ marginTop: 12,
1039
+ padding: "8px 12px",
1040
+ borderRadius: t.cornerRadius,
1041
+ fontSize: 14,
1042
+ fontWeight: 600,
1043
+ background: a(color, 0.1),
1044
+ color,
1045
+ border: `1px solid ${a(color, 0.3)}`,
1046
+ display: "flex",
1047
+ alignItems: "center",
1048
+ gap: 8,
1049
+ });
1050
+
937
1051
  const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
938
1052
  width: 40,
939
1053
  height: 40,
@@ -168,7 +168,9 @@ function WaitlistPlaceCard({
168
168
  {offerOpen ? (
169
169
  <span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700, color: t.primary }}>
170
170
  <BellRing size={16} />
171
- Tickets available
171
+ {/* 2.6b — "reserved for you" is a materially different promise from
172
+ "available", and the fan acts differently on each. */}
173
+ {entry.offer.seatsReserved ? "Reserved for you" : "Tickets available"}
172
174
  </span>
173
175
  ) : entry.position != null ? (
174
176
  <span style={{ fontSize: 14, opacity: 0.85 }}>
@@ -213,6 +215,9 @@ function WaitlistPlaceCard({
213
215
  */
214
216
  function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
215
217
  const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
218
+ // Absent on a payload from an older API build — read as "not reserved", which
219
+ // is the weaker and therefore safe reading.
220
+ const reserved = entry.offer.seatsReserved === true;
216
221
 
217
222
  useEffect(() => {
218
223
  setRemaining(entry.offer.secondsRemaining ?? 0);
@@ -254,8 +259,23 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
254
259
  </div>
255
260
  {deadline && (
256
261
  <div style={{ opacity: 0.8, marginTop: 4 }}>
257
- {remaining > 0 ? `Your tickets are held until ${deadline}.` : `Your offer expired at ${deadline}.`} Tickets are
258
- not reserved buy from the ticket options above to secure them.
262
+ {/*
263
+ 2.6bthe sentence is chosen by whether the seats are actually
264
+ HELD, not by whether an offer exists.
265
+
266
+ The old copy said both things at once — "your tickets are held
267
+ until Friday" and "tickets are not reserved" — because the second
268
+ was true and the first was aspirational. Now one of them is true per
269
+ row: a reserved offer really does keep the seats off the market for
270
+ the window, and a grandfathered one really does not. Saying the
271
+ strong version of an offer that reserves nothing is the exact
272
+ failure this change closes.
273
+ */}
274
+ {remaining <= 0
275
+ ? `Your offer expired at ${deadline}.`
276
+ : reserved
277
+ ? `${entry.quantity === 1 ? "Your ticket is" : `Your ${entry.quantity} tickets are`} reserved until ${deadline}. Nobody else can buy ${entry.quantity === 1 ? "it" : "them"}. Check out from the ticket options above.`
278
+ : `Your offer closes ${deadline}. Tickets are not reserved. Buy from the ticket options above to secure them.`}
259
279
  </div>
260
280
  )}
261
281
  </div>
@@ -91,7 +91,7 @@ export function HoldNotice({
91
91
  {/* The server's message names the right noun and the right verb
92
92
  tense; ours only stands in before it has spoken. */}
93
93
  {expiredMessage ??
94
- `These ${noun} are no longer held for you. Try again to reserve them they may well still be available.`}
94
+ `These ${noun} are no longer held for you. Try again to reserve them: they may well still be available.`}
95
95
  </p>
96
96
  {(onRetry || secondaryAction) && (
97
97
  <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
@@ -163,7 +163,7 @@ export function HoldNotice({
163
163
  </p>
164
164
  <p style={{ fontSize: 13, opacity: 0.75, margin: "4px 0 0" }}>
165
165
  {hold.isUrgent
166
- ? `Complete payment soon after this your ${noun} go back on sale.`
166
+ ? `Complete payment soon: after this your ${noun} go back on sale.`
167
167
  : `We're holding your ${noun} while you pay.`}
168
168
  </p>
169
169
  </div>
@@ -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")}