@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
@@ -65,13 +65,13 @@ const WHAT_LABEL: Record<string, string> = {
65
65
 
66
66
  /** Human copy for the banner. Pure — no React, no formatting concerns. */
67
67
  export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagnosticsMessage {
68
- const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what} ${f.error}.`);
68
+ const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what}: ${f.error}.`);
69
69
 
70
70
  if (d.probe) {
71
71
  details.push(
72
72
  d.probe.ok
73
73
  ? `The API answered ${d.probe.url} in ${d.probe.ms}ms, so this looks like a per-route failure rather than a network one.`
74
- : `Couldn't reach ${d.probe.url} either ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
74
+ : `Couldn't reach ${d.probe.url} either: ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
75
75
  );
76
76
  }
77
77
  if (d.apiUrl) details.push(`API: ${d.apiUrl}`);
@@ -84,7 +84,7 @@ export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagno
84
84
  // The sentence that would have saved the debugging session this was built
85
85
  // after: the defaults on screen are a symptom, not the setting.
86
86
  consequence:
87
- "You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived not because of how your site is configured. Your published site is unaffected.",
87
+ "You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived, not because of how your site is configured. Your published site is unaffected.",
88
88
  details,
89
89
  };
90
90
  }
@@ -227,6 +227,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
227
227
  const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
228
228
  const cancelMembership = useCancelMembership();
229
229
  const [isCancelling, setIsCancelling] = useState(false);
230
+ const [cancelError, setCancelError] = useState<string | null>(null);
230
231
 
231
232
  const membership = user?.membership;
232
233
  // Two separate questions, deliberately answered separately:
@@ -238,12 +239,20 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
238
239
  const openBillingPortal = useOpenBillingPortal();
239
240
  const [billingError, setBillingError] = useState<string | null>(null);
240
241
 
242
+ // A failure here used to be swallowed whole: the button said "Cancelling…",
243
+ // then said "Cancel" again, and the member was never told anything. That is
244
+ // how a free-tier member — whose cancel the server rejected outright — could
245
+ // press this forever with no effect and no explanation.
241
246
  const onCancel = async () => {
242
247
  if (!membership?.id) return;
248
+ setCancelError(null);
243
249
  setIsCancelling(true);
244
250
  try {
245
251
  await cancelMembership.mutateAsync({ membershipId: membership.id });
246
252
  window.location.reload();
253
+ } catch (error) {
254
+ const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
255
+ setCancelError(detail || "We couldn't cancel your membership. Please try again or contact us.");
247
256
  } finally {
248
257
  setIsCancelling(false);
249
258
  }
@@ -302,6 +311,11 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
302
311
  </p>
303
312
  )}
304
313
  {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
314
+ {cancelError && (
315
+ <p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
316
+ {cancelError}
317
+ </p>
318
+ )}
305
319
  </div>
306
320
  ) : (
307
321
  <p style={{ opacity: 0.8 }}>You don&apos;t have an active membership.</p>
@@ -315,8 +329,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
315
329
  )}
316
330
  {/* Cancelling stays available for anyone who still HAS access — a
317
331
  past_due member must not be trapped in a subscription they can see
318
- but not leave. */}
319
- {access.hasAccess && (
332
+ but not leave. Hidden once it is already scheduled ("Ending"), where
333
+ the only honest label would be "Cancel again". */}
334
+ {access.hasAccess && !access.cancelAtPeriodEnd && (
320
335
  <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
321
336
  {isCancelling ? "Cancelling…" : "Cancel"}
322
337
  </button>
@@ -625,7 +640,7 @@ function WaitlistPlaceRow({
625
640
  fontSize: 14,
626
641
  }}
627
642
  >
628
- <strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}</strong> your offer closes{" "}
643
+ <strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}.</strong> Your offer closes{" "}
629
644
  {deadline.toLocaleString(undefined, {
630
645
  weekday: "short",
631
646
  day: "numeric",
@@ -721,7 +736,60 @@ function formatSessionWindow(booking: MyBooking): string {
721
736
  });
722
737
  const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
723
738
  const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
724
- return `${day}, ${from} ${to}${zone ? ` (${zone})` : ""}`;
739
+ return `${day}, ${from} to ${to}${zone ? ` (${zone})` : ""}`;
740
+ }
741
+
742
+ /**
743
+ * WHERE the session is — the line this portal existed without.
744
+ *
745
+ * A client who bought a coaching call could see the time, the price and an
746
+ * add-to-calendar button, and nothing at all about how to attend; the answer
747
+ * arrived, if it arrived, in a separate email from the coach.
748
+ *
749
+ * The two kinds are not interchangeable, so they do not render the same. An
750
+ * online session's link is an anchor — at 9:59 for a 10:00 call it is the only
751
+ * thing on this row anyone wants — shown in full rather than as the word "Join",
752
+ * because the client may be reading this on a phone and about to type it into
753
+ * a laptop. An in-person address is plain text: it is the coach's own words,
754
+ * often "Studio B, ring the buzzer" rather than anything a maps provider could
755
+ * resolve, and linking it would guess at something they did not say.
756
+ *
757
+ * `location` is already resolved server-side (booking override over product
758
+ * default) and is null unless the session is confirmed, so there is no rule to
759
+ * apply here — which is the same discipline `canReschedule` follows two blocks
760
+ * down.
761
+ */
762
+ function BookingLocationLine({
763
+ location,
764
+ primary,
765
+ }: {
766
+ location: MyBooking["location"];
767
+ primary: string;
768
+ }) {
769
+ if (!location) return null;
770
+
771
+ if (location.type === "online") {
772
+ return (
773
+ <div style={{ fontSize: 13, marginTop: 4 }}>
774
+ <span style={{ opacity: 0.7 }}>Join: </span>
775
+ <a
776
+ href={location.url}
777
+ target="_blank"
778
+ rel="noreferrer"
779
+ style={{ color: primary, textDecoration: "underline", wordBreak: "break-all" }}
780
+ >
781
+ {location.url}
782
+ </a>
783
+ </div>
784
+ );
785
+ }
786
+
787
+ return (
788
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
789
+ <span style={{ fontWeight: 600 }}>Where: </span>
790
+ {location.address}
791
+ </div>
792
+ );
725
793
  }
726
794
 
727
795
  function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
@@ -773,6 +841,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
773
841
  {booking.coachingProductDurationMinutes != null && (
774
842
  <div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
775
843
  )}
844
+ <BookingLocationLine location={booking.location} primary={t.primary} />
776
845
  </div>
777
846
  <div style={{ textAlign: "right" }}>
778
847
  {cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
@@ -77,7 +77,7 @@ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerP
77
77
  <div style={{ flex: 1, minWidth: 0 }}>
78
78
  <div style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
79
79
  {a.currentTrack?.title ?? "Nothing playing"}
80
- {a.currentTrack?.artist ? ` ${a.currentTrack.artist}` : ""}
80
+ {a.currentTrack?.artist ? ` · ${a.currentTrack.artist}` : ""}
81
81
  </div>
82
82
  <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, opacity: 0.8 }}>
83
83
  <span>{fmtTime(a.currentTime)}</span>
@@ -105,7 +105,7 @@ export function BundleConfirmation({
105
105
  <WarnSeal icon={<Clock size={28} />} />
106
106
  <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
107
107
  <p style={{ opacity: 0.75, marginBottom: 20 }}>
108
- Your payment went through and part of your order is confirmed. We&apos;re still finishing the rest — it
108
+ Your payment went through and part of your order is confirmed. We&apos;re still finishing the rest. It
109
109
  will arrive by email shortly, with nothing more to pay.
110
110
  </p>
111
111
  <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
@@ -13,6 +13,7 @@ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
13
13
  import { Loading } from "./Loading";
14
14
  import { CartLineOptions } from "./CartLineOptions";
15
15
  import { HoldNotice } from "./HoldNotice";
16
+ import { PaystackPayButton } from "./PaystackPayButton";
16
17
 
17
18
  export interface CheckoutProps {
18
19
  /** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
@@ -798,11 +799,16 @@ export function Checkout({
798
799
 
799
800
  // ── Payment section ───────────────────────────────────────────────────────────
800
801
  /**
801
- * The payment stage. The order + payment-intent are created by `useCheckout`
802
- * (on entering this stage); here we only render the resolved provider UI
803
- * Stripe via the registered `<ForgePaymentProvider>` renderer, Paystack via a
804
- * redirect button (the flow also auto-redirects). Re-keyed on the client secret
805
- * so a coupon that re-issues the intent remounts the form against the latest one.
802
+ * The payment stage. The order + charge are created by `useCheckout` (on
803
+ * entering this stage); here we only render the resolved provider UI: Stripe
804
+ * via the registered `<ForgePaymentProvider>` renderer, Paystack via a pay
805
+ * button that opens its inline modal ON THIS PAGE. Re-keyed on the client
806
+ * secret so a coupon that re-issues the intent remounts the form against the
807
+ * latest one.
808
+ *
809
+ * The buyer never leaves the creator's site on either provider now. Nothing
810
+ * happens on mount: the modal is opened by the button, so a buyer still reading
811
+ * the summary is not interrupted, and one who closes it can press it again.
806
812
  */
807
813
  function PaymentSection({
808
814
  checkout,
@@ -812,19 +818,15 @@ function PaymentSection({
812
818
  onBack?: () => void;
813
819
  }) {
814
820
  const theme = useForgeTheme();
815
- const { formatCurrency } = useFormatCurrency();
816
821
  const renderPay = usePaymentRenderer();
817
822
  const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
823
+ const { canOpenPaystack, openPaystackCheckout } = checkout;
818
824
  const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
819
825
 
820
- // Paystack has no in-app form send the buyer to the hosted checkout as soon
821
- // as the redirect URL (paymentSecret) is ready. The button below is a manual
822
- // fallback in case the automatic redirect is blocked (e.g. a pop-up blocker).
823
- useEffect(() => {
824
- if (isPaystack && paymentSecret && typeof window !== "undefined") {
825
- window.location.href = paymentSecret;
826
- }
827
- }, [isPaystack, paymentSecret]);
826
+ // "There is something to pay with", whichever provider answered: Stripe's
827
+ // secret or Paystack's session. Used only to decide whether the preparing
828
+ // spinner is still honest.
829
+ const hasCharge = isPaystack ? canOpenPaystack : !!paymentSecret;
828
830
 
829
831
  const backButton = onBack ? (
830
832
  <button
@@ -897,25 +899,16 @@ function PaymentSection({
897
899
  {/* Suppressed once the server has refused: there is no intent coming, so a
898
900
  "Preparing payment…" spinner would sit there forever under a notice
899
901
  that has already explained what happened. */}
900
- {isCreatingOrder && !paymentSecret && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
902
+ {isCreatingOrder && !hasCharge && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
901
903
 
902
904
  {isPaystack
903
- ? paymentSecret &&
904
- !!chargedAmount && (
905
- <button
906
- type="button"
907
- onClick={() => {
908
- window.location.href = paymentSecret;
909
- }}
910
- className="w-full px-4 py-3 font-semibold"
911
- style={{
912
- backgroundColor: theme.colors.primary,
913
- color: readableTextOn(theme.colors.primary),
914
- borderRadius: `${theme.cornerRadius}px`,
915
- }}
916
- >
917
- {`Pay ${formatCurrency(chargedAmount, chargedCurrency || undefined)} Now`}
918
- </button>
905
+ ? canOpenPaystack && (
906
+ <PaystackPayButton
907
+ onPay={openPaystackCheckout}
908
+ amount={chargedAmount}
909
+ currency={chargedCurrency || undefined}
910
+ disabled={isHoldExpired}
911
+ />
919
912
  )
920
913
  : paymentSecret &&
921
914
  renderPay && (
@@ -975,7 +968,7 @@ function ShippingRateSelector({
975
968
  >
976
969
  <div>
977
970
  <span className="text-sm font-medium" style={{ color: theme.colors.text }}>
978
- {group.rates[0].carrier} {group.rates[0].service}
971
+ {group.rates[0].carrier} · {group.rates[0].service}
979
972
  </span>
980
973
  {group.rates[0].estimatedDays != null && (
981
974
  <span className="text-xs ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
@@ -215,10 +215,10 @@ function OrderConfirmation({
215
215
  ? `We’ve emailed your receipt to ${email}.`
216
216
  : "Your receipt is on its way."
217
217
  : isFailed
218
- ? "Your payment didn’t go through you haven’t been charged. You can try again."
218
+ ? "Your payment didn’t go through, so you haven’t been charged. You can try again."
219
219
  : gaveUp
220
220
  ? "This is taking longer than usual. We’ll email your receipt as soon as it clears."
221
- : "Hang tight this only takes a few seconds.";
221
+ : "Hang tight: this only takes a few seconds.";
222
222
 
223
223
  return (
224
224
  <ConfirmationStage>
@@ -6,6 +6,7 @@ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
7
  import { summarizeTaxQuote } from "../format/PriceDisplay";
8
8
  import { Loading } from "./Loading";
9
+ import { PaystackPayButton } from "./PaystackPayButton";
9
10
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
10
11
  import { buttonStyle } from "./Button";
11
12
  import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
@@ -283,7 +284,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
283
284
  }
284
285
  })();
285
286
 
286
- const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
287
+ const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} to ${c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
287
288
  const navButton = (disabled: boolean): CSSProperties => ({
288
289
  padding: 8,
289
290
  borderRadius: theme.cornerRadius,
@@ -545,7 +546,15 @@ function PaymentStep({
545
546
  the money moves, and a policy read three clicks earlier is not a policy
546
547
  read before committing. */}
547
548
  <CancellationTerms terms={c.cancellation} style={{ marginBottom: 16 }} />
548
- {!renderPayment ? (
549
+ {/* Paystack needs no registered renderer at all (it has its own modal),
550
+ so it is checked BEFORE the "not configured" branch. */}
551
+ {c.isPaystack ? (
552
+ c.canOpenPaystack ? (
553
+ <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(total)}`} />
554
+ ) : (
555
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
556
+ )
557
+ ) : !renderPayment ? (
549
558
  <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
550
559
  ) : c.clientSecret ? (
551
560
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: total, mode: "redirect" })
@@ -79,7 +79,7 @@ export function CoachingConfirmation({
79
79
  <ConfirmationStage>
80
80
  <ConfirmationLostCheckout
81
81
  title="We can’t confirm your booking in this browser"
82
- body="Confirming a booking needs the checkout details saved in the tab you started in and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your confirmation email, so check your inbox before booking again. Otherwise, pick a new time and start over."
82
+ body="Confirming a booking needs the checkout details saved in the tab you started in, and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your confirmation email, so check your inbox before booking again. Otherwise, pick a new time and start over."
83
83
  restartPath={restartPath ?? `/i/coaching/${slug}`}
84
84
  restartLabel="Pick a new time"
85
85
  />
@@ -123,7 +123,7 @@ export function CoachingConfirmation({
123
123
  style={{ fontSize: 15, lineHeight: 1.5, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}
124
124
  >
125
125
  {confirmed
126
- ? `Nice one${data?.firstName ? `, ${data.firstName}` : ""} your session is confirmed. We’ve sent the details to ${data?.email ?? "your inbox"}.`
126
+ ? `Nice one${data?.firstName ? `, ${data.firstName}` : ""}. Your session is confirmed. We’ve sent the details to ${data?.email ?? "your inbox"}.`
127
127
  : `Your booking is currently ${data?.status ?? "unresolved"}. If you were charged, reach out and we’ll sort it right away.`}
128
128
  </p>
129
129
  </div>
@@ -139,7 +139,7 @@ export function CoachingConfirmation({
139
139
  <ConfirmationRow label="Session" value={formatSession(data.sessionStartTime)} accent strong />
140
140
  )}
141
141
  {data.durationMinutes ? <ConfirmationRow label="Duration" value={`${data.durationMinutes} minutes`} /> : null}
142
- <ConfirmationRow label="Guest" value={`${data.firstName} ${data.lastName}`.trim() || ""} />
142
+ <ConfirmationRow label="Guest" value={`${data.firstName} ${data.lastName}`.trim() || "Not given"} />
143
143
  <ConfirmationRow label="Email" value={data.email} />
144
144
  <ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
145
145
  {Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
@@ -34,7 +34,7 @@ export function CohortPage({ cohortId }: CohortPageProps) {
34
34
  {(cohort.startDate || cohort.endDate) && (
35
35
  <p style={{ opacity: 0.7, marginBottom: 16 }}>
36
36
  {cohort.startDate ? formatDate(cohort.startDate) : "TBD"}
37
- {cohort.endDate ? ` ${formatDate(cohort.endDate)}` : ""}
37
+ {cohort.endDate ? ` to ${formatDate(cohort.endDate)}` : ""}
38
38
  </p>
39
39
  )}
40
40
  {cohort.description && <p style={{ marginBottom: 24, lineHeight: 1.6 }}>{cohort.description}</p>}
@@ -11,7 +11,7 @@ export interface ContactFormProps {
11
11
  /** One-line themed website contact form, built on `useContactForm`. */
12
12
  export function ContactForm({
13
13
  buttonText = "Send Message",
14
- successMessage = "Message sent — we'll be in touch.",
14
+ successMessage = "Message sent. We'll be in touch.",
15
15
  showJoinEmailList = true,
16
16
  }: ContactFormProps) {
17
17
  const theme = useForgeTheme();
@@ -6,6 +6,7 @@ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
7
  import { summarizeTaxQuote } from "../format/PriceDisplay";
8
8
  import { Loading } from "./Loading";
9
+ import { PaystackPayButton } from "./PaystackPayButton";
9
10
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
10
11
  import { buttonStyle } from "./Button";
11
12
  import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
@@ -354,7 +355,15 @@ function PaymentStep({
354
355
  </span>
355
356
  </div>
356
357
  </div>
357
- {!renderPayment ? (
358
+ {/* Paystack needs no registered renderer at all (it has its own modal),
359
+ so it is checked BEFORE the "not configured" branch. */}
360
+ {c.isPaystack ? (
361
+ c.canOpenPaystack ? (
362
+ <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(amount)}`} />
363
+ ) : (
364
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
365
+ )
366
+ ) : !renderPayment ? (
358
367
  <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
359
368
  ) : c.clientSecret ? (
360
369
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount, mode: "redirect" })
@@ -71,7 +71,7 @@ export function CourseConfirmation({
71
71
  <ConfirmationStage>
72
72
  <ConfirmationLostCheckout
73
73
  title="We can’t confirm your enrollment in this browser"
74
- body="Confirming an enrollment needs the checkout details saved in the tab you started in and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your enrollment email, so check your inbox before paying again. Otherwise, start the enrollment over."
74
+ body="Confirming an enrollment needs the checkout details saved in the tab you started in, and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your enrollment email, so check your inbox before paying again. Otherwise, start the enrollment over."
75
75
  restartPath={restartPath ?? `/i/courses/${slug}`}
76
76
  restartLabel="Start over"
77
77
  />
@@ -177,7 +177,7 @@ export function CourseConfirmation({
177
177
  </div>
178
178
  )}
179
179
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 12, columnGap: 16, margin: 0 }}>
180
- <ConfirmationRow label="Student" value={`${data.firstName} ${data.lastName}`.trim() || ""} />
180
+ <ConfirmationRow label="Student" value={`${data.firstName} ${data.lastName}`.trim() || "Not given"} />
181
181
  <ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
182
182
  {Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
183
183
  <ConfirmationRow label="Tax" value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} />
@@ -129,7 +129,7 @@ export function DiscountCodeField({
129
129
  <div>
130
130
  {applied.map((c) => (
131
131
  <p key={c.code} style={{ margin: 0, fontSize: 14, fontWeight: 600, color: theme.colors.primary }}>
132
- {c.code} {fmt(c.discountAmount)} off
132
+ {c.code} · {fmt(c.discountAmount)} off
133
133
  </p>
134
134
  ))}
135
135
  </div>
@@ -39,7 +39,7 @@ export function EmailListForm({
39
39
  if (f.isSuccess) {
40
40
  return (
41
41
  <div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
42
- {f.requiresConfirmation ? "Almost there check your email to confirm." : successMessage}
42
+ {f.requiresConfirmation ? "Almost there: check your email to confirm." : successMessage}
43
43
  </div>
44
44
  );
45
45
  }
@@ -211,7 +211,7 @@ export function EventConfirmation({
211
211
  )}
212
212
 
213
213
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 10, columnGap: 16, margin: "16px 0 0" }}>
214
- <ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || ""} />
214
+ <ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || "Not given"} />
215
215
  <ConfirmationRow label="Reference" value={`#${order.id.slice(0, 8).toUpperCase()}`} mono />
216
216
  </dl>
217
217
  </div>
@@ -128,7 +128,7 @@ export function EventSeriesDetail({
128
128
  <span>
129
129
  {series.dateCount} dates
130
130
  {series.firstDateTime && series.lastDateTime
131
- ? ` · ${formatDate(series.firstDateTime, series.events[0]?.timezone)} ${formatDate(
131
+ ? ` · ${formatDate(series.firstDateTime, series.events[0]?.timezone)} to ${formatDate(
132
132
  series.lastDateTime,
133
133
  series.events[series.events.length - 1]?.timezone,
134
134
  )}`