@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.
@@ -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
- * 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.
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
- <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}
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
- <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>
363
+ <MusicLinkLayout style={activeStyle} parts={parts} />
364
+ </Overlay>
365
+ );
366
+ }
213
367
 
214
- {link.artistName ? (
215
- <p style={{ margin: 0, fontSize: 16, color: t.muted, fontFamily: t.fontFamily }}>{link.artistName}</p>
216
- ) : null}
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
- {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}
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
- {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}
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
- <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>
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
- 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>
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: 60,
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: 0.35,
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 style={{ position: "relative", width: "100%", display: "flex", justifyContent: "center" }}>{children}</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 the `addons` block uses it.
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
- return (
67
- <div>
68
- {c.title && <h3 style={{ fontWeight: 600, marginBottom: 12 }}>{c.title}</h3>}
69
- <ProductGrid columns={c.columns} limit={c.limit} />
70
- </div>
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
- // Responsive: each card is at least ~150px wide, but also at least
82
- // (container / columns), so it renders `columns` across on wide screens and
83
- // wraps to fewer on narrow ones — no fixed column count that cramps mobile.
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 track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
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
+ });