@tribe-nest/forge 3.23.0 → 3.25.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.
@@ -0,0 +1,518 @@
1
+ import { useEffect, useMemo, useRef, useState } 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
+ import { DEFAULT_MUSIC_LINK_STYLE, MusicLinkLayout, type LayoutParts, type MusicLinkStyle } from "./musicLinkStyles";
11
+ import { useCookieConsent } from "../headless/consent/useCookieConsent";
12
+
13
+ /**
14
+ * How each streaming service is presented.
15
+ *
16
+ * `color` is the service's own brand colour, used for the badge, and `label` is
17
+ * the name a fan recognises rather than the API key.
18
+ *
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.
28
+ */
29
+ const SERVICES: Record<string, { label: string; color: string; action?: string }> = {
30
+ spotify: { label: "Spotify", color: "#1DB954", action: "Play" },
31
+ appleMusic: { label: "Apple Music", color: "#FA243C", action: "Play" },
32
+ itunes: { label: "iTunes", color: "#FA243C", action: "Buy" },
33
+ youtube: { label: "YouTube", color: "#FF0000", action: "Watch" },
34
+ youtubeMusic: { label: "YouTube Music", color: "#FF0000", action: "Play" },
35
+ deezer: { label: "Deezer", color: "#A238FF", action: "Play" },
36
+ tidal: { label: "Tidal", color: "#000000", action: "Play" },
37
+ amazonMusic: { label: "Amazon Music", color: "#25D1DA", action: "Play" },
38
+ soundcloud: { label: "SoundCloud", color: "#FF5500", action: "Play" },
39
+ bandcamp: { label: "Bandcamp", color: "#629AA9", action: "Buy" },
40
+ audiomack: { label: "Audiomack", color: "#FFA200", action: "Play" },
41
+ boomplay: { label: "Boomplay", color: "#E62E2D", action: "Play" },
42
+ anghami: { label: "Anghami", color: "#8A2BE2", action: "Play" },
43
+ pandora: { label: "Pandora", color: "#3668FF", action: "Play" },
44
+ napster: { label: "Napster", color: "#00B9F1", action: "Play" },
45
+ audius: { label: "Audius", color: "#CC0FE0", action: "Play" },
46
+ tiktok: { label: "TikTok", color: "#000000", action: "Open" },
47
+ };
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
+
63
+ const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
64
+
65
+ export interface MusicLinkPageProps {
66
+ /** The slug from the route. Ignored when `link` is passed directly. */
67
+ slug?: string;
68
+ /** Server-fetched link from the route loader. Renders with no client fetch. */
69
+ initialLink?: IMusicLink | null;
70
+ /** Heading above the service list. */
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;
79
+ /** Shown when the slug resolves to nothing. */
80
+ notFoundMessage?: string;
81
+ className?: string;
82
+ style?: CSSProperties;
83
+ }
84
+
85
+ /**
86
+ * A music smart link page: artwork, title, and one row per streaming service.
87
+ *
88
+ * ─────────────────────────────────────────────────────────────────────────
89
+ * WHY THIS RENDERS AS A FIXED FULL-VIEWPORT LAYER
90
+ * ─────────────────────────────────────────────────────────────────────────
91
+ * A landing page has to own the screen, and on a deployed site there is no other
92
+ * way to get it. The starter's `__root.tsx` wraps every route in `<Nav/>`, a
93
+ * `max-width: 1040px` `<main>`, and `<SiteFooter/>`; it is tenant-owned, it is
94
+ * never overwritten by an update, and a route cannot escape the root route in
95
+ * TanStack. So a page that merely styles itself well still renders as a column
96
+ * in the middle of the artist's website, under their menu.
97
+ *
98
+ * Covering the viewport from inside the block is the only mechanism that works
99
+ * on sites that already exist, which is all of them. It is the default rather
100
+ * than an option for the same reason.
101
+ *
102
+ * ─────────────────────────────────────────────────────────────────────────
103
+ * WHY EACH SERVICE IS A REAL ANCHOR
104
+ * ─────────────────────────────────────────────────────────────────────────
105
+ * `<a href>`, not a button with a JS redirect. Fans long-press to copy, open in
106
+ * a new tab, and share these links onward, and a click handler pretending to be
107
+ * a link breaks all three. The tracking hangs off the click; it does not own the
108
+ * navigation, and if tracking fails the fan still reaches Spotify.
109
+ */
110
+ export function MusicLinkPage({
111
+ slug,
112
+ initialLink,
113
+ listHeading = "Listen on",
114
+ privacyHref,
115
+ showPoweredBy = true,
116
+ playLabel = "Play a preview",
117
+ pauseLabel = "Pause the preview",
118
+ notFoundMessage = "This link is no longer available.",
119
+ className,
120
+ style,
121
+ }: MusicLinkPageProps) {
122
+ const siteTokens = useThemeTokens();
123
+ const { data, isLoading } = useMusicLink(slug, { initialData: initialLink });
124
+ const link = data ?? initialLink ?? null;
125
+
126
+ /**
127
+ * Site theme, then the link's `mode` preset, then its explicit colours.
128
+ *
129
+ * Three layers rather than one because the three answer different questions.
130
+ * The site theme is what every link should look like by default, and a creator
131
+ * who restyles their site should not then restyle twenty links. `mode` is how
132
+ * people actually talk about a release page ("make this one dark"), and it has
133
+ * to move background, text, surface and border together or the result is
134
+ * unreadable. Explicit colours are the escape from both.
135
+ */
136
+ const t = useMemo(() => {
137
+ const theme = link?.theme;
138
+ if (!theme) return siteTokens;
139
+
140
+ const preset =
141
+ theme.mode === "dark"
142
+ ? { background: "#0b0b0d", text: "#ffffff", surface: "#17171b", border: "#2a2a30", muted: "#a1a1aa" }
143
+ : theme.mode === "light"
144
+ ? { background: "#ffffff", text: "#111113", surface: "#f5f5f7", border: "#e4e4e7", muted: "#71717a" }
145
+ : {};
146
+
147
+ return {
148
+ ...siteTokens,
149
+ ...preset,
150
+ ...(theme.background ? { background: theme.background } : {}),
151
+ ...(theme.text ? { text: theme.text } : {}),
152
+ ...(theme.surface ? { surface: theme.surface } : {}),
153
+ ...(theme.border ? { border: theme.border } : {}),
154
+ ...(theme.accent ? { primary: theme.accent } : {}),
155
+ cornerRadius: theme.buttonShape === "pill" ? 999 : theme.buttonShape === "square" ? 0 : siteTokens.cornerRadius,
156
+ };
157
+ }, [siteTokens, link?.theme]);
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
+
164
+ const pixelId = link?.metaPixelId ?? "";
165
+ const { fire } = usePageMetaPixel(pixelId);
166
+ const { track, trackBeacon } = useTrackEvent();
167
+
168
+ // Lock the page behind the overlay. Without this the tenant's own page keeps
169
+ // scrolling underneath on iOS, which reads as a rendering bug.
170
+ useEffect(() => {
171
+ if (typeof document === "undefined") return;
172
+ const previous = document.body.style.overflow;
173
+ document.body.style.overflow = "hidden";
174
+ return () => {
175
+ document.body.style.overflow = previous;
176
+ };
177
+ }, []);
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
+
214
+ const destinations = useMemo(() => link?.destinations ?? [], [link]);
215
+
216
+ const onDestinationClick = (platform: string, url: string) => {
217
+ // ONE id, shared by the browser pixel event and the server-side CAPI twin
218
+ // that `maybeSendPageCapi` emits for this click. Meta deduplicates on
219
+ // (event_id, event_name), so without a shared id the same tap is counted as
220
+ // two conversions and the campaign optimises against inflated numbers.
221
+ const eventId = newEventId();
222
+
223
+ fire("Lead", { content_name: serviceMeta(platform).label, content_category: "music_link" });
224
+
225
+ trackBeacon("click", {
226
+ pathname: typeof window !== "undefined" ? window.location.pathname : undefined,
227
+ destination: platform,
228
+ slug: link?.slug,
229
+ href: url,
230
+ eventId,
231
+ });
232
+ };
233
+
234
+ if (!link) {
235
+ return (
236
+ <Overlay t={t} className={className} style={style}>
237
+ <p style={{ color: t.muted, fontFamily: t.fontFamily }}>{isLoading ? "" : notFoundMessage}</p>
238
+ </Overlay>
239
+ );
240
+ }
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
+
342
+ return (
343
+ <Overlay
344
+ t={t}
345
+ className={className}
346
+ style={style}
347
+ artworkUrl={link.theme?.artworkBackdrop === false ? null : link.artworkUrl}
348
+ backdropOpacity={immersive ? 0.62 : 0.35}
349
+ scrim={immersive}
350
+ >
351
+ {pixelId ? <PageMetaPixel pixelId={pixelId} /> : null}
352
+
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}
362
+
363
+ <MusicLinkLayout style={activeStyle} parts={parts} />
364
+ </Overlay>
365
+ );
366
+ }
367
+
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;
380
+
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
+ };
394
+
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>
417
+
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>
424
+
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>
437
+ );
438
+ }
439
+
440
+ function Overlay({
441
+ t,
442
+ artworkUrl,
443
+ className,
444
+ style,
445
+ children,
446
+ backdropOpacity = 0.35,
447
+ scrim = false,
448
+ }: {
449
+ t: ReturnType<typeof useThemeTokens>;
450
+ artworkUrl?: string | null;
451
+ className?: string;
452
+ style?: CSSProperties;
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;
458
+ }) {
459
+ return (
460
+ <div
461
+ className={className}
462
+ style={{
463
+ position: "fixed",
464
+ inset: 0,
465
+ zIndex: OVERLAY_Z_INDEX,
466
+ overflowY: "auto",
467
+ background: t.background,
468
+ display: "flex",
469
+ flexDirection: "column",
470
+ alignItems: "center",
471
+ justifyContent: "flex-start",
472
+ padding: "48px 20px 64px",
473
+ ...style,
474
+ }}
475
+ >
476
+ {artworkUrl ? (
477
+ <div
478
+ aria-hidden="true"
479
+ style={{
480
+ position: "absolute",
481
+ inset: 0,
482
+ backgroundImage: `url(${artworkUrl})`,
483
+ backgroundSize: "cover",
484
+ backgroundPosition: "center",
485
+ filter: "blur(48px) saturate(1.4)",
486
+ transform: "scale(1.2)",
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%)",
501
+ pointerEvents: "none",
502
+ }}
503
+ />
504
+ ) : null}
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>
516
+ </div>
517
+ );
518
+ }
@@ -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
+ });