@tribe-nest/forge 3.22.0 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/useAnalytics.ts +59 -8
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +16 -0
  8. package/src/data/queries/useMusicLink.ts +28 -0
  9. package/src/data/queries/useMyBookings.ts +25 -0
  10. package/src/data/queries/usePaymentFlow.ts +84 -18
  11. package/src/data/queries/useShipping.ts +5 -0
  12. package/src/data/queries/useSubscriptions.ts +50 -4
  13. package/src/index.ts +6 -0
  14. package/src/server/index.ts +51 -0
  15. package/src/server/platform.ts +1 -1
  16. package/src/server/platformEvents.generated.ts +33 -33
  17. package/src/server/pwa.ts +2 -2
  18. package/src/types/models.ts +94 -5
  19. package/src/types/paystack-inline.d.ts +45 -0
  20. package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
  21. package/src/ui/format/_tests/attendees.spec.ts +4 -4
  22. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  23. package/src/ui/format/attendees.ts +2 -2
  24. package/src/ui/format/ticketAvailability.ts +226 -0
  25. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  26. package/src/ui/headless/checkout/useCheckout.ts +72 -8
  27. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  29. package/src/ui/headless/donation/Donation.tsx +10 -0
  30. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  31. package/src/ui/headless/event/useEventCheckout.ts +70 -0
  32. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  33. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  34. package/src/ui/headless/offer/Offer.tsx +19 -1
  35. package/src/ui/headless/offer/OfferContext.tsx +14 -1
  36. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  37. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  38. package/src/ui/index.ts +23 -0
  39. package/src/ui/shell/diagnosticsGating.ts +3 -3
  40. package/src/ui/styled/AccountDashboard.tsx +73 -4
  41. package/src/ui/styled/AudioPlayer.tsx +1 -1
  42. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  43. package/src/ui/styled/Checkout.tsx +25 -32
  44. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  45. package/src/ui/styled/CoachingBooking.tsx +11 -2
  46. package/src/ui/styled/CoachingConfirmation.tsx +3 -3
  47. package/src/ui/styled/CohortPage.tsx +1 -1
  48. package/src/ui/styled/ContactForm.tsx +1 -1
  49. package/src/ui/styled/CourseCheckout.tsx +10 -1
  50. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  51. package/src/ui/styled/DiscountCode.tsx +1 -1
  52. package/src/ui/styled/EmailListForm.tsx +1 -1
  53. package/src/ui/styled/EventConfirmation.tsx +1 -1
  54. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  55. package/src/ui/styled/EventTickets.tsx +135 -21
  56. package/src/ui/styled/EventWaitlist.tsx +23 -3
  57. package/src/ui/styled/HoldNotice.tsx +2 -2
  58. package/src/ui/styled/InvoicePayment.tsx +14 -5
  59. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  60. package/src/ui/styled/MusicLinkPage.tsx +365 -0
  61. package/src/ui/styled/OfferButton.tsx +1 -1
  62. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  63. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  64. package/src/ui/styled/PresaleCode.tsx +1 -1
  65. package/src/ui/styled/ReviewForm.tsx +2 -2
  66. package/src/ui/styled/TicketTransfer.tsx +3 -3
  67. package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
  68. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  69. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  70. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  71. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  72. package/src/utils/membershipAccess.ts +3 -3
  73. package/src/utils/paystackCheckout.ts +277 -0
  74. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -0,0 +1,365 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import type { CSSProperties } from "react";
3
+ import type { IMusicLink } from "../../types/models";
4
+ import { useMusicLink } from "../../data/queries/useMusicLink";
5
+ import { newEventId, useTrackEvent } from "../../data/queries/useAnalytics";
6
+ import { usePageMetaPixel } from "../analytics/PageMetaPixel";
7
+ import { PageMetaPixel } from "../analytics/PageMetaPixel";
8
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
+ import { PageActions } from "./PageActions";
10
+
11
+ /**
12
+ * How each streaming service is presented.
13
+ *
14
+ * `color` is the service's own brand colour, used for the badge, and `label` is
15
+ * the name a fan recognises rather than the API key.
16
+ *
17
+ * There is deliberately no logo artwork here. Reproducing a dozen trademarked
18
+ * marks from memory gets some of them subtly wrong, and every one of these
19
+ * services publishes brand guidelines with rules about colour, clear space and
20
+ * permitted alterations. A wrong Spotify logo on an artist's own domain is the
21
+ * artist's problem, not ours to create. Drop real SVGs in behind `icon` when
22
+ * the assets have been taken from each service's brand kit; the layout already
23
+ * reserves the space.
24
+ */
25
+ const SERVICES: Record<string, { label: string; color: string; action?: string }> = {
26
+ spotify: { label: "Spotify", color: "#1DB954", action: "Play" },
27
+ appleMusic: { label: "Apple Music", color: "#FA243C", action: "Play" },
28
+ itunes: { label: "iTunes", color: "#FA243C", action: "Buy" },
29
+ youtube: { label: "YouTube", color: "#FF0000", action: "Watch" },
30
+ youtubeMusic: { label: "YouTube Music", color: "#FF0000", action: "Play" },
31
+ deezer: { label: "Deezer", color: "#A238FF", action: "Play" },
32
+ tidal: { label: "Tidal", color: "#000000", action: "Play" },
33
+ amazonMusic: { label: "Amazon Music", color: "#25D1DA", action: "Play" },
34
+ soundcloud: { label: "SoundCloud", color: "#FF5500", action: "Play" },
35
+ bandcamp: { label: "Bandcamp", color: "#629AA9", action: "Buy" },
36
+ audiomack: { label: "Audiomack", color: "#FFA200", action: "Play" },
37
+ boomplay: { label: "Boomplay", color: "#E62E2D", action: "Play" },
38
+ anghami: { label: "Anghami", color: "#8A2BE2", action: "Play" },
39
+ pandora: { label: "Pandora", color: "#3668FF", action: "Play" },
40
+ napster: { label: "Napster", color: "#00B9F1", action: "Play" },
41
+ audius: { label: "Audius", color: "#CC0FE0", action: "Play" },
42
+ tiktok: { label: "TikTok", color: "#000000", action: "Open" },
43
+ };
44
+
45
+ const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
46
+
47
+ export interface MusicLinkPageProps {
48
+ /** The slug from the route. Ignored when `link` is passed directly. */
49
+ slug?: string;
50
+ /** Server-fetched link from the route loader. Renders with no client fetch. */
51
+ initialLink?: IMusicLink | null;
52
+ /** Heading above the service list. */
53
+ listHeading?: string;
54
+ /** Shown when the slug resolves to nothing. */
55
+ notFoundMessage?: string;
56
+ className?: string;
57
+ style?: CSSProperties;
58
+ }
59
+
60
+ /**
61
+ * A music smart link page: artwork, title, and one row per streaming service.
62
+ *
63
+ * ─────────────────────────────────────────────────────────────────────────
64
+ * WHY THIS RENDERS AS A FIXED FULL-VIEWPORT LAYER
65
+ * ─────────────────────────────────────────────────────────────────────────
66
+ * A landing page has to own the screen, and on a deployed site there is no other
67
+ * way to get it. The starter's `__root.tsx` wraps every route in `<Nav/>`, a
68
+ * `max-width: 1040px` `<main>`, and `<SiteFooter/>`; it is tenant-owned, it is
69
+ * never overwritten by an update, and a route cannot escape the root route in
70
+ * TanStack. So a page that merely styles itself well still renders as a column
71
+ * in the middle of the artist's website, under their menu.
72
+ *
73
+ * Covering the viewport from inside the block is the only mechanism that works
74
+ * on sites that already exist, which is all of them. It is the default rather
75
+ * than an option for the same reason.
76
+ *
77
+ * ─────────────────────────────────────────────────────────────────────────
78
+ * WHY EACH SERVICE IS A REAL ANCHOR
79
+ * ─────────────────────────────────────────────────────────────────────────
80
+ * `<a href>`, not a button with a JS redirect. Fans long-press to copy, open in
81
+ * a new tab, and share these links onward, and a click handler pretending to be
82
+ * a link breaks all three. The tracking hangs off the click; it does not own the
83
+ * navigation, and if tracking fails the fan still reaches Spotify.
84
+ */
85
+ export function MusicLinkPage({
86
+ slug,
87
+ initialLink,
88
+ listHeading = "Listen on",
89
+ notFoundMessage = "This link is no longer available.",
90
+ className,
91
+ style,
92
+ }: MusicLinkPageProps) {
93
+ const siteTokens = useThemeTokens();
94
+ const { data, isLoading } = useMusicLink(slug, { initialData: initialLink });
95
+ const link = data ?? initialLink ?? null;
96
+
97
+ /**
98
+ * Site theme, then the link's `mode` preset, then its explicit colours.
99
+ *
100
+ * Three layers rather than one because the three answer different questions.
101
+ * The site theme is what every link should look like by default, and a creator
102
+ * who restyles their site should not then restyle twenty links. `mode` is how
103
+ * people actually talk about a release page ("make this one dark"), and it has
104
+ * to move background, text, surface and border together or the result is
105
+ * unreadable. Explicit colours are the escape from both.
106
+ */
107
+ const t = useMemo(() => {
108
+ const theme = link?.theme;
109
+ if (!theme) return siteTokens;
110
+
111
+ const preset =
112
+ theme.mode === "dark"
113
+ ? { background: "#0b0b0d", text: "#ffffff", surface: "#17171b", border: "#2a2a30", muted: "#a1a1aa" }
114
+ : theme.mode === "light"
115
+ ? { background: "#ffffff", text: "#111113", surface: "#f5f5f7", border: "#e4e4e7", muted: "#71717a" }
116
+ : {};
117
+
118
+ return {
119
+ ...siteTokens,
120
+ ...preset,
121
+ ...(theme.background ? { background: theme.background } : {}),
122
+ ...(theme.text ? { text: theme.text } : {}),
123
+ ...(theme.surface ? { surface: theme.surface } : {}),
124
+ ...(theme.border ? { border: theme.border } : {}),
125
+ ...(theme.accent ? { primary: theme.accent } : {}),
126
+ cornerRadius: theme.buttonShape === "pill" ? 999 : theme.buttonShape === "square" ? 0 : siteTokens.cornerRadius,
127
+ };
128
+ }, [siteTokens, link?.theme]);
129
+
130
+ const pixelId = link?.metaPixelId ?? "";
131
+ const { fire } = usePageMetaPixel(pixelId);
132
+ const { trackBeacon } = useTrackEvent();
133
+
134
+ // Lock the page behind the overlay. Without this the tenant's own page keeps
135
+ // scrolling underneath on iOS, which reads as a rendering bug.
136
+ useEffect(() => {
137
+ if (typeof document === "undefined") return;
138
+ const previous = document.body.style.overflow;
139
+ document.body.style.overflow = "hidden";
140
+ return () => {
141
+ document.body.style.overflow = previous;
142
+ };
143
+ }, []);
144
+
145
+ const destinations = useMemo(() => link?.destinations ?? [], [link]);
146
+
147
+ const onDestinationClick = (platform: string, url: string) => {
148
+ // ONE id, shared by the browser pixel event and the server-side CAPI twin
149
+ // that `maybeSendPageCapi` emits for this click. Meta deduplicates on
150
+ // (event_id, event_name), so without a shared id the same tap is counted as
151
+ // two conversions and the campaign optimises against inflated numbers.
152
+ const eventId = newEventId();
153
+
154
+ fire("Lead", { content_name: serviceMeta(platform).label, content_category: "music_link" });
155
+
156
+ trackBeacon("click", {
157
+ pathname: typeof window !== "undefined" ? window.location.pathname : undefined,
158
+ destination: platform,
159
+ slug: link?.slug,
160
+ href: url,
161
+ eventId,
162
+ });
163
+ };
164
+
165
+ if (!link) {
166
+ return (
167
+ <Overlay t={t} className={className} style={style}>
168
+ <p style={{ color: t.muted, fontFamily: t.fontFamily }}>{isLoading ? "" : notFoundMessage}</p>
169
+ </Overlay>
170
+ );
171
+ }
172
+
173
+ return (
174
+ <Overlay
175
+ t={t}
176
+ className={className}
177
+ style={style}
178
+ artworkUrl={link.theme?.artworkBackdrop === false ? null : link.artworkUrl}
179
+ >
180
+ {pixelId ? <PageMetaPixel pixelId={pixelId} /> : null}
181
+
182
+ <div style={{ width: "100%", maxWidth: 420, display: "flex", flexDirection: "column", alignItems: "center" }}>
183
+ {link.artworkUrl ? (
184
+ <img
185
+ src={link.artworkUrl}
186
+ alt={link.title}
187
+ width={260}
188
+ height={260}
189
+ style={{
190
+ width: 260,
191
+ height: 260,
192
+ maxWidth: "70vw",
193
+ maxHeight: "70vw",
194
+ objectFit: "cover",
195
+ borderRadius: t.cornerRadius,
196
+ boxShadow: "0 18px 50px rgba(0,0,0,0.45)",
197
+ }}
198
+ />
199
+ ) : null}
200
+
201
+ <h1
202
+ style={{
203
+ margin: "24px 0 4px",
204
+ fontSize: 24,
205
+ lineHeight: 1.25,
206
+ textAlign: "center",
207
+ color: t.text,
208
+ fontFamily: t.headingFontFamily || t.fontFamily,
209
+ }}
210
+ >
211
+ {link.title}
212
+ </h1>
213
+
214
+ {link.artistName ? (
215
+ <p style={{ margin: 0, fontSize: 16, color: t.muted, fontFamily: t.fontFamily }}>{link.artistName}</p>
216
+ ) : null}
217
+
218
+ {link.description ? (
219
+ <p
220
+ style={{
221
+ margin: "12px 0 0",
222
+ fontSize: 14,
223
+ textAlign: "center",
224
+ color: t.muted,
225
+ fontFamily: t.fontFamily,
226
+ }}
227
+ >
228
+ {link.description}
229
+ </p>
230
+ ) : null}
231
+
232
+ {destinations.length > 0 ? (
233
+ <p
234
+ style={{
235
+ margin: "28px 0 12px",
236
+ fontSize: 12,
237
+ letterSpacing: "0.08em",
238
+ textTransform: "uppercase",
239
+ color: t.muted,
240
+ fontFamily: t.fontFamily,
241
+ }}
242
+ >
243
+ {listHeading}
244
+ </p>
245
+ ) : null}
246
+
247
+ <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 10 }}>
248
+ {destinations.map((destination) => {
249
+ const meta = serviceMeta(destination.platform);
250
+ return (
251
+ <a
252
+ key={destination.platform}
253
+ href={destination.url}
254
+ target="_blank"
255
+ rel="noopener noreferrer"
256
+ // Stands the global click listener down. It would otherwise
257
+ // record a second, poorer event for this same tap, and its
258
+ // payload would drive a duplicate CAPI Lead.
259
+ data-track-skip=""
260
+ data-track={`music-link-${destination.platform}`}
261
+ onClick={() => onDestinationClick(destination.platform, destination.url)}
262
+ style={{
263
+ display: "flex",
264
+ alignItems: "center",
265
+ gap: 12,
266
+ padding: "12px 14px",
267
+ borderRadius: t.cornerRadius,
268
+ background: t.surface,
269
+ border: `1px solid ${t.border}`,
270
+ color: t.text,
271
+ textDecoration: "none",
272
+ fontFamily: t.fontFamily,
273
+ }}
274
+ >
275
+ <span
276
+ aria-hidden="true"
277
+ style={{
278
+ width: 34,
279
+ height: 34,
280
+ flex: "0 0 34px",
281
+ borderRadius: 999,
282
+ background: meta.color,
283
+ color: "#ffffff",
284
+ display: "flex",
285
+ alignItems: "center",
286
+ justifyContent: "center",
287
+ fontSize: 15,
288
+ fontWeight: 700,
289
+ }}
290
+ >
291
+ {meta.label.charAt(0)}
292
+ </span>
293
+ <span style={{ flex: 1, fontSize: 15, fontWeight: 600 }}>{meta.label}</span>
294
+ <span style={{ fontSize: 13, fontWeight: 700, color: t.primary }}>{meta.action}</span>
295
+ </a>
296
+ );
297
+ })}
298
+ </div>
299
+
300
+ {/*
301
+ The creator's own additions: a newsletter form, a vinyl pre-order, a
302
+ lead magnet, a donation. This route is a platform-owned chassis file
303
+ that is re-materialized from the starter on every build, so anything
304
+ added to it IN CODE would be overwritten on the next deploy. Page
305
+ actions are data instead, resolved per link with a per-page-type
306
+ default, so the shell stays ours and the contents stay theirs.
307
+ */}
308
+ <div style={{ width: "100%", marginTop: 28 }}>
309
+ <PageActions pageType="music_link" entityId={link.id} />
310
+ </div>
311
+ </div>
312
+ </Overlay>
313
+ );
314
+ }
315
+
316
+ function Overlay({
317
+ t,
318
+ artworkUrl,
319
+ className,
320
+ style,
321
+ children,
322
+ }: {
323
+ t: ReturnType<typeof useThemeTokens>;
324
+ artworkUrl?: string | null;
325
+ className?: string;
326
+ style?: CSSProperties;
327
+ children: React.ReactNode;
328
+ }) {
329
+ return (
330
+ <div
331
+ className={className}
332
+ style={{
333
+ position: "fixed",
334
+ inset: 0,
335
+ zIndex: 60,
336
+ overflowY: "auto",
337
+ background: t.background,
338
+ display: "flex",
339
+ flexDirection: "column",
340
+ alignItems: "center",
341
+ justifyContent: "flex-start",
342
+ padding: "48px 20px 64px",
343
+ ...style,
344
+ }}
345
+ >
346
+ {artworkUrl ? (
347
+ <div
348
+ aria-hidden="true"
349
+ style={{
350
+ position: "absolute",
351
+ inset: 0,
352
+ backgroundImage: `url(${artworkUrl})`,
353
+ backgroundSize: "cover",
354
+ backgroundPosition: "center",
355
+ filter: "blur(48px) saturate(1.4)",
356
+ transform: "scale(1.2)",
357
+ opacity: 0.35,
358
+ pointerEvents: "none",
359
+ }}
360
+ />
361
+ ) : null}
362
+ <div style={{ position: "relative", width: "100%", display: "flex", justifyContent: "center" }}>{children}</div>
363
+ </div>
364
+ );
365
+ }
@@ -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} />