@tribe-nest/forge 3.22.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 (69) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/useAuthActions.ts +1 -1
  4. package/src/data/queries/useCheckouts.ts +5 -0
  5. package/src/data/queries/useEventWaitlist.ts +123 -5
  6. package/src/data/queries/useEvents.ts +16 -0
  7. package/src/data/queries/useMyBookings.ts +25 -0
  8. package/src/data/queries/usePaymentFlow.ts +84 -18
  9. package/src/data/queries/useShipping.ts +5 -0
  10. package/src/data/queries/useSubscriptions.ts +50 -4
  11. package/src/index.ts +5 -0
  12. package/src/server/platform.ts +1 -1
  13. package/src/server/platformEvents.generated.ts +33 -33
  14. package/src/server/pwa.ts +2 -2
  15. package/src/types/models.ts +52 -5
  16. package/src/types/paystack-inline.d.ts +45 -0
  17. package/src/ui/format/_tests/attendees.spec.ts +4 -4
  18. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  19. package/src/ui/format/attendees.ts +2 -2
  20. package/src/ui/format/ticketAvailability.ts +226 -0
  21. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  22. package/src/ui/headless/checkout/useCheckout.ts +72 -8
  23. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  24. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  25. package/src/ui/headless/donation/Donation.tsx +10 -0
  26. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  27. package/src/ui/headless/event/useEventCheckout.ts +70 -0
  28. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  29. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  30. package/src/ui/headless/offer/Offer.tsx +19 -1
  31. package/src/ui/headless/offer/OfferContext.tsx +14 -1
  32. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  33. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  34. package/src/ui/index.ts +22 -0
  35. package/src/ui/shell/diagnosticsGating.ts +3 -3
  36. package/src/ui/styled/AccountDashboard.tsx +73 -4
  37. package/src/ui/styled/AudioPlayer.tsx +1 -1
  38. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  39. package/src/ui/styled/Checkout.tsx +25 -32
  40. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  41. package/src/ui/styled/CoachingBooking.tsx +11 -2
  42. package/src/ui/styled/CoachingConfirmation.tsx +3 -3
  43. package/src/ui/styled/CohortPage.tsx +1 -1
  44. package/src/ui/styled/ContactForm.tsx +1 -1
  45. package/src/ui/styled/CourseCheckout.tsx +10 -1
  46. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  47. package/src/ui/styled/DiscountCode.tsx +1 -1
  48. package/src/ui/styled/EmailListForm.tsx +1 -1
  49. package/src/ui/styled/EventConfirmation.tsx +1 -1
  50. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  51. package/src/ui/styled/EventTickets.tsx +135 -21
  52. package/src/ui/styled/EventWaitlist.tsx +23 -3
  53. package/src/ui/styled/HoldNotice.tsx +2 -2
  54. package/src/ui/styled/InvoicePayment.tsx +14 -5
  55. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  56. package/src/ui/styled/OfferButton.tsx +1 -1
  57. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  58. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  59. package/src/ui/styled/PresaleCode.tsx +1 -1
  60. package/src/ui/styled/ReviewForm.tsx +2 -2
  61. package/src/ui/styled/TicketTransfer.tsx +3 -3
  62. package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
  63. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  64. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  65. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  66. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  67. package/src/utils/membershipAccess.ts +3 -3
  68. package/src/utils/paystackCheckout.ts +277 -0
  69. 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")}
@@ -131,7 +131,7 @@ function OfferBody({
131
131
  >
132
132
  {c.variants.map((v) => (
133
133
  <option key={v.id} value={v.id}>
134
- {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)}
135
135
  </option>
136
136
  ))}
137
137
  </select>
@@ -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
+ }
@@ -78,7 +78,7 @@ export function PresaleCodeField({
78
78
  }}
79
79
  >
80
80
  <KeyRound size={14} />
81
- Presale unlocked {presale.code}
81
+ Presale unlocked: {presale.code}
82
82
  </span>
83
83
  <button
84
84
  type="button"
@@ -97,7 +97,7 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
97
97
  <StarRow rating={afterSubmit.rating} size={20} />
98
98
  {held ? (
99
99
  <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
100
- Your review has been received and is awaiting approval it will appear once it's been checked.
100
+ Your review has been received and is awaiting approval. It will appear once it's been checked.
101
101
  </p>
102
102
  ) : (
103
103
  <p style={{ opacity: 0.75, lineHeight: 1.6 }}>
@@ -120,7 +120,7 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
120
120
  {existing.title && <p style={{ fontWeight: 700, margin: 0 }}>{existing.title}</p>}
121
121
  {existing.body && <p style={{ opacity: 0.85, lineHeight: 1.6, margin: 0 }}>{existing.body}</p>}
122
122
  <p style={{ opacity: 0.6, fontSize: 13 }}>
123
- Reviews can be edited for 30 days after they're written this one can no longer be changed.
123
+ Reviews can be edited for 30 days after they're written. This one can no longer be changed.
124
124
  </p>
125
125
  </Panel>
126
126
  </div>
@@ -248,7 +248,7 @@ function TicketTransferRow({
248
248
  <div>
249
249
  Sent to <strong>{pending.toEmail}</strong>
250
250
  {formatWhen(pending.expiresAt) && pending.open
251
- ? ` the link works until ${formatWhen(pending.expiresAt)}.`
251
+ ? `. The link works until ${formatWhen(pending.expiresAt)}.`
252
252
  : "."}
253
253
  </div>
254
254
  <div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
@@ -370,7 +370,7 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
370
370
  <h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>The ticket is yours</h1>
371
371
  <p style={{ marginTop: 8 }}>
372
372
  Ticket <strong>{claimed.eventPassId}</strong> is now in your name. We have emailed it to{" "}
373
- <strong>{claimed.toEmail}</strong> bring that QR code to the door.
373
+ <strong>{claimed.toEmail}</strong>. Bring that QR code to the door.
374
374
  </p>
375
375
  </div>
376
376
  );
@@ -396,7 +396,7 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
396
396
  Claim your ticket
397
397
  </h1>
398
398
  <p style={{ color: t.muted, marginBottom: 24 }}>
399
- Someone sent you their ticket. Tell us what to put on it you do not need an account.
399
+ Someone sent you their ticket. Tell us what to put on it. You do not need an account.
400
400
  </p>
401
401
  <form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
402
402
  <input
@@ -82,7 +82,7 @@ describe("PresaleCodeField", () => {
82
82
  // Without this, a code for a tier that was already VISIBLE (code-gated but
83
83
  // listed) appears to do nothing at all: the tier list is unchanged.
84
84
  const html = render(state({ code: "PRESALE24", status: "accepted", isUnlocked: true, message: "Code applied." }));
85
- expect(html).toContain("Presale unlocked PRESALE24");
85
+ expect(html).toContain("Presale unlocked: PRESALE24");
86
86
  expect(html).toContain('data-testid="presale-code-remove"');
87
87
  // The input is gone — there is nothing left to type.
88
88
  expect(html).not.toContain('data-testid="presale-code-input"');
@@ -329,7 +329,7 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
329
329
  </div>
330
330
  <div style={{ fontSize: 13, color: t.muted, maxWidth: 300 }}>
331
331
  {isForum
332
- ? "Kick things off start the first discussion in this space."
332
+ ? "Kick things off: start the first discussion in this space."
333
333
  : "Be the first to share something with the community."}
334
334
  </div>
335
335
  <button
@@ -237,7 +237,7 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
237
237
  {locked ? (
238
238
  <div style={{ border, borderRadius: t.cornerRadius, padding: 14, textAlign: "center", color: t.muted, fontSize: 14 }}>
239
239
  <Lock size={13} style={{ verticalAlign: -2, marginRight: 6 }} />
240
- This post is locked replies are closed.
240
+ This post is locked: replies are closed.
241
241
  </div>
242
242
  ) : (
243
243
  !replyingTo && <CommunityComposer compact placeholder="Write a reply…" submitLabel="Reply" onSubmit={submitReply} />