@tribe-nest/forge 3.24.0 → 3.26.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/client/activeFunnel.ts +35 -0
- package/src/client/createForgeClient.ts +9 -0
- package/src/contexts/CartContext.tsx +8 -1
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- package/src/types/models.ts +7 -0
- package/src/ui/headless/checkout/useCheckout.ts +50 -12
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- package/src/ui/styled/BundleConfirmation.tsx +77 -46
- package/src/ui/styled/Checkout.tsx +41 -2
- package/src/ui/styled/EventTickets.tsx +84 -22
- package/src/ui/styled/MusicLinkPage.tsx +290 -137
- package/src/ui/styled/PageActions.tsx +13 -9
- package/src/ui/styled/ProductGrid.tsx +17 -4
- package/src/ui/styled/_tests/musicLinkChrome.spec.ts +48 -0
- package/src/ui/styled/musicLinkStyles.tsx +475 -0
- package/src/ui/styled/musicServiceIcons.ts +26 -0
- package/src/utils/contrast.ts +60 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { CSSProperties } from "react";
|
|
3
3
|
import type { IMusicLink } from "../../types/models";
|
|
4
4
|
import { useMusicLink } from "../../data/queries/useMusicLink";
|
|
@@ -7,6 +7,8 @@ import { usePageMetaPixel } from "../analytics/PageMetaPixel";
|
|
|
7
7
|
import { PageMetaPixel } from "../analytics/PageMetaPixel";
|
|
8
8
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
9
9
|
import { PageActions } from "./PageActions";
|
|
10
|
+
import { DEFAULT_MUSIC_LINK_STYLE, MusicLinkLayout, type LayoutParts, type MusicLinkStyle } from "./musicLinkStyles";
|
|
11
|
+
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* How each streaming service is presented.
|
|
@@ -14,13 +16,15 @@ import { PageActions } from "./PageActions";
|
|
|
14
16
|
* `color` is the service's own brand colour, used for the badge, and `label` is
|
|
15
17
|
* the name a fan recognises rather than the API key.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* Marks and brand hex come from `musicServiceIcons.ts`, generated from
|
|
20
|
+
* simple-icons rather than drawn by hand: reproducing a dozen trademarked logos
|
|
21
|
+
* from memory gets several subtly wrong, and a wrong Spotify mark on an artist's
|
|
22
|
+
* own domain is the artist's problem. Four services (Amazon Music, Boomplay,
|
|
23
|
+
* Anghami, Audius) have no entry there and keep the initial badge; adding them
|
|
24
|
+
* means finding an officially published SVG, not guessing one.
|
|
25
|
+
*
|
|
26
|
+
* `color` below is only the fallback for those four. Anything with a real icon
|
|
27
|
+
* uses the official hex from the generated file.
|
|
24
28
|
*/
|
|
25
29
|
const SERVICES: Record<string, { label: string; color: string; action?: string }> = {
|
|
26
30
|
spotify: { label: "Spotify", color: "#1DB954", action: "Play" },
|
|
@@ -42,6 +46,20 @@ const SERVICES: Record<string, { label: string; color: string; action?: string }
|
|
|
42
46
|
tiktok: { label: "TikTok", color: "#000000", action: "Open" },
|
|
43
47
|
};
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Below `CookieConsent`, which is fixed at 70.
|
|
51
|
+
*
|
|
52
|
+
* This page covers the viewport, so anything the shell renders underneath is
|
|
53
|
+
* hidden. The consent banner must NOT be: this page fires a Meta Pixel, so
|
|
54
|
+
* hiding the control that governs it would mean running marketing tracking
|
|
55
|
+
* with the consent UI painted over. The gap is deliberate, and a spec asserts
|
|
56
|
+
* the ordering so a future bump here cannot quietly close it.
|
|
57
|
+
*/
|
|
58
|
+
const OVERLAY_Z_INDEX = 60;
|
|
59
|
+
|
|
60
|
+
/** TribeNest's hosted policy, matching what `CookieConsent` falls back to. */
|
|
61
|
+
const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
|
|
62
|
+
|
|
45
63
|
const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
|
|
46
64
|
|
|
47
65
|
export interface MusicLinkPageProps {
|
|
@@ -51,6 +69,13 @@ export interface MusicLinkPageProps {
|
|
|
51
69
|
initialLink?: IMusicLink | null;
|
|
52
70
|
/** Heading above the service list. */
|
|
53
71
|
listHeading?: string;
|
|
72
|
+
/** The site's own privacy policy. Falls back to TribeNest's hosted one. */
|
|
73
|
+
privacyHref?: string;
|
|
74
|
+
/** The "Powered by TribeNest" credit. */
|
|
75
|
+
showPoweredBy?: boolean;
|
|
76
|
+
/** Accessible names for the preview control. */
|
|
77
|
+
playLabel?: string;
|
|
78
|
+
pauseLabel?: string;
|
|
54
79
|
/** Shown when the slug resolves to nothing. */
|
|
55
80
|
notFoundMessage?: string;
|
|
56
81
|
className?: string;
|
|
@@ -86,6 +111,10 @@ export function MusicLinkPage({
|
|
|
86
111
|
slug,
|
|
87
112
|
initialLink,
|
|
88
113
|
listHeading = "Listen on",
|
|
114
|
+
privacyHref,
|
|
115
|
+
showPoweredBy = true,
|
|
116
|
+
playLabel = "Play a preview",
|
|
117
|
+
pauseLabel = "Pause the preview",
|
|
89
118
|
notFoundMessage = "This link is no longer available.",
|
|
90
119
|
className,
|
|
91
120
|
style,
|
|
@@ -127,9 +156,14 @@ export function MusicLinkPage({
|
|
|
127
156
|
};
|
|
128
157
|
}, [siteTokens, link?.theme]);
|
|
129
158
|
|
|
159
|
+
// Falls back rather than throwing on an unknown value: a style saved by a
|
|
160
|
+
// newer admin than the site's Forge must render as something, and Classic is
|
|
161
|
+
// the safe something.
|
|
162
|
+
const activeStyle: MusicLinkStyle = (link?.theme?.style as MusicLinkStyle) ?? DEFAULT_MUSIC_LINK_STYLE;
|
|
163
|
+
|
|
130
164
|
const pixelId = link?.metaPixelId ?? "";
|
|
131
165
|
const { fire } = usePageMetaPixel(pixelId);
|
|
132
|
-
const { trackBeacon } = useTrackEvent();
|
|
166
|
+
const { track, trackBeacon } = useTrackEvent();
|
|
133
167
|
|
|
134
168
|
// Lock the page behind the overlay. Without this the tenant's own page keeps
|
|
135
169
|
// scrolling underneath on iOS, which reads as a rendering bug.
|
|
@@ -142,6 +176,41 @@ export function MusicLinkPage({
|
|
|
142
176
|
};
|
|
143
177
|
}, []);
|
|
144
178
|
|
|
179
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
180
|
+
const [playing, setPlaying] = useState(false);
|
|
181
|
+
|
|
182
|
+
// Stop the preview when the page goes away. Without this a fan who taps play
|
|
183
|
+
// and then taps Spotify leaves audio running underneath the store they just
|
|
184
|
+
// opened, which on mobile is both confusing and hard to stop.
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
return () => {
|
|
187
|
+
audioRef.current?.pause();
|
|
188
|
+
};
|
|
189
|
+
}, []);
|
|
190
|
+
|
|
191
|
+
const togglePreview = () => {
|
|
192
|
+
const audio = audioRef.current;
|
|
193
|
+
if (!audio) return;
|
|
194
|
+
if (audio.paused) {
|
|
195
|
+
// A tap IS the user gesture mobile autoplay policy requires, so this is
|
|
196
|
+
// allowed where an autoplaying preview would be blocked. `catch` because
|
|
197
|
+
// it still rejects on some in-app browsers and a rejected promise here
|
|
198
|
+
// must not surface as an error to a fan.
|
|
199
|
+
void audio
|
|
200
|
+
.play()
|
|
201
|
+
.then(() => setPlaying(true))
|
|
202
|
+
.catch(() => setPlaying(false));
|
|
203
|
+
} else {
|
|
204
|
+
audio.pause();
|
|
205
|
+
setPlaying(false);
|
|
206
|
+
}
|
|
207
|
+
if (!audio.paused)
|
|
208
|
+
track("preview_play", {
|
|
209
|
+
pathname: typeof window !== "undefined" ? window.location.pathname : undefined,
|
|
210
|
+
slug: link?.slug,
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
145
214
|
const destinations = useMemo(() => link?.destinations ?? [], [link]);
|
|
146
215
|
|
|
147
216
|
const onDestinationClick = (platform: string, url: string) => {
|
|
@@ -170,146 +239,201 @@ export function MusicLinkPage({
|
|
|
170
239
|
);
|
|
171
240
|
}
|
|
172
241
|
|
|
242
|
+
const previewButton = (size: number) =>
|
|
243
|
+
link.previewUrl ? (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={togglePreview}
|
|
247
|
+
aria-label={playing ? pauseLabel : playLabel}
|
|
248
|
+
data-track-skip=""
|
|
249
|
+
style={{
|
|
250
|
+
width: size,
|
|
251
|
+
height: size,
|
|
252
|
+
borderRadius: 999,
|
|
253
|
+
border: "none",
|
|
254
|
+
cursor: "pointer",
|
|
255
|
+
background: "rgba(0,0,0,0.55)",
|
|
256
|
+
backdropFilter: "blur(4px)",
|
|
257
|
+
color: "#ffffff",
|
|
258
|
+
display: "flex",
|
|
259
|
+
alignItems: "center",
|
|
260
|
+
justifyContent: "center",
|
|
261
|
+
padding: 0,
|
|
262
|
+
flex: "none",
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<svg
|
|
266
|
+
viewBox="0 0 24 24"
|
|
267
|
+
width={Math.round(size * 0.44)}
|
|
268
|
+
height={Math.round(size * 0.44)}
|
|
269
|
+
fill="currentColor"
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
>
|
|
272
|
+
{playing ? (
|
|
273
|
+
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
|
274
|
+
) : (
|
|
275
|
+
<path d="M8 5.14v13.72a.5.5 0 0 0 .76.43l11.14-6.86a.5.5 0 0 0 0-.86L8.76 4.71a.5.5 0 0 0-.76.43z" />
|
|
276
|
+
)}
|
|
277
|
+
</svg>
|
|
278
|
+
</button>
|
|
279
|
+
) : null;
|
|
280
|
+
|
|
281
|
+
const artwork = (size: number) =>
|
|
282
|
+
link.artworkUrl ? (
|
|
283
|
+
<div style={{ position: "relative", lineHeight: 0 }}>
|
|
284
|
+
<img
|
|
285
|
+
src={link.artworkUrl}
|
|
286
|
+
alt={link.title}
|
|
287
|
+
width={size}
|
|
288
|
+
height={size}
|
|
289
|
+
style={{
|
|
290
|
+
width: size,
|
|
291
|
+
height: size,
|
|
292
|
+
maxWidth: "70vw",
|
|
293
|
+
maxHeight: "70vw",
|
|
294
|
+
objectFit: "cover",
|
|
295
|
+
borderRadius: t.cornerRadius,
|
|
296
|
+
boxShadow: "0 18px 50px rgba(0,0,0,0.45)",
|
|
297
|
+
}}
|
|
298
|
+
/>
|
|
299
|
+
|
|
300
|
+
{/*
|
|
301
|
+
The preview, over the cover. Only when there IS one: a play button that
|
|
302
|
+
does nothing is worse than no play button, and plenty of releases
|
|
303
|
+
resolve without a preview. Scaled with the artwork so it stays
|
|
304
|
+
proportionate on the styles that shrink the cover.
|
|
305
|
+
*/}
|
|
306
|
+
{size >= 110 ? (
|
|
307
|
+
<div
|
|
308
|
+
style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
309
|
+
>
|
|
310
|
+
{previewButton(Math.round(size * 0.25))}
|
|
311
|
+
</div>
|
|
312
|
+
) : null}
|
|
313
|
+
</div>
|
|
314
|
+
) : null;
|
|
315
|
+
|
|
316
|
+
const parts: LayoutParts = {
|
|
317
|
+
t,
|
|
318
|
+
link: {
|
|
319
|
+
title: link.title,
|
|
320
|
+
artistName: link.artistName,
|
|
321
|
+
description: link.description,
|
|
322
|
+
artworkUrl: link.artworkUrl,
|
|
323
|
+
},
|
|
324
|
+
destinations,
|
|
325
|
+
metaFor: serviceMeta,
|
|
326
|
+
onDestinationClick,
|
|
327
|
+
artwork,
|
|
328
|
+
previewButton: previewButton(72),
|
|
329
|
+
listHeading,
|
|
330
|
+
pageActions: (
|
|
331
|
+
<div style={{ width: "100%", marginTop: 28 }}>
|
|
332
|
+
<PageActions pageType="music_link" entityId={link.id} />
|
|
333
|
+
</div>
|
|
334
|
+
),
|
|
335
|
+
footer: <SmartLinkFooter t={t} privacyHref={privacyHref} showPoweredBy={showPoweredBy} />,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// The immersive style uses the cover AS the page, so its backdrop is opaque
|
|
339
|
+
// rather than the faint blur the others sit on.
|
|
340
|
+
const immersive = activeStyle === "immersive";
|
|
341
|
+
|
|
173
342
|
return (
|
|
174
343
|
<Overlay
|
|
175
344
|
t={t}
|
|
176
345
|
className={className}
|
|
177
346
|
style={style}
|
|
178
347
|
artworkUrl={link.theme?.artworkBackdrop === false ? null : link.artworkUrl}
|
|
348
|
+
backdropOpacity={immersive ? 0.62 : 0.35}
|
|
349
|
+
scrim={immersive}
|
|
179
350
|
>
|
|
180
351
|
{pixelId ? <PageMetaPixel pixelId={pixelId} /> : null}
|
|
181
352
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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}
|
|
353
|
+
{link.previewUrl ? (
|
|
354
|
+
<audio
|
|
355
|
+
ref={audioRef}
|
|
356
|
+
src={link.previewUrl}
|
|
357
|
+
preload="none"
|
|
358
|
+
onEnded={() => setPlaying(false)}
|
|
359
|
+
onPause={() => setPlaying(false)}
|
|
360
|
+
/>
|
|
361
|
+
) : null}
|
|
200
362
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
lineHeight: 1.25,
|
|
206
|
-
textAlign: "center",
|
|
207
|
-
color: t.text,
|
|
208
|
-
fontFamily: t.headingFontFamily || t.fontFamily,
|
|
209
|
-
}}
|
|
210
|
-
>
|
|
211
|
-
{link.title}
|
|
212
|
-
</h1>
|
|
363
|
+
<MusicLinkLayout style={activeStyle} parts={parts} />
|
|
364
|
+
</Overlay>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
213
367
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
368
|
+
function SmartLinkFooter({
|
|
369
|
+
t,
|
|
370
|
+
privacyHref,
|
|
371
|
+
showPoweredBy,
|
|
372
|
+
}: {
|
|
373
|
+
t: ReturnType<typeof useThemeTokens>;
|
|
374
|
+
privacyHref?: string;
|
|
375
|
+
showPoweredBy: boolean;
|
|
376
|
+
}) {
|
|
377
|
+
const { reopen } = useCookieConsent();
|
|
378
|
+
const href = privacyHref ?? TRIBENEST_PRIVACY_URL;
|
|
379
|
+
const external = !privacyHref;
|
|
217
380
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
381
|
+
const linkStyle: CSSProperties = {
|
|
382
|
+
color: t.muted,
|
|
383
|
+
textDecoration: "none",
|
|
384
|
+
borderBottom: `1px solid ${t.border}`,
|
|
385
|
+
background: "none",
|
|
386
|
+
border: "none",
|
|
387
|
+
borderBottomWidth: 1,
|
|
388
|
+
borderBottomStyle: "solid",
|
|
389
|
+
borderBottomColor: t.border,
|
|
390
|
+
padding: 0,
|
|
391
|
+
font: "inherit",
|
|
392
|
+
cursor: "pointer",
|
|
393
|
+
};
|
|
231
394
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
395
|
+
return (
|
|
396
|
+
<footer
|
|
397
|
+
style={{
|
|
398
|
+
marginTop: 36,
|
|
399
|
+
display: "flex",
|
|
400
|
+
flexWrap: "wrap",
|
|
401
|
+
alignItems: "center",
|
|
402
|
+
justifyContent: "center",
|
|
403
|
+
gap: 14,
|
|
404
|
+
fontSize: 12,
|
|
405
|
+
color: t.muted,
|
|
406
|
+
fontFamily: t.fontFamily,
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
<a
|
|
410
|
+
href={href}
|
|
411
|
+
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
412
|
+
data-track-skip=""
|
|
413
|
+
style={linkStyle}
|
|
414
|
+
>
|
|
415
|
+
Privacy
|
|
416
|
+
</a>
|
|
246
417
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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>
|
|
418
|
+
{/* Reopening the banner is the only way a visitor can change their mind
|
|
419
|
+
once a choice is stored, and the shell puts this in the footer we are
|
|
420
|
+
covering. */}
|
|
421
|
+
<button type="button" onClick={reopen} data-track-skip="" style={linkStyle}>
|
|
422
|
+
Cookie settings
|
|
423
|
+
</button>
|
|
299
424
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
</Overlay>
|
|
425
|
+
{showPoweredBy ? (
|
|
426
|
+
<a
|
|
427
|
+
href="https://www.tribenest.co"
|
|
428
|
+
target="_blank"
|
|
429
|
+
rel="noopener noreferrer"
|
|
430
|
+
data-track-skip=""
|
|
431
|
+
style={{ ...linkStyle, borderBottom: "none" }}
|
|
432
|
+
>
|
|
433
|
+
Powered by TribeNest
|
|
434
|
+
</a>
|
|
435
|
+
) : null}
|
|
436
|
+
</footer>
|
|
313
437
|
);
|
|
314
438
|
}
|
|
315
439
|
|
|
@@ -319,12 +443,18 @@ function Overlay({
|
|
|
319
443
|
className,
|
|
320
444
|
style,
|
|
321
445
|
children,
|
|
446
|
+
backdropOpacity = 0.35,
|
|
447
|
+
scrim = false,
|
|
322
448
|
}: {
|
|
323
449
|
t: ReturnType<typeof useThemeTokens>;
|
|
324
450
|
artworkUrl?: string | null;
|
|
325
451
|
className?: string;
|
|
326
452
|
style?: CSSProperties;
|
|
327
453
|
children: React.ReactNode;
|
|
454
|
+
/** How strongly the cover shows through. Immersive leans on it; the rest hint. */
|
|
455
|
+
backdropOpacity?: number;
|
|
456
|
+
/** A dark gradient over the cover, so light type stays legible on any artwork. */
|
|
457
|
+
scrim?: boolean;
|
|
328
458
|
}) {
|
|
329
459
|
return (
|
|
330
460
|
<div
|
|
@@ -332,7 +462,7 @@ function Overlay({
|
|
|
332
462
|
style={{
|
|
333
463
|
position: "fixed",
|
|
334
464
|
inset: 0,
|
|
335
|
-
zIndex:
|
|
465
|
+
zIndex: OVERLAY_Z_INDEX,
|
|
336
466
|
overflowY: "auto",
|
|
337
467
|
background: t.background,
|
|
338
468
|
display: "flex",
|
|
@@ -354,12 +484,35 @@ function Overlay({
|
|
|
354
484
|
backgroundPosition: "center",
|
|
355
485
|
filter: "blur(48px) saturate(1.4)",
|
|
356
486
|
transform: "scale(1.2)",
|
|
357
|
-
opacity:
|
|
487
|
+
opacity: backdropOpacity,
|
|
488
|
+
pointerEvents: "none",
|
|
489
|
+
}}
|
|
490
|
+
/>
|
|
491
|
+
) : null}
|
|
492
|
+
{/* Artwork is uncontrollable input: a pale cover would leave white type on
|
|
493
|
+
white. The scrim guarantees a dark ground under the immersive style. */}
|
|
494
|
+
{artworkUrl && scrim ? (
|
|
495
|
+
<div
|
|
496
|
+
aria-hidden="true"
|
|
497
|
+
style={{
|
|
498
|
+
position: "absolute",
|
|
499
|
+
inset: 0,
|
|
500
|
+
background: "linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.78) 65%, rgba(0,0,0,0.9) 100%)",
|
|
358
501
|
pointerEvents: "none",
|
|
359
502
|
}}
|
|
360
503
|
/>
|
|
361
504
|
) : null}
|
|
362
|
-
<div
|
|
505
|
+
<div
|
|
506
|
+
style={{
|
|
507
|
+
position: "relative",
|
|
508
|
+
width: "100%",
|
|
509
|
+
display: "flex",
|
|
510
|
+
justifyContent: "center",
|
|
511
|
+
minHeight: scrim ? "100%" : undefined,
|
|
512
|
+
}}
|
|
513
|
+
>
|
|
514
|
+
{children}
|
|
515
|
+
</div>
|
|
363
516
|
</div>
|
|
364
517
|
);
|
|
365
518
|
}
|
|
@@ -15,7 +15,7 @@ export interface PageActionsProps {
|
|
|
15
15
|
placement?: string;
|
|
16
16
|
/**
|
|
17
17
|
* Where product detail lives on this site — `/i/store` on code sites,
|
|
18
|
-
* `/products` on Craft ones. Only
|
|
18
|
+
* `/products` on Craft ones. Only add-on product cards use it.
|
|
19
19
|
*/
|
|
20
20
|
productBasePath?: string;
|
|
21
21
|
className?: string;
|
|
@@ -63,14 +63,11 @@ function ActionRenderer({
|
|
|
63
63
|
case "offer":
|
|
64
64
|
return <OfferButton productId={c.productId} text={c.text} />;
|
|
65
65
|
case "product_cards":
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
);
|
|
72
|
-
case "addons":
|
|
73
|
-
return (
|
|
66
|
+
// One config, two buying rules. `isAddon` gates the products behind the
|
|
67
|
+
// page's own purchase (a ticket on an event page); without it they are
|
|
68
|
+
// ordinary cards anyone can buy on their own. Both draw the same grid,
|
|
69
|
+
// which is why this is one action type rather than two.
|
|
70
|
+
return c.isAddon ? (
|
|
74
71
|
<Addons
|
|
75
72
|
entityId={entityId}
|
|
76
73
|
productIds={c.productIds ?? []}
|
|
@@ -78,6 +75,13 @@ function ActionRenderer({
|
|
|
78
75
|
columns={c.columns}
|
|
79
76
|
productBasePath={productBasePath}
|
|
80
77
|
/>
|
|
78
|
+
) : (
|
|
79
|
+
<div>
|
|
80
|
+
{c.title && <h3 style={{ fontWeight: 600, marginBottom: 12 }}>{c.title}</h3>}
|
|
81
|
+
{/* Chosen products win; with none chosen the grid falls back to the
|
|
82
|
+
shop listing capped at `limit`. */}
|
|
83
|
+
<ProductGrid columns={c.columns} limit={c.limit} productIds={c.productIds} />
|
|
84
|
+
</div>
|
|
81
85
|
);
|
|
82
86
|
case "donation":
|
|
83
87
|
return <DonationButton donationId={c.donationId} text={c.text} />;
|
|
@@ -78,11 +78,24 @@ export function ProductGrid({
|
|
|
78
78
|
if (isLoading) return <Loading />;
|
|
79
79
|
if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>{emptyLabel ?? "No products yet."}</p>;
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// `columns` is the DESKTOP count. Each card is also given a minimum width, so
|
|
82
|
+
// the grid drops to fewer columns as the container narrows and reaches a
|
|
83
|
+
// single column on a phone.
|
|
84
|
+
//
|
|
85
|
+
// No media query, because the column count is per-instance data: a static
|
|
86
|
+
// stylesheet would need a generated class for every distinct `columns` value
|
|
87
|
+
// on the page. `auto-fill` expresses the same intent with no injected CSS,
|
|
88
|
+
// and it responds to the CONTAINER rather than the viewport, so a grid in a
|
|
89
|
+
// narrow column behaves correctly on a wide screen.
|
|
90
|
+
//
|
|
91
|
+
// The minimum is what sets the phone breakpoint. At 260px a 390px-wide phone
|
|
92
|
+
// fits one card ((390 + 16) / (260 + 16) = 1.47), and anything under ~552px
|
|
93
|
+
// stays single-column; a 3-column desktop is unaffected because
|
|
94
|
+
// (1200 - 32) / 3 = 389px already exceeds it. Raised from 150px, which fitted
|
|
95
|
+
// two cramped cards side by side on every phone.
|
|
84
96
|
const gap = 16;
|
|
85
|
-
const
|
|
97
|
+
const minCardWidth = 260;
|
|
98
|
+
const track = `minmax(max(${minCardWidth}px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
|
|
86
99
|
|
|
87
100
|
return (
|
|
88
101
|
<div
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const read = (p: string) => readFileSync(join(__dirname, "..", p), "utf8");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `MusicLinkPage` covers the viewport, so everything the app shell renders
|
|
9
|
+
* underneath it is hidden. These assertions pin the two things that must NOT be.
|
|
10
|
+
*
|
|
11
|
+
* Source assertions rather than a render test on purpose: what is being checked
|
|
12
|
+
* is a relationship between two components' stacking, which a render of either
|
|
13
|
+
* one alone cannot see, and which a person editing one of them would have no
|
|
14
|
+
* reason to think about.
|
|
15
|
+
*/
|
|
16
|
+
describe("music link page chrome", () => {
|
|
17
|
+
const page = read("MusicLinkPage.tsx");
|
|
18
|
+
const consent = read("CookieConsent.tsx");
|
|
19
|
+
|
|
20
|
+
const zIndexOf = (src: string, pattern: RegExp) => {
|
|
21
|
+
const match = pattern.exec(src);
|
|
22
|
+
if (!match) throw new Error("no z-index found");
|
|
23
|
+
return Number(match[1]);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
it("stays below the cookie banner", () => {
|
|
27
|
+
// The page fires a Meta Pixel. Painting over the consent UI would mean
|
|
28
|
+
// running marketing tracking with its only control hidden, which is a
|
|
29
|
+
// compliance problem rather than a layout one.
|
|
30
|
+
const overlay = zIndexOf(page, /const OVERLAY_Z_INDEX = (\d+);/);
|
|
31
|
+
const banner = zIndexOf(consent, /zIndex:\s*(\d+)/);
|
|
32
|
+
|
|
33
|
+
expect(overlay).toBeLessThan(banner);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("carries its own privacy and cookie-settings links", () => {
|
|
37
|
+
// The site footer that normally holds these is covered by the overlay, so
|
|
38
|
+
// the page has to supply them itself or a visitor has no way to read the
|
|
39
|
+
// policy or withdraw consent from the page that tracks them.
|
|
40
|
+
expect(page).toContain("Cookie settings");
|
|
41
|
+
expect(page).toContain("Privacy");
|
|
42
|
+
expect(page).toContain("useCookieConsent");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("credits the platform", () => {
|
|
46
|
+
expect(page).toContain("Powered by TribeNest");
|
|
47
|
+
});
|
|
48
|
+
});
|