@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.
- package/package.json +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/useAnalytics.ts +59 -8
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +16 -0
- package/src/data/queries/useMusicLink.ts +28 -0
- package/src/data/queries/useMyBookings.ts +25 -0
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +6 -0
- package/src/server/index.ts +51 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +94 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
- package/src/ui/format/_tests/attendees.spec.ts +4 -4
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +2 -2
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/useCheckout.ts +72 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +70 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +14 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +23 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +73 -4
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +25 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +3 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +1 -1
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +135 -21
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +2 -2
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MusicLinkPage.tsx +365 -0
- package/src/ui/styled/OfferButton.tsx +1 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +1 -1
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +3 -3
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- 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"}
|
|
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 } =
|
|
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
|
-
{
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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} />
|