@tribe-nest/forge 3.53.0 → 3.57.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 +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, ArrowRight, Calendar, MapPin, RotateCcw, Ticket } from "lucide-react";
1
+ import { AlertTriangle, ArrowRight, Ban, Calendar, Hourglass, MapPin, RotateCcw, Ticket, TimerOff } from "lucide-react";
2
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
3
  import { readableTextOn } from "../theme/contrast";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -8,7 +8,11 @@ import { useForgeT } from "../../i18n";
8
8
  import { useEventOrderFinalize } from "../../data/queries/useFinalize";
9
9
  import { useEvent } from "../../data/queries/useEvents";
10
10
  import { AddToCalendar } from "./AddToCalendar";
11
- import { getTicketOrderOutcome, TICKET_ORDER_REFUNDED_COPY } from "../../utils/ticketOrderOutcome";
11
+ import {
12
+ getTicketOrderOutcome,
13
+ ticketRequestFailureKind,
14
+ TICKET_ORDER_REFUNDED_COPY,
15
+ } from "../../utils/ticketOrderOutcome";
12
16
 
13
17
  export interface EventConfirmationProps {
14
18
  slug: string;
@@ -20,6 +24,12 @@ export interface EventConfirmationProps {
20
24
  formatAmount?: (n: number, currency?: string) => string;
21
25
  /** Where the "browse more events" action links. */
22
26
  explorePath?: string;
27
+ /**
28
+ * Where a fan whose ticket REQUEST lapsed or could not be settled is sent to
29
+ * try again: the event itself, which may still have seats. Default
30
+ * `/i/events/:slug`.
31
+ */
32
+ eventPath?: string;
23
33
  }
24
34
 
25
35
  /** Locale date-time formatting, replicating the site-starter `formatDateTime`. */
@@ -42,6 +52,7 @@ export function EventConfirmation({
42
52
  orderId,
43
53
  formatAmount,
44
54
  explorePath = "/i/events",
55
+ eventPath,
45
56
  }: EventConfirmationProps) {
46
57
  const theme = useForgeTheme();
47
58
  const t = useForgeT();
@@ -75,6 +86,15 @@ export function EventConfirmation({
75
86
  const outcome = getTicketOrderOutcome(order);
76
87
  const paid = outcome === "paid";
77
88
  const refunded = outcome === "refunded";
89
+ /**
90
+ * Ticket requests (curated access, feature 3). `awaiting` is a request the
91
+ * organiser has not decided; `requestEnded` names how one ended without a
92
+ * sale (declined, expired, or approved but the card could not be settled),
93
+ * and is `null` for an ordinary abandoned checkout, whose copy is unchanged.
94
+ */
95
+ const awaiting = outcome === "awaiting_approval";
96
+ const requestEnded = ticketRequestFailureKind(order);
97
+ const tryAgainPath = eventPath ?? `/i/events/${slug}`;
78
98
  const fmt = (n: number) => formatAmt(n, order.currency || undefined);
79
99
  const location =
80
100
  event && event.type !== "virtual" && event.address
@@ -91,6 +111,14 @@ export function EventConfirmation({
91
111
  ) : refunded ? (
92
112
  // Neutral, not alarmed: a refund is a completed outcome, not a fault.
93
113
  <NeutralSeal icon={<RotateCcw size={32} color={alpha(text, 0.65)} />} text={text} />
114
+ ) : awaiting ? (
115
+ // Waiting is the intended state of a request, so it gets the
116
+ // theme's own colour, not warning amber.
117
+ <NeutralSeal icon={<Hourglass size={32} color={primary} />} text={text} />
118
+ ) : requestEnded === "declined" ? (
119
+ <NeutralSeal icon={<Ban size={32} color={alpha(text, 0.65)} />} text={text} />
120
+ ) : requestEnded === "expired" ? (
121
+ <NeutralSeal icon={<TimerOff size={32} color={alpha(text, 0.65)} />} text={text} />
94
122
  ) : (
95
123
  <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
96
124
  )}
@@ -103,7 +131,12 @@ export function EventConfirmation({
103
131
  fontWeight: 700,
104
132
  letterSpacing: "0.22em",
105
133
  textTransform: "uppercase",
106
- color: paid ? primary : refunded ? alpha(text, 0.65) : "#f59e0b",
134
+ color:
135
+ paid || awaiting
136
+ ? primary
137
+ : refunded || requestEnded === "declined" || requestEnded === "expired"
138
+ ? alpha(text, 0.65)
139
+ : "#f59e0b",
107
140
  animationDelay: "0.15s",
108
141
  }}
109
142
  >
@@ -111,22 +144,76 @@ export function EventConfirmation({
111
144
  ? t("forge.event_confirmation.eyebrow_paid")
112
145
  : refunded
113
146
  ? TICKET_ORDER_REFUNDED_COPY.eyebrow
114
- : t("forge.event_confirmation.eyebrow_status", { status: order.status.replace(/_/g, " ") })}
147
+ : awaiting
148
+ ? t("forge.event_confirmation.eyebrow_awaiting")
149
+ : requestEnded === "declined"
150
+ ? t("forge.event_confirmation.eyebrow_declined")
151
+ : requestEnded === "expired"
152
+ ? t("forge.event_confirmation.eyebrow_expired")
153
+ : requestEnded === "payment_failed"
154
+ ? t("forge.event_confirmation.eyebrow_request_payment_failed")
155
+ : t("forge.event_confirmation.eyebrow_status", { status: order.status.replace(/_/g, " ") })}
115
156
  </p>
116
157
  <h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
117
158
  {paid
118
159
  ? t("forge.event_confirmation.title_paid")
119
160
  : refunded
120
161
  ? TICKET_ORDER_REFUNDED_COPY.heading
121
- : t("forge.event_confirmation.title_incomplete")}
162
+ : awaiting
163
+ ? t("forge.event_confirmation.title_awaiting")
164
+ : requestEnded === "declined"
165
+ ? t("forge.event_confirmation.title_declined")
166
+ : requestEnded === "expired"
167
+ ? t("forge.event_confirmation.title_expired")
168
+ : requestEnded === "payment_failed"
169
+ ? t("forge.event_confirmation.title_request_payment_failed")
170
+ : t("forge.event_confirmation.title_incomplete")}
122
171
  </h1>
123
172
  <p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: 0, animationDelay: "0.3s" }}>
124
173
  {paid
125
174
  ? t("forge.event_confirmation.body_paid", { email: order.email })
126
175
  : refunded
127
176
  ? TICKET_ORDER_REFUNDED_COPY.body
128
- : t("forge.event_confirmation.body_incomplete", { status: order.status.replace(/_/g, " ") })}
177
+ : awaiting
178
+ ? t("forge.event_confirmation.body_awaiting_no_date")
179
+ : requestEnded === "declined"
180
+ ? t("forge.event_confirmation.body_declined")
181
+ : requestEnded === "expired"
182
+ ? t("forge.event_confirmation.body_expired")
183
+ : requestEnded === "payment_failed"
184
+ ? t("forge.event_confirmation.body_request_payment_failed")
185
+ : t("forge.event_confirmation.body_incomplete", { status: order.status.replace(/_/g, " ") })}
129
186
  </p>
187
+ {/* "Nothing is charged until they approve" is the promise the whole
188
+ request rests on. Content, not help: a fan who has just committed
189
+ a card reads it every time. */}
190
+ {awaiting && (
191
+ <p
192
+ className="cf-rise"
193
+ data-testid="request-nothing-charged"
194
+ style={{ fontSize: 14, fontWeight: 600, color: primary, margin: "10px 0 0", animationDelay: "0.34s" }}
195
+ >
196
+ {t("forge.event_confirmation.awaiting_nothing_charged")}
197
+ </p>
198
+ )}
199
+ {/* The organiser's own words, when they gave any. Theirs, so quoted
200
+ rather than paraphrased. */}
201
+ {requestEnded === "declined" && order.approvalDecisionNote && (
202
+ <blockquote
203
+ data-testid="request-decision-note"
204
+ style={{
205
+ margin: "14px auto 0",
206
+ padding: "10px 14px",
207
+ maxWidth: 400,
208
+ fontSize: 14,
209
+ color: alpha(text, 0.8),
210
+ borderLeft: `3px solid ${alpha(text, 0.18)}`,
211
+ textAlign: "left",
212
+ }}
213
+ >
214
+ {order.approvalDecisionNote}
215
+ </blockquote>
216
+ )}
130
217
  </div>
131
218
 
132
219
  {/* ── Event summary ── */}
@@ -209,7 +296,9 @@ export function EventConfirmation({
209
296
  ? t("forge.event_confirmation.total_paid")
210
297
  : refunded
211
298
  ? TICKET_ORDER_REFUNDED_COPY.totalLabel
212
- : t("forge.event_confirmation.total")}
299
+ : awaiting
300
+ ? t("forge.event_confirmation.total_on_approval")
301
+ : t("forge.event_confirmation.total")}
213
302
  </span>
214
303
  {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
215
304
  <span style={{ fontSize: 18, fontWeight: 800, color: primary }}>
@@ -242,7 +331,10 @@ export function EventConfirmation({
242
331
  {/* ── Action ── */}
243
332
  <div className="cf-rise" style={{ padding: "20px 30px 30px", animationDelay: "0.52s" }}>
244
333
  <a
245
- href={explorePath}
334
+ // A request that lapsed or could not be settled sends the fan back
335
+ // to the event, which may still have seats; everything else browses.
336
+ href={requestEnded === "expired" || requestEnded === "payment_failed" ? tryAgainPath : explorePath}
337
+ data-testid="confirmation-action"
246
338
  style={{
247
339
  display: "inline-flex",
248
340
  alignItems: "center",
@@ -257,7 +349,12 @@ export function EventConfirmation({
257
349
  textDecoration: "none",
258
350
  }}
259
351
  >
260
- {t("forge.event_confirmation.explore")} <ArrowRight size={16} />
352
+ {requestEnded === "payment_failed"
353
+ ? t("forge.event_confirmation.buy_again")
354
+ : requestEnded === "expired"
355
+ ? t("forge.event_confirmation.view_event")
356
+ : t("forge.event_confirmation.explore")}{" "}
357
+ <ArrowRight size={16} />
261
358
  </a>
262
359
  </div>
263
360
  </ConfirmationCard>
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
2
- import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
2
+ import { AlertCircle, CalendarClock, Clock, KeyRound, ShieldCheck, X } from "lucide-react";
3
3
  import type { IEvent, ITicket } from "../../types/models";
4
4
  import { useEventCheckout } from "../headless/event/useEventCheckout";
5
5
  import { useForgeT } from "../../i18n";
@@ -26,6 +26,8 @@ import { MembershipGateNotice } from "./MembershipGateNotice";
26
26
  import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
27
27
  import { PresaleCodeField } from "./PresaleCode";
28
28
  import { HoldNotice } from "./HoldNotice";
29
+ import { CallHelpHint } from "../media/CallHelpHint";
30
+ import { isDeferredSettlement, deferredChargeNotice } from "../payment/stripeConfirmOutcome";
29
31
  import {
30
32
  DialogRoot,
31
33
  DialogTrigger,
@@ -401,6 +403,16 @@ function TicketStep({
401
403
  const gate =
402
404
  event.membershipGate && !event.membershipGate.allowed ? event.membershipGate : ticket.membershipGate;
403
405
  const isGated = !!gate && !gate.allowed;
406
+ /**
407
+ * Ticket requests (curated access, feature 3). The badge says the
408
+ * tier is requested, not bought; the `?` beside it says what that
409
+ * means for the fan's card. `isMixBlocked` is the one rule the
410
+ * picker enforces itself: one order is either a request or a
411
+ * purchase, so once the selection is one kind the other kind's `+`
412
+ * goes dark, and the sentence under the row says why.
413
+ */
414
+ const needsApproval = ticket.requiresApproval === true;
415
+ const isMixBlocked = c.isTierBlockedByApprovalMix(ticket.id);
404
416
  const borderColor = isExpired ? "#ef4444" : u && !u.expired ? a(u.color, 0.5) : a(theme.colors.primary, 0.25);
405
417
  return (
406
418
  <div
@@ -471,6 +483,31 @@ function TicketStep({
471
483
  {onSaleBadge(saleWindow.startsAt, new Date(now))}
472
484
  </div>
473
485
  )}
486
+ {needsApproval && (
487
+ <div
488
+ data-testid="ticket-approval-badge"
489
+ style={{
490
+ display: "inline-flex",
491
+ alignItems: "center",
492
+ gap: 4,
493
+ padding: "2px 8px",
494
+ borderRadius: 6,
495
+ fontSize: 12,
496
+ fontWeight: 700,
497
+ marginBottom: 6,
498
+ marginRight: 6,
499
+ background: a(theme.colors.primary, 0.1),
500
+ color: theme.colors.primary,
501
+ border: `1px solid ${a(theme.colors.primary, 0.3)}`,
502
+ }}
503
+ >
504
+ <ShieldCheck size={12} />
505
+ {t("forge.event_tickets.approval_badge")}
506
+ <CallHelpHint label={t("forge.event_tickets.approval_help_label")}>
507
+ {t("forge.event_tickets.approval_help_body")}
508
+ </CallHelpHint>
509
+ </div>
510
+ )}
474
511
  {u && !u.expired && (
475
512
  <div
476
513
  style={{
@@ -546,7 +583,9 @@ function TicketStep({
546
583
  // but because letting a buyer build a cart the server will
547
584
  // not sell is precisely the "discoverable only by failing"
548
585
  // journey this change exists to end.
549
- disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled}
586
+ disabled={
587
+ selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled || isMixBlocked
588
+ }
550
589
  style={stepperStyle(theme, true)}
551
590
  >
552
591
  +
@@ -603,6 +642,18 @@ function TicketStep({
603
642
  {seatNotice}
604
643
  </div>
605
644
  )}
645
+ {/* The `+` above went dark for this tier. That is state the fan
646
+ has to act on (remove the others, or leave this one), so it
647
+ stays visible; the reason lives behind the `?`. */}
648
+ {isMixBlocked && !isSoldOut && !isExpired && (
649
+ <div data-testid="ticket-approval-mix-notice" style={infoNoticeStyle(theme.colors.primary, theme)}>
650
+ <AlertCircle size={16} />
651
+ <span>{t("forge.event_tickets.approval_mix_blocked")}</span>
652
+ <CallHelpHint label={t("forge.event_tickets.approval_mix_help_label")}>
653
+ {t("forge.event_tickets.approval_mix_help_body")}
654
+ </CallHelpHint>
655
+ </div>
656
+ )}
606
657
  {isExpired && (
607
658
  <div
608
659
  style={{
@@ -634,7 +685,12 @@ function TicketStep({
634
685
  it has to be the happy path, with a direct tickets-only checkout as
635
686
  the secondary exit. Without add-ons the original flow is untouched:
636
687
  Continue goes straight to details, the cart is the secondary exit. */}
637
- {hasAddons ? (
688
+ {/* A request cannot go to the cart: a bundle forces payment and the API
689
+ refuses approval tiers in one. So a request selection gets the plain
690
+ Continue with no cart exit, whether or not the page sells add-ons. */}
691
+ {c.awaitingApproval ? (
692
+ <ActionBar c={c} fmt={fmt} onNext={c.goToDetails} nextLabel={t("forge.event_tickets.continue")} />
693
+ ) : hasAddons ? (
638
694
  <ActionBar
639
695
  c={c}
640
696
  fmt={fmt}
@@ -851,12 +907,18 @@ function DetailsStep({
851
907
  // fee and any discount: a free ticket with a FLAT booking fee still
852
908
  // costs money and still goes to payment, and a 100%-off code on a paid
853
909
  // ticket does not.
910
+ // A request says so on the button, priced or not: with a price the
911
+ // next screen authorises the card rather than charging it, and with
912
+ // none there is no next screen at all. Either way "Continue to
913
+ // payment" would promise a sale that is not happening.
854
914
  nextLabel={
855
915
  c.isProcessing
856
916
  ? t("forge.event_tickets.processing")
857
- : c.totalAmount > 0
858
- ? t("forge.event_tickets.continue_payment")
859
- : t("forge.event_tickets.confirm")
917
+ : c.awaitingApproval
918
+ ? t("forge.event_tickets.request_tickets")
919
+ : c.totalAmount > 0
920
+ ? t("forge.event_tickets.continue_payment")
921
+ : t("forge.event_tickets.confirm")
860
922
  }
861
923
  nextDisabled={c.isProcessing}
862
924
  />
@@ -880,10 +942,17 @@ function PaymentStep({
880
942
  // row (already contained in the charged total below); inclusive stores get a
881
943
  // muted "Includes X tax" caption. Display only.
882
944
  const taxSummary = summarizeTaxQuote(c.taxQuote);
945
+ // A priced ticket request: start-payment said the charge is deferred, so
946
+ // this screen authorises the card and the wording must not say "purchase".
947
+ const deferred = isDeferredSettlement(c.settlement);
883
948
  return (
884
949
  <div>
885
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.event_tickets.payment_title")}</h2>
886
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.event_tickets.payment_subtitle")}</p>
950
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
951
+ {deferred ? t("forge.event_tickets.authorize_title") : t("forge.event_tickets.payment_title")}
952
+ </h2>
953
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
954
+ {deferred ? t("forge.event_tickets.authorize_subtitle") : t("forge.event_tickets.payment_subtitle")}
955
+ </p>
887
956
  {/* The reservation clock. Event ticket holds are taken unconditionally
888
957
  (no feature switch), so this is the one checkout where the countdown
889
958
  fires for every profile, and it was the one that did not render it. */}
@@ -954,17 +1023,42 @@ function PaymentStep({
954
1023
  so it is checked BEFORE the "not configured" branch. */}
955
1024
  {c.isPaystack ? (
956
1025
  c.canOpenPaystack ? (
957
- <PaystackPayButton
958
- onPay={c.openPaystackCheckout}
959
- label={t("forge.event_tickets.pay", { amount: fmt(c.totalAmount) })}
960
- />
1026
+ <>
1027
+ <PaystackPayButton
1028
+ onPay={c.openPaystackCheckout}
1029
+ label={
1030
+ deferred
1031
+ ? t("forge.event_tickets.authorize", { amount: fmt(c.totalAmount) })
1032
+ : t("forge.event_tickets.pay", { amount: fmt(c.totalAmount) })
1033
+ }
1034
+ />
1035
+ {/* Content, not help: the fan has just been told "Authorize" and
1036
+ must be told when the money moves. The built-in Stripe renderer
1037
+ draws its own copy of this line under its own button. */}
1038
+ {deferred && (
1039
+ <p data-testid="deferred-charge-notice" style={{ fontSize: 13, opacity: 0.8, marginTop: 8 }}>
1040
+ {t("forge.event_tickets.deferred_charged_on_approval")}
1041
+ {deferredChargeNotice(c.deferredStrategy) === "authorization" && (
1042
+ <> {t("forge.event_tickets.deferred_hold_on_statement")}</>
1043
+ )}
1044
+ </p>
1045
+ )}
1046
+ </>
961
1047
  ) : (
962
1048
  <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
963
1049
  )
964
1050
  ) : !renderPayment ? (
965
1051
  <p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.event_tickets.payment_not_configured")}</p>
966
1052
  ) : c.clientSecret ? (
967
- renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
1053
+ renderPayment({
1054
+ clientSecret: c.clientSecret,
1055
+ returnUrl: c.returnUrl,
1056
+ amount: c.totalAmount,
1057
+ mode: "redirect",
1058
+ // Additive: a renderer that ignores these renders the sale it always did.
1059
+ settlement: c.settlement,
1060
+ deferredStrategy: c.deferredStrategy,
1061
+ })
968
1062
  ) : (
969
1063
  <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
970
1064
  )}
@@ -17,10 +17,12 @@ export interface LoginFormProps {
17
17
  * form looks exactly as it did.
18
18
  */
19
19
  membershipTierId?: string;
20
+ /** The membership invite token riding on the same redirect, if any. See `MembershipTierCallout`. */
21
+ membershipInviteToken?: string;
20
22
  }
21
23
 
22
24
  /** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
23
- export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId }: LoginFormProps) {
25
+ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId, membershipInviteToken }: LoginFormProps) {
24
26
  const t = useForgeT();
25
27
  const theme = useForgeTheme();
26
28
  const flow = useLoginFlow({ onSuccess });
@@ -70,7 +72,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId
70
72
  <div style={card}>
71
73
  {/* Still on the way to the tier they picked, so it stays in view while
72
74
  they fetch the code out of their inbox. */}
73
- <MembershipTierCallout membershipTierId={membershipTierId} />
75
+ <MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
74
76
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
75
77
  <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
76
78
  {t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
@@ -118,7 +120,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId
118
120
  <div style={card}>
119
121
  {/* What the fan was doing when they hit this wall. Nothing is drawn when
120
122
  they came here on their own. */}
121
- <MembershipTierCallout membershipTierId={membershipTierId} />
123
+ <MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
122
124
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
123
125
  {flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
124
126
  <form