@tribe-nest/forge 3.54.0 → 3.58.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 (102) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -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 +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. package/src/utils/landing.ts +1 -1
@@ -4,6 +4,9 @@ import { CreditCard, MapPin, Package, User } from "lucide-react";
4
4
  import { useCheckout, CHECKOUT_STAGE_ORDER, type CheckoutStage } from "../headless/checkout/useCheckout";
5
5
  import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
6
6
  import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
7
+ import type { CountryCode } from "libphonenumber-js";
8
+ import { usePhoneInput } from "../headless/forms/usePhoneInput";
9
+ import { PhoneInput } from "./PhoneInput";
7
10
  import { useForgeT } from "../../i18n";
8
11
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
9
12
  import { readableTextOn } from "../theme/contrast";
@@ -94,6 +97,26 @@ export function Checkout({
94
97
  if (selectedCountryCode) shippingForm.setValue("stateCode", "");
95
98
  }, [selectedCountryCode, shippingForm]);
96
99
 
100
+ /**
101
+ * The delivery phone, in `shipping` mode.
102
+ *
103
+ * `shipping` rather than `messaging` because this number is for a courier to
104
+ * ring, so a landline is a perfectly good answer and the only bar is that the
105
+ * number is real. It also means no SMS warning appears on a field that has
106
+ * nothing to do with SMS.
107
+ *
108
+ * The country comes from the address the visitor is already filling in, which
109
+ * is the most confident source there is, and it arrives AFTER first render:
110
+ * `usePhoneInput` adopts a late-resolving default, but stops doing so once
111
+ * the visitor has touched the select themselves. A number kept in a form
112
+ * field is only ever the E.164 string, never what was typed.
113
+ */
114
+ const shippingPhone = usePhoneInput({
115
+ mode: "shipping",
116
+ defaultCountry: (selectedCountryCode || undefined) as CountryCode | undefined,
117
+ onChange: (e164) => shippingForm.setValue("phone", e164 ?? "", { shouldValidate: true }),
118
+ });
119
+
97
120
  const handleLogin = () => {
98
121
  const here = typeof window !== "undefined" ? window.location.pathname : "/checkout";
99
122
  go(`${loginPath}?redirect=${encodeURIComponent(here)}`);
@@ -155,7 +178,15 @@ export function Checkout({
155
178
  </button>
156
179
  );
157
180
 
158
- const SecondaryButton = ({ text, onClick, fullWidth }: { text: string; onClick?: () => void; fullWidth?: boolean }) => (
181
+ const SecondaryButton = ({
182
+ text,
183
+ onClick,
184
+ fullWidth,
185
+ }: {
186
+ text: string;
187
+ onClick?: () => void;
188
+ fullWidth?: boolean;
189
+ }) => (
159
190
  <button
160
191
  type="button"
161
192
  onClick={onClick}
@@ -186,7 +217,12 @@ export function Checkout({
186
217
  return (
187
218
  <div
188
219
  className={["fg:w-full fg:max-w-4xl fg:mx-auto fg:p-6", className].filter(Boolean).join(" ")}
189
- style={{ backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fontFamily, ...style }}
220
+ style={{
221
+ backgroundColor: theme.colors.background,
222
+ color: theme.colors.text,
223
+ fontFamily: theme.fontFamily,
224
+ ...style,
225
+ }}
190
226
  >
191
227
  <div className="fg:text-center fg:py-12">
192
228
  <h1 className="fg:text-2xl fg:font-bold fg:mb-4" style={{ color: theme.colors.text }}>
@@ -218,7 +254,12 @@ export function Checkout({
218
254
  return (
219
255
  <div
220
256
  className={["fg:w-full fg:max-w-6xl fg:mx-auto fg:p-6", className].filter(Boolean).join(" ")}
221
- style={{ backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fontFamily, ...style }}
257
+ style={{
258
+ backgroundColor: theme.colors.background,
259
+ color: theme.colors.text,
260
+ fontFamily: theme.fontFamily,
261
+ ...style,
262
+ }}
222
263
  >
223
264
  {/* Progress Indicator */}
224
265
  <div className="fg:mb-8">
@@ -227,7 +268,9 @@ export function Checkout({
227
268
  const reached = currentIdx >= stageIdx(stage.key);
228
269
  return (
229
270
  <React.Fragment key={stage.key}>
230
- {idx > 0 && <div className="fg:w-4 fg:h-0.5" style={{ backgroundColor: `${theme.colors.text}${alpha(0.3)}` }} />}
271
+ {idx > 0 && (
272
+ <div className="fg:w-4 fg:h-0.5" style={{ backgroundColor: `${theme.colors.text}${alpha(0.3)}` }} />
273
+ )}
231
274
  <div
232
275
  className="fg:flex fg:items-center"
233
276
  style={{ color: reached ? theme.colors.primary : `${theme.colors.text}${alpha(0.4)}` }}
@@ -263,6 +306,48 @@ export function Checkout({
263
306
  </div>
264
307
  )}
265
308
 
309
+ {/* Seats in the cart that another buyer took (seating wave 2). State
310
+ the buyer has to act on, so it is a visible alert, never a help
311
+ hint: the seats by name and the one way forward, which is back to
312
+ that event's picker. No seat is substituted here. */}
313
+ {c.seatsConflict && (
314
+ <div
315
+ role="alert"
316
+ data-testid="seats-conflict"
317
+ className="fg:mb-4 fg:p-4 fg:text-sm"
318
+ style={{
319
+ color: theme.colors.text,
320
+ border: `1px solid ${theme.colors.primary}`,
321
+ backgroundColor: `${theme.colors.primary}${alpha(0.08)}`,
322
+ borderRadius: `${theme.cornerRadius}px`,
323
+ }}
324
+ >
325
+ <p className="fg:font-semibold fg:mb-1">{tr("forge.checkout.seats_conflict_title")}</p>
326
+ <p className="fg:mb-3">
327
+ {tr("forge.checkout.seats_conflict_body", {
328
+ seats: c.seatsConflict.labels.join(", "),
329
+ event_title: c.seatsConflict.eventTitle,
330
+ })}
331
+ </p>
332
+ <button
333
+ type="button"
334
+ data-testid="seats-conflict-choose"
335
+ className="fg:px-4 fg:py-2 fg:font-semibold"
336
+ style={{
337
+ backgroundColor: theme.colors.primary,
338
+ color: readableTextOn(theme.colors.primary),
339
+ borderRadius: `${theme.cornerRadius}px`,
340
+ }}
341
+ onClick={() => {
342
+ const path = c.chooseOtherSeats();
343
+ if (path) go(path);
344
+ }}
345
+ >
346
+ {tr("forge.checkout.seats_conflict_action")}
347
+ </button>
348
+ </div>
349
+ )}
350
+
266
351
  {/* Stage: User Details */}
267
352
  {c.currentStage === "userDetails" && !c.user && (
268
353
  <div
@@ -290,7 +375,10 @@ export function Checkout({
290
375
  <form onSubmit={guestForm.handleSubmit(c.submitGuest)} className="fg:space-y-4">
291
376
  <div className="fg:grid fg:grid-cols-1 fg:md:grid-cols-2 fg:gap-4">
292
377
  <div>
293
- <label className="fg:block fg:text-sm fg:font-medium fg:mb-2" style={{ color: theme.colors.text }}>
378
+ <label
379
+ className="fg:block fg:text-sm fg:font-medium fg:mb-2"
380
+ style={{ color: theme.colors.text }}
381
+ >
294
382
  {tr("forge.checkout.first_name")}
295
383
  </label>
296
384
  <Controller
@@ -306,11 +394,16 @@ export function Checkout({
306
394
  )}
307
395
  />
308
396
  {guestForm.formState.errors.firstName && (
309
- <p className="fg:text-sm fg:text-red-500 fg:mt-1">{guestForm.formState.errors.firstName.message}</p>
397
+ <p className="fg:text-sm fg:text-red-500 fg:mt-1">
398
+ {guestForm.formState.errors.firstName.message}
399
+ </p>
310
400
  )}
311
401
  </div>
312
402
  <div>
313
- <label className="fg:block fg:text-sm fg:font-medium fg:mb-2" style={{ color: theme.colors.text }}>
403
+ <label
404
+ className="fg:block fg:text-sm fg:font-medium fg:mb-2"
405
+ style={{ color: theme.colors.text }}
406
+ >
314
407
  {tr("forge.checkout.last_name")}
315
408
  </label>
316
409
  <Controller
@@ -326,7 +419,9 @@ export function Checkout({
326
419
  )}
327
420
  />
328
421
  {guestForm.formState.errors.lastName && (
329
- <p className="fg:text-sm fg:text-red-500 fg:mt-1">{guestForm.formState.errors.lastName.message}</p>
422
+ <p className="fg:text-sm fg:text-red-500 fg:mt-1">
423
+ {guestForm.formState.errors.lastName.message}
424
+ </p>
330
425
  )}
331
426
  </div>
332
427
  </div>
@@ -403,7 +498,9 @@ export function Checkout({
403
498
  )}
404
499
  />
405
500
  {shippingForm.formState.errors.address1 && (
406
- <p className="fg:text-sm fg:text-red-500 fg:mt-1">{shippingForm.formState.errors.address1.message}</p>
501
+ <p className="fg:text-sm fg:text-red-500 fg:mt-1">
502
+ {shippingForm.formState.errors.address1.message}
503
+ </p>
407
504
  )}
408
505
  </div>
409
506
 
@@ -464,13 +561,18 @@ export function Checkout({
464
561
  )}
465
562
  />
466
563
  {shippingForm.formState.errors.countryCode && (
467
- <p className="fg:text-sm fg:text-red-500 fg:mt-1">{shippingForm.formState.errors.countryCode.message}</p>
564
+ <p className="fg:text-sm fg:text-red-500 fg:mt-1">
565
+ {shippingForm.formState.errors.countryCode.message}
566
+ </p>
468
567
  )}
469
568
  </div>
470
569
 
471
570
  {selectedCountry?.states && selectedCountry.states.length > 0 && (
472
571
  <div>
473
- <label className="fg:block fg:text-sm fg:font-medium fg:mb-2" style={{ color: theme.colors.text }}>
572
+ <label
573
+ className="fg:block fg:text-sm fg:font-medium fg:mb-2"
574
+ style={{ color: theme.colors.text }}
575
+ >
474
576
  {tr("forge.checkout.state_label")}
475
577
  </label>
476
578
  <Controller
@@ -489,7 +591,9 @@ export function Checkout({
489
591
  )}
490
592
  />
491
593
  {shippingForm.formState.errors.stateCode && (
492
- <p className="fg:text-sm fg:text-red-500 fg:mt-1">{shippingForm.formState.errors.stateCode.message}</p>
594
+ <p className="fg:text-sm fg:text-red-500 fg:mt-1">
595
+ {shippingForm.formState.errors.stateCode.message}
596
+ </p>
493
597
  )}
494
598
  </div>
495
599
  )}
@@ -503,11 +607,11 @@ export function Checkout({
503
607
  control={shippingForm.control}
504
608
  name="phone"
505
609
  rules={{ required: tr("forge.checkout.phone_required") }}
506
- render={({ field }) => (
507
- <input {...field} placeholder={tr("forge.checkout.phone_placeholder")} style={inputStyle} />
508
- )}
610
+ render={() => <PhoneInput controller={shippingPhone} />}
509
611
  />
510
- {shippingForm.formState.errors.phone && (
612
+ {/* The field reports its own shape errors. This line is for the
613
+ form-level "we need one at all", which the field cannot know. */}
614
+ {shippingForm.formState.errors.phone && !shippingPhone.error && (
511
615
  <p className="fg:text-sm fg:text-red-500 fg:mt-1">{shippingForm.formState.errors.phone.message}</p>
512
616
  )}
513
617
  </div>
@@ -652,7 +756,10 @@ export function Checkout({
652
756
 
653
757
  {/* Shipping Info */}
654
758
  {c.hasPhysicalProduct && c.shippingData && (
655
- <div className="fg:mb-4 fg:pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
759
+ <div
760
+ className="fg:mb-4 fg:pb-4"
761
+ style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}
762
+ >
656
763
  <h4 className="fg:font-semibold fg:mb-2" style={{ color: theme.colors.text }}>
657
764
  {tr("forge.checkout.summary_shipping_address")}
658
765
  </h4>
@@ -683,7 +790,11 @@ export function Checkout({
683
790
  .map(([ticketId, qty]) => {
684
791
  const meta = t.ticketMeta[ticketId];
685
792
  return (
686
- <div key={`${t.eventId}-${ticketId}`} className="fg:flex fg:justify-between fg:text-sm" data-testid="summary-ticket-line">
793
+ <div
794
+ key={`${t.eventId}-${ticketId}`}
795
+ className="fg:flex fg:justify-between fg:text-sm"
796
+ data-testid="summary-ticket-line"
797
+ >
687
798
  <div className="fg:flex-1">
688
799
  <p className="fg:font-medium" style={{ color: theme.colors.text }}>
689
800
  {meta?.title ?? tr("forge.checkout.ticket_fallback")}
@@ -694,6 +805,19 @@ export function Checkout({
694
805
  <p className="fg:text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
695
806
  {tr("forge.checkout.qty", { count: qty })}
696
807
  </p>
808
+ {/* The seats being paid for, by name: the one thing
809
+ on this page the buyer chose on a map. */}
810
+ {(meta?.seats?.length ?? 0) > 0 && (
811
+ <p
812
+ className="fg:text-xs"
813
+ data-testid="summary-ticket-seats"
814
+ style={{ color: `${theme.colors.text}${alpha(0.6)}` }}
815
+ >
816
+ {tr("forge.checkout.seats_label", {
817
+ seats: (meta?.seats ?? []).map((seat) => seat.label).join(", "),
818
+ })}
819
+ </p>
820
+ )}
697
821
  </div>
698
822
  <p className="fg:font-medium" style={{ color: theme.colors.text }}>
699
823
  {formatCurrency((meta?.price ?? 0) * qty)}
@@ -740,7 +864,10 @@ export function Checkout({
740
864
  that creates it, and one typed afterwards goes through
741
865
  `/public/checkouts/apply-coupon`. Neither freezes the field. */}
742
866
  {((c.currentStage === "payment" && c.orderId) || c.isBundle) && (
743
- <div className="fg:mb-4 fg:pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
867
+ <div
868
+ className="fg:mb-4 fg:pb-4"
869
+ style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}
870
+ >
744
871
  {!c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
745
872
  <button
746
873
  type="button"
@@ -873,13 +1000,19 @@ export function Checkout({
873
1000
  {/* Inclusive mode: the total already contains tax — break out the
874
1001
  contained amount once known, else a generic muted caption. */}
875
1002
  {taxSummary?.mode === "inclusive" ? (
876
- <p className="fg:text-xs fg:text-right" style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}>
1003
+ <p
1004
+ className="fg:text-xs fg:text-right"
1005
+ style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}
1006
+ >
877
1007
  {taxSummary.label}
878
1008
  </p>
879
1009
  ) : (
880
1010
  !taxSummary &&
881
1011
  pricesIncludeTax && (
882
- <p className="fg:text-xs fg:text-right" style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}>
1012
+ <p
1013
+ className="fg:text-xs fg:text-right"
1014
+ style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}
1015
+ >
883
1016
  {tr("forge.checkout.incl_tax")}
884
1017
  </p>
885
1018
  )
@@ -905,19 +1038,14 @@ export function Checkout({
905
1038
  * happens on mount: the modal is opened by the button, so a buyer still reading
906
1039
  * the summary is not interrupted, and one who closes it can press it again.
907
1040
  */
908
- function PaymentSection({
909
- checkout,
910
- onBack,
911
- }: {
912
- checkout: ReturnType<typeof useCheckout>;
913
- onBack?: () => void;
914
- }) {
1041
+ function PaymentSection({ checkout, onBack }: { checkout: ReturnType<typeof useCheckout>; onBack?: () => void }) {
915
1042
  const theme = useForgeTheme();
916
1043
  const tr = useForgeT();
917
1044
  const renderPay = usePaymentRenderer();
918
- const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
1045
+ const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } =
1046
+ checkout;
919
1047
  const { canOpenPaystack, openPaystackCheckout } = checkout;
920
- const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
1048
+ const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold, seatsConflict } = checkout;
921
1049
 
922
1050
  // "There is something to pay with", whichever provider answered: Stripe's
923
1051
  // secret or Paystack's session. Used only to decide whether the preparing
@@ -995,7 +1123,7 @@ function PaymentSection({
995
1123
  {/* Suppressed once the server has refused: there is no intent coming, so a
996
1124
  "Preparing payment…" spinner would sit there forever under a notice
997
1125
  that has already explained what happened. */}
998
- {isCreatingOrder && !hasCharge && !isHoldExpired && (
1126
+ {isCreatingOrder && !hasCharge && !isHoldExpired && !seatsConflict && (
999
1127
  <Loading label={tr("forge.checkout.preparing_payment")} size={22} />
1000
1128
  )}
1001
1129
 
@@ -1063,7 +1191,10 @@ function ShippingRateSelector({
1063
1191
  // Flat / external — a single, auto-selected rate.
1064
1192
  <div
1065
1193
  className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md"
1066
- style={{ backgroundColor: `${theme.colors.primary}${alpha(0.05)}`, borderRadius: `${theme.cornerRadius}px` }}
1194
+ style={{
1195
+ backgroundColor: `${theme.colors.primary}${alpha(0.05)}`,
1196
+ borderRadius: `${theme.cornerRadius}px`,
1197
+ }}
1067
1198
  >
1068
1199
  <div>
1069
1200
  <span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
@@ -1,4 +1,6 @@
1
+ import type { CountryCode } from "libphonenumber-js";
1
2
  import { useEmailListForm } from "../headless/forms/useEmailListForm";
3
+ import { PhoneInput } from "./PhoneInput";
2
4
  import { useForgeT } from "../../i18n";
3
5
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
6
  import { readableTextOn } from "../theme/contrast";
@@ -8,6 +10,8 @@ export interface EmailListFormProps {
8
10
  leadMagnetId?: string;
9
11
  includeName?: boolean;
10
12
  includePhoneNumber?: boolean;
13
+ /** Seeds the phone country select. Undefined lets the visitor choose. */
14
+ defaultCountry?: CountryCode;
11
15
  title?: string;
12
16
  description?: string;
13
17
  buttonText?: string;
@@ -20,6 +24,7 @@ export function EmailListForm({
20
24
  leadMagnetId,
21
25
  includeName,
22
26
  includePhoneNumber,
27
+ defaultCountry,
23
28
  title,
24
29
  description,
25
30
  buttonText,
@@ -27,7 +32,7 @@ export function EmailListForm({
27
32
  }: EmailListFormProps) {
28
33
  const t = useForgeT();
29
34
  const theme = useForgeTheme();
30
- const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber });
35
+ const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber, defaultCountry });
31
36
 
32
37
  // Defaults resolve here rather than in the signature so they can be
33
38
  // translated. `??` keeps a caller-supplied "" falsy exactly as before.
@@ -71,14 +76,7 @@ export function EmailListForm({
71
76
  onChange={(e) => f.setEmail(e.target.value)}
72
77
  style={inputStyle}
73
78
  />
74
- {includePhoneNumber && (
75
- <input
76
- placeholder={t("forge.email_list_form.phone_placeholder")}
77
- value={f.phoneNumber}
78
- onChange={(e) => f.setPhoneNumber(e.target.value)}
79
- style={inputStyle}
80
- />
81
- )}
79
+ {includePhoneNumber && <PhoneInput controller={f.phone} />}
82
80
  {f.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{f.error}</p>}
83
81
  <button
84
82
  onClick={f.submit}
@@ -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 { EventSeatPicker } from "./seating/EventSeatPicker";
30
+ import { tierColorMap } from "./seating/paint";
29
31
  import { CallHelpHint } from "../media/CallHelpHint";
30
32
  import { isDeferredSettlement, deferredChargeNotice } from "../payment/stripeConfirmOutcome";
31
33
  import {
@@ -86,7 +88,11 @@ export function EventTickets({
86
88
  const theme = useForgeTheme();
87
89
  const registered = usePaymentRenderer();
88
90
  const fmt = useAmountFormatter(formatAmount);
89
- const c = useEventCheckout(slug, { finalisePath, onComplete });
91
+ const [open, setOpen] = useState(false);
92
+ // `seatingActive`: the seating read is requested, and polled, only while
93
+ // this modal is open. Closed, an event page with a seat map costs the API
94
+ // nothing more than one without.
95
+ const c = useEventCheckout(slug, { finalisePath, onComplete, seatingActive: open });
90
96
  const renderPay = renderPayment ?? registered ?? undefined;
91
97
 
92
98
  // Does this page sell add-ons for this event? Same query key as the page's
@@ -103,8 +109,6 @@ export function EventTickets({
103
109
  [pageActionsData],
104
110
  );
105
111
 
106
- const [open, setOpen] = useState(false);
107
-
108
112
  const tryClose = (next: boolean) => {
109
113
  if (!next && c.step !== "tickets" && typeof window !== "undefined") {
110
114
  if (!window.confirm(t("forge.event_tickets.leave_confirm"))) return;
@@ -288,6 +292,9 @@ function TicketStep({
288
292
  // Inclusive stores caption ticket prices as tax-inclusive (display only).
289
293
  const pricesIncludeTax = usePricesIncludeTax();
290
294
  const [now, setNow] = useState(() => Date.now());
295
+ // The same palette the map and the legend use, so the swatch on a tier row
296
+ // is the colour of its seats by construction.
297
+ const tierColors = useMemo(() => tierColorMap(event.tickets.map((ticket) => ticket.id)), [event.tickets]);
291
298
 
292
299
  useEffect(() => {
293
300
  const id = setInterval(() => setNow(Date.now()), 1000);
@@ -365,10 +372,38 @@ function TicketStep({
365
372
  and from the broadcast-pass prompt) where there is no page-level box.
366
373
  Both read the SAME stored code, so unlocking in one unlocks the other. */}
367
374
  <PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
375
+ {/* Reserved seating (wave 2). The map sits above the tier list so the
376
+ buyer picks a seat first and reads the tier row as the receipt for
377
+ it; the row's own controls (chips, best available, the stepper) all
378
+ write the same selection. */}
379
+ {event.hasSeatMap && (
380
+ <div style={{ marginBottom: 16 }}>
381
+ <EventSeatPicker
382
+ event={event}
383
+ seating={c.seating}
384
+ selectedSeats={c.selectedSeats}
385
+ onToggleSeat={c.toggleSeat}
386
+ seatsTaken={c.seatsTaken}
387
+ onDismissSeatsTaken={c.dismissSeatsTaken}
388
+ formatAmount={fmt}
389
+ />
390
+ </div>
391
+ )}
368
392
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
369
393
  {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.no_tickets")}</p>}
370
394
  {event.tickets.map((ticket) => {
371
395
  const selectedQty = c.selectedTickets[ticket.id] ?? 0;
396
+ /**
397
+ * A tier that sells reserved seats: its quantity IS the seat
398
+ * selection, so the row shows the chosen seats as removable chips
399
+ * and offers "Best available" beside the stepper. The stepper stays,
400
+ * picking or dropping best-available seats, for a buyer who does not
401
+ * care where they sit.
402
+ */
403
+ const isSeated = event.hasSeatMap === true && c.isSeatedTier(ticket.id);
404
+ const tierColor = isSeated ? tierColors[ticket.id] : undefined;
405
+ const seatsLeft = c.seating.availableByTier[ticket.id] ?? 0;
406
+ const chosenSeats = c.seatSummary.find((s) => s.ticketId === ticket.id)?.seats ?? [];
372
407
  /**
373
408
  * `quantity - sold - HELD`, which is what the server sells on. The
374
409
  * old `quantity - sold` showed seats sitting in other buyers' live
@@ -429,7 +464,25 @@ function TicketStep({
429
464
  style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}
430
465
  >
431
466
  <div style={{ flex: 1 }}>
432
- <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
467
+ <h3
468
+ style={{
469
+ fontSize: 17,
470
+ fontWeight: 600,
471
+ marginBottom: 6,
472
+ display: "flex",
473
+ alignItems: "center",
474
+ gap: 8,
475
+ }}
476
+ >
477
+ {tierColor && (
478
+ <span
479
+ aria-hidden
480
+ data-testid="ticket-tier-swatch"
481
+ style={{ width: 12, height: 12, borderRadius: 9999, background: tierColor, flexShrink: 0 }}
482
+ />
483
+ )}
484
+ {ticket.title}
485
+ </h3>
433
486
  {/* A hidden tier is only ever on screen because a code
434
487
  revealed it, so say so. Without the badge, a code that
435
488
  unlocked a tier the buyer had not seen listed looks like it
@@ -592,6 +645,56 @@ function TicketStep({
592
645
  </button>
593
646
  </div>
594
647
  </div>
648
+ {isSeated && !isSoldOut && !isExpired && !isScheduled && !isGated && (
649
+ <div
650
+ data-testid="ticket-seat-controls"
651
+ style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, marginTop: 4 }}
652
+ >
653
+ {chosenSeats.map((seat) => (
654
+ <span
655
+ key={seat.id}
656
+ data-testid="ticket-seat-chip"
657
+ style={seatChipStyle(tierColor ?? theme.colors.primary, theme)}
658
+ >
659
+ {seat.label}
660
+ <button
661
+ type="button"
662
+ aria-label={t("forge.event_tickets.seat_remove_aria", { seat: seat.label })}
663
+ onClick={() => c.toggleSeat(ticket.id, seat.id)}
664
+ style={{
665
+ background: "transparent",
666
+ border: "none",
667
+ padding: 0,
668
+ cursor: "pointer",
669
+ color: "inherit",
670
+ display: "inline-flex",
671
+ }}
672
+ >
673
+ <X size={12} />
674
+ </button>
675
+ </span>
676
+ ))}
677
+ <button
678
+ type="button"
679
+ data-testid="ticket-best-available"
680
+ onClick={() => c.pickBestAvailable(ticket.id, Math.max(1, selectedQty))}
681
+ disabled={seatsLeft === 0 || isMixBlocked}
682
+ style={{
683
+ ...secondaryButtonStyle(theme),
684
+ padding: "6px 10px",
685
+ fontSize: 13,
686
+ opacity: seatsLeft === 0 || isMixBlocked ? 0.5 : 1,
687
+ }}
688
+ >
689
+ {t("forge.event_tickets.seat_best_available")}
690
+ </button>
691
+ <span style={{ fontSize: 13, opacity: 0.7 }}>
692
+ {seatsLeft === 1
693
+ ? t("forge.event_tickets.seats_left_one", { count: seatsLeft })
694
+ : t("forge.event_tickets.seats_left_other", { count: seatsLeft })}
695
+ </span>
696
+ </div>
697
+ )}
595
698
  {/* The amount box appears only once a seat is actually selected.
596
699
  Before that there is nothing to price, and a row of empty
597
700
  inputs down the tier list reads as a form to fill in rather
@@ -688,6 +791,9 @@ function TicketStep({
688
791
  {/* A request cannot go to the cart: a bundle forces payment and the API
689
792
  refuses approval tiers in one. So a request selection gets the plain
690
793
  Continue with no cart exit, whether or not the page sells add-ons. */}
794
+ {/* Reserved seats ride in the bundle cart like any other line (the cart
795
+ line carries their ids and labels, and the bundle checkout re-checks
796
+ them before payment), so a seated selection keeps the cart exits. */}
691
797
  {c.awaitingApproval ? (
692
798
  <ActionBar c={c} fmt={fmt} onNext={c.goToDetails} nextLabel={t("forge.event_tickets.continue")} />
693
799
  ) : hasAddons ? (
@@ -965,6 +1071,24 @@ function PaymentStep({
965
1071
  marginBottom: 16,
966
1072
  }}
967
1073
  >
1074
+ {/* The seats being paid for, by name. The one thing on this screen the
1075
+ buyer chose on a map rather than typed, so it is the one thing they
1076
+ will want to re-read before the card. */}
1077
+ {c.seatSummary.length > 0 && (
1078
+ <div data-testid="payment-seat-summary" style={{ marginBottom: 12, fontSize: 14 }}>
1079
+ <div style={{ fontWeight: 600, marginBottom: 4 }}>{t("forge.event_tickets.seats_title")}</div>
1080
+ {c.seatSummary.map((tier) => (
1081
+ <div key={tier.ticketId} style={{ display: "flex", flexDirection: "column", gap: 2, marginBottom: 6 }}>
1082
+ <span style={{ opacity: 0.75 }}>{tier.title}</span>
1083
+ {tier.seats.map((seat) => (
1084
+ <span key={seat.id} style={{ paddingLeft: 12 }}>
1085
+ {seat.label}
1086
+ </span>
1087
+ ))}
1088
+ </div>
1089
+ ))}
1090
+ </div>
1091
+ )}
968
1092
  {/* What the code actually did, from the order response, never a total
969
1093
  that silently shrank. Automatic (no-code) discounts land here too. */}
970
1094
  <DiscountSummaryLines
@@ -1328,6 +1452,21 @@ const infoNoticeStyle = (color: string, t: ForgeTheme): CSSProperties => ({
1328
1452
  gap: 8,
1329
1453
  });
1330
1454
 
1455
+ /** A chosen seat under its tier row, in the tier's colour, with its remove button. */
1456
+ const seatChipStyle = (color: string, t: ForgeTheme): CSSProperties => ({
1457
+ display: "inline-flex",
1458
+ alignItems: "center",
1459
+ gap: 6,
1460
+ padding: "4px 8px",
1461
+ borderRadius: t.cornerRadius,
1462
+ fontSize: 13,
1463
+ fontWeight: 600,
1464
+ background: a(color, 0.12),
1465
+ color: t.colors.text,
1466
+ border: `1px solid ${a(color, 0.5)}`,
1467
+ borderLeft: `4px solid ${color}`,
1468
+ });
1469
+
1331
1470
  const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
1332
1471
  width: 40,
1333
1472
  height: 40,
@@ -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