@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.
- package/package.json +2 -1
- package/src/data/queries/useAnalytics.ts +59 -8
- package/src/data/queries/useMusicLink.ts +28 -0
- package/src/index.ts +1 -0
- package/src/server/index.ts +51 -0
- package/src/types/models.ts +49 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/styled/MusicLinkPage.tsx +518 -0
- 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
|
@@ -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
|
+
});
|