@tribe-nest/forge 3.23.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -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 +42 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/styled/MusicLinkPage.tsx +365 -0
package/package.json
CHANGED
|
@@ -62,25 +62,76 @@ export interface TrackEventOptions {
|
|
|
62
62
|
* `const { track } = useTrackEvent(); track("cta_click", { id: "hero" })`.
|
|
63
63
|
*/
|
|
64
64
|
export function useTrackEvent(opts?: TrackEventOptions) {
|
|
65
|
-
const { client, subdomain: ctxSubdomain } = useForge();
|
|
65
|
+
const { client, apiUrl, subdomain: ctxSubdomain } = useForge();
|
|
66
66
|
const subdomain = opts?.subdomain ?? ctxSubdomain;
|
|
67
67
|
const storeSession = opts?.storeSession ?? true;
|
|
68
68
|
|
|
69
|
-
const
|
|
70
|
-
(
|
|
71
|
-
if (typeof window === "undefined" || !client || !subdomain) return;
|
|
69
|
+
const buildPayload = useCallback(
|
|
70
|
+
(eventData: Record<string, unknown>) => {
|
|
72
71
|
// Cookieless mode omits the sessionId entirely — the backend derives a
|
|
73
72
|
// rotating per-day visitor hash so nothing touches the device.
|
|
74
|
-
const sessionId = storeSession ? getAnalyticsSessionId(subdomain) : undefined;
|
|
73
|
+
const sessionId = storeSession && subdomain ? getAnalyticsSessionId(subdomain) : undefined;
|
|
75
74
|
const payload: Record<string, unknown> = { ...eventData };
|
|
76
75
|
if (sessionId) payload.sessionId = sessionId;
|
|
76
|
+
return payload;
|
|
77
|
+
},
|
|
78
|
+
[subdomain, storeSession],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const track = useCallback(
|
|
82
|
+
(eventType: string, eventData: Record<string, unknown> = {}) => {
|
|
83
|
+
if (typeof window === "undefined" || !client || !subdomain) return;
|
|
77
84
|
// Fire-and-forget: analytics must never block the UI or surface errors.
|
|
78
85
|
client
|
|
79
|
-
.post("/public/websites/track-event", { subdomain, eventType, eventData:
|
|
86
|
+
.post("/public/websites/track-event", { subdomain, eventType, eventData: buildPayload(eventData) })
|
|
80
87
|
.catch(() => {});
|
|
81
88
|
},
|
|
82
|
-
[client, subdomain,
|
|
89
|
+
[client, subdomain, buildPayload],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Same event, but it survives the page going away.
|
|
94
|
+
*
|
|
95
|
+
* `track` above posts through the Axios instance, and Axios in the browser is
|
|
96
|
+
* XHR. XHR has no `keepalive` option at all, so when a click navigates the tab
|
|
97
|
+
* somewhere else the request is cancelled mid-flight and the event is simply
|
|
98
|
+
* lost. That is tolerable for a click that stays on the site and fatal for one
|
|
99
|
+
* that does not: on a music smart link EVERY conversion is an outbound
|
|
100
|
+
* navigation to Spotify, so the ordinary transport would drop a large share of
|
|
101
|
+
* the only events the page exists to record, and take the server-side Meta
|
|
102
|
+
* `Lead` down with them.
|
|
103
|
+
*
|
|
104
|
+
* `fetch` with `keepalive: true` is the fix. Deliberately not
|
|
105
|
+
* `navigator.sendBeacon`, which cannot set `Content-Type: application/json`
|
|
106
|
+
* without wrapping the body in a Blob, and this endpoint is JSON-only.
|
|
107
|
+
*
|
|
108
|
+
* Use it for any event immediately followed by a navigation. Ordinary
|
|
109
|
+
* in-page events should keep using `track`, which goes through the configured
|
|
110
|
+
* client and picks up its interceptors.
|
|
111
|
+
*/
|
|
112
|
+
const trackBeacon = useCallback(
|
|
113
|
+
(eventType: string, eventData: Record<string, unknown> = {}) => {
|
|
114
|
+
if (typeof window === "undefined" || !apiUrl || !subdomain) return;
|
|
115
|
+
try {
|
|
116
|
+
void fetch(`${apiUrl.replace(/\/$/, "")}/public/websites/track-event`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
keepalive: true,
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
subdomain,
|
|
122
|
+
eventType,
|
|
123
|
+
eventData: buildPayload(eventData),
|
|
124
|
+
// The referrer header only carries the origin cross-origin, so the
|
|
125
|
+
// server cannot work out which page this happened on by itself.
|
|
126
|
+
eventSourceUrl: window.location.href,
|
|
127
|
+
}),
|
|
128
|
+
}).catch(() => {});
|
|
129
|
+
} catch {
|
|
130
|
+
// Best-effort by definition; never disrupt the navigation.
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[apiUrl, subdomain, buildPayload],
|
|
83
134
|
);
|
|
84
135
|
|
|
85
|
-
return { track };
|
|
136
|
+
return { track, trackBeacon };
|
|
86
137
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { IMusicLink } from "../../types/models";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One music smart link by slug.
|
|
7
|
+
*
|
|
8
|
+
* Pass `initialData` from the route loader (`fetchMusicLinkServer`) so the page
|
|
9
|
+
* renders complete on the server with no client spinner. That is not just a
|
|
10
|
+
* polish detail here: these links are opened almost entirely from social apps'
|
|
11
|
+
* in-app browsers on a phone, on the worst connection the fan will use all day,
|
|
12
|
+
* and a page that shows a spinner before its buttons loses the tap.
|
|
13
|
+
*
|
|
14
|
+
* `undefined` while loading, `null` when the link does not exist or is archived.
|
|
15
|
+
*/
|
|
16
|
+
export function useMusicLink(slug?: string, options?: { initialData?: IMusicLink | null }) {
|
|
17
|
+
const { client, profileId } = useForge();
|
|
18
|
+
|
|
19
|
+
return useQuery<IMusicLink | null>({
|
|
20
|
+
queryKey: ["music-link", profileId, slug],
|
|
21
|
+
queryFn: async () => {
|
|
22
|
+
const res = await client.get(`/public/music-links`, { params: { profileId, slug } });
|
|
23
|
+
return res.data ?? null;
|
|
24
|
+
},
|
|
25
|
+
enabled: !!profileId && !!client && !!slug,
|
|
26
|
+
...(options?.initialData !== undefined ? { initialData: options.initialData } : {}),
|
|
27
|
+
});
|
|
28
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export * from "./data/queries/useBlog";
|
|
|
77
77
|
export * from "./data/queries/usePodcast";
|
|
78
78
|
export * from "./data/queries/useCollections";
|
|
79
79
|
export * from "./data/queries/useEvents";
|
|
80
|
+
export * from "./data/queries/useMusicLink";
|
|
80
81
|
export * from "./data/queries/useEventSeries";
|
|
81
82
|
export * from "./data/queries/useInvoice";
|
|
82
83
|
export * from "./data/queries/usePaymentLink";
|
package/src/server/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure };
|
|
|
34
34
|
import type {
|
|
35
35
|
IEvent,
|
|
36
36
|
IEventSeries,
|
|
37
|
+
IMusicLink,
|
|
37
38
|
IPublicProduct,
|
|
38
39
|
PublicCourse,
|
|
39
40
|
CoachingProduct,
|
|
@@ -54,6 +55,9 @@ import {
|
|
|
54
55
|
type ReviewSchemaReview,
|
|
55
56
|
type SiteSeoContext,
|
|
56
57
|
} from "../utils/structuredData";
|
|
58
|
+
// Also React-free, so it belongs in this entry rather than being re-derived by
|
|
59
|
+
// each route that needs share tags.
|
|
60
|
+
import { buildHeadMeta } from "../utils/headMeta";
|
|
57
61
|
export type { SiteSeoContext };
|
|
58
62
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
59
63
|
|
|
@@ -293,6 +297,53 @@ export function fetchEventSeriesServer(opts: {
|
|
|
293
297
|
});
|
|
294
298
|
}
|
|
295
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Fetch a music smart link by slug for SSR.
|
|
302
|
+
*
|
|
303
|
+
* `null` means render the not-found state: the slug does not exist for this
|
|
304
|
+
* profile, or the link is archived. Archiving is how a creator takes a link
|
|
305
|
+
* down, so a 404 is the correct answer rather than a stale page.
|
|
306
|
+
*/
|
|
307
|
+
export function fetchMusicLinkServer(opts: {
|
|
308
|
+
apiUrl: string;
|
|
309
|
+
profileId?: string;
|
|
310
|
+
slug: string;
|
|
311
|
+
}): Promise<IMusicLink | null> {
|
|
312
|
+
return getJson<IMusicLink>(opts.apiUrl, `/public/music-links`, {
|
|
313
|
+
profileId: opts.profileId,
|
|
314
|
+
slug: opts.slug,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Head tags for a music smart link.
|
|
320
|
+
*
|
|
321
|
+
* The share card matters more here than on any other page in the product. A
|
|
322
|
+
* music link exists to be pasted into an Instagram story, a WhatsApp message or
|
|
323
|
+
* a tweet, so for most of its audience the OG image and title ARE the page: they
|
|
324
|
+
* decide whether anyone taps through at all. `og:type` is `music.song` (or
|
|
325
|
+
* `music.album`) rather than the default `website`, which is what lets a
|
|
326
|
+
* platform render it as a music card instead of a generic link.
|
|
327
|
+
*/
|
|
328
|
+
export function buildMusicLinkHead(
|
|
329
|
+
link: Pick<IMusicLink, "title" | "artistName" | "description" | "artworkUrl"> | null,
|
|
330
|
+
opts?: { canonicalUrl?: string; kind?: "song" | "album" },
|
|
331
|
+
) {
|
|
332
|
+
if (!link) {
|
|
333
|
+
return buildHeadMeta({ title: "Link not found", noindex: true });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const title = link.artistName ? `${link.artistName} - ${link.title}` : link.title;
|
|
337
|
+
|
|
338
|
+
return buildHeadMeta({
|
|
339
|
+
title,
|
|
340
|
+
description: link.description || `Listen to ${title} on your favourite streaming service.`,
|
|
341
|
+
image: link.artworkUrl || undefined,
|
|
342
|
+
canonicalUrl: opts?.canonicalUrl,
|
|
343
|
+
ogType: opts?.kind === "album" ? "music.album" : "music.song",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
296
347
|
/** Fetch a single product by id or slug for SSR. */
|
|
297
348
|
export function fetchProductServer(opts: {
|
|
298
349
|
apiUrl: string;
|
package/src/types/models.ts
CHANGED
|
@@ -1875,3 +1875,45 @@ export type PaymentLinkData = {
|
|
|
1875
1875
|
profileName: string;
|
|
1876
1876
|
profileSubdomain: string;
|
|
1877
1877
|
};
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* A music smart link, as served publicly at `/l/<slug>` on a creator's site.
|
|
1881
|
+
*
|
|
1882
|
+
* This is the PUBLIC shape, which is narrower than the stored row: the backend
|
|
1883
|
+
* builds it field by field, drops disabled destinations, sorts the rest by the
|
|
1884
|
+
* order the creator arranged, and resolves `metaPixelId` from the route's
|
|
1885
|
+
* tracking config (falling back to the profile pixel). No CAPI token is ever
|
|
1886
|
+
* part of it.
|
|
1887
|
+
*/
|
|
1888
|
+
export interface IMusicLinkDestination {
|
|
1889
|
+
/** A key from the platform allowlist, e.g. `spotify`, `appleMusic`. */
|
|
1890
|
+
platform: string;
|
|
1891
|
+
url: string;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** Per-link presentation, layered over the site theme by `MusicLinkPage`. */
|
|
1895
|
+
export interface IMusicLinkTheme {
|
|
1896
|
+
mode?: "light" | "dark";
|
|
1897
|
+
background?: string;
|
|
1898
|
+
text?: string;
|
|
1899
|
+
accent?: string;
|
|
1900
|
+
surface?: string;
|
|
1901
|
+
border?: string;
|
|
1902
|
+
/** Blurred cover behind the page. Defaults to on. */
|
|
1903
|
+
artworkBackdrop?: boolean;
|
|
1904
|
+
buttonShape?: "rounded" | "pill" | "square";
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
export interface IMusicLink {
|
|
1908
|
+
id: string;
|
|
1909
|
+
slug: string;
|
|
1910
|
+
title: string;
|
|
1911
|
+
artistName: string | null;
|
|
1912
|
+
description: string | null;
|
|
1913
|
+
artworkUrl: string | null;
|
|
1914
|
+
releaseDate: string | null;
|
|
1915
|
+
theme: IMusicLinkTheme | null;
|
|
1916
|
+
destinations: IMusicLinkDestination[];
|
|
1917
|
+
/** Public pixel id for this route, or null when neither it nor the profile has one. */
|
|
1918
|
+
metaPixelId: string | null;
|
|
1919
|
+
}
|
|
@@ -188,6 +188,17 @@ export function ForgeAnalytics({
|
|
|
188
188
|
const el = start.closest("a,button,[data-track]") as HTMLElement | null;
|
|
189
189
|
if (!el) return;
|
|
190
190
|
|
|
191
|
+
// Opt-out for elements that report their own click.
|
|
192
|
+
//
|
|
193
|
+
// This listener cannot know that a component already sent a richer event
|
|
194
|
+
// for the same click, so without a way to stand down, anything that tracks
|
|
195
|
+
// its own clicks is counted twice. Worse than the double count: this
|
|
196
|
+
// listener's payload carries no `eventId`, so the resulting CAPI `Lead`
|
|
197
|
+
// has nothing to dedupe against, and Meta sees two conversions for one
|
|
198
|
+
// action. Any element that fires its own event must carry
|
|
199
|
+
// `data-track-skip`.
|
|
200
|
+
if (el.closest("[data-track-skip]")) return;
|
|
201
|
+
|
|
191
202
|
const anchor = el.closest("a") as HTMLAnchorElement | null;
|
|
192
203
|
const text = (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 120) || undefined;
|
|
193
204
|
track("click", {
|
|
@@ -197,6 +208,10 @@ export function ForgeAnalytics({
|
|
|
197
208
|
text,
|
|
198
209
|
href: anchor?.href || undefined,
|
|
199
210
|
id: el.id || el.getAttribute("data-track") || undefined,
|
|
211
|
+
// Page views have carried an id since this shipped; clicks did not, so a
|
|
212
|
+
// click's server-side CAPI twin had no `event_id` and could never be
|
|
213
|
+
// deduplicated against a browser pixel event of the same name.
|
|
214
|
+
eventId: newEventId(),
|
|
200
215
|
});
|
|
201
216
|
};
|
|
202
217
|
|
package/src/ui/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
|
|
|
64
64
|
export { EmailListForm, type EmailListFormProps } from "./styled/EmailListForm";
|
|
65
65
|
export { BlogComments, type BlogCommentsProps } from "./styled/BlogComments";
|
|
66
66
|
export { PageActions, type PageActionsProps } from "./styled/PageActions";
|
|
67
|
+
export { MusicLinkPage, type MusicLinkPageProps } from "./styled/MusicLinkPage";
|
|
67
68
|
export { BlogPostFooter, type BlogPostFooterProps } from "./styled/BlogPostFooter";
|
|
68
69
|
export { BotProtection, EMPTY_BOT_FIELDS, type BotFields } from "./styled/BotProtection";
|
|
69
70
|
export { ContactForm, type ContactFormProps } from "./styled/ContactForm";
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import type { IMusicLink } from "../../types/models";
|
|
4
|
+
import { useMusicLink } from "../../data/queries/useMusicLink";
|
|
5
|
+
import { newEventId, useTrackEvent } from "../../data/queries/useAnalytics";
|
|
6
|
+
import { usePageMetaPixel } from "../analytics/PageMetaPixel";
|
|
7
|
+
import { PageMetaPixel } from "../analytics/PageMetaPixel";
|
|
8
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
9
|
+
import { PageActions } from "./PageActions";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How each streaming service is presented.
|
|
13
|
+
*
|
|
14
|
+
* `color` is the service's own brand colour, used for the badge, and `label` is
|
|
15
|
+
* the name a fan recognises rather than the API key.
|
|
16
|
+
*
|
|
17
|
+
* There is deliberately no logo artwork here. Reproducing a dozen trademarked
|
|
18
|
+
* marks from memory gets some of them subtly wrong, and every one of these
|
|
19
|
+
* services publishes brand guidelines with rules about colour, clear space and
|
|
20
|
+
* permitted alterations. A wrong Spotify logo on an artist's own domain is the
|
|
21
|
+
* artist's problem, not ours to create. Drop real SVGs in behind `icon` when
|
|
22
|
+
* the assets have been taken from each service's brand kit; the layout already
|
|
23
|
+
* reserves the space.
|
|
24
|
+
*/
|
|
25
|
+
const SERVICES: Record<string, { label: string; color: string; action?: string }> = {
|
|
26
|
+
spotify: { label: "Spotify", color: "#1DB954", action: "Play" },
|
|
27
|
+
appleMusic: { label: "Apple Music", color: "#FA243C", action: "Play" },
|
|
28
|
+
itunes: { label: "iTunes", color: "#FA243C", action: "Buy" },
|
|
29
|
+
youtube: { label: "YouTube", color: "#FF0000", action: "Watch" },
|
|
30
|
+
youtubeMusic: { label: "YouTube Music", color: "#FF0000", action: "Play" },
|
|
31
|
+
deezer: { label: "Deezer", color: "#A238FF", action: "Play" },
|
|
32
|
+
tidal: { label: "Tidal", color: "#000000", action: "Play" },
|
|
33
|
+
amazonMusic: { label: "Amazon Music", color: "#25D1DA", action: "Play" },
|
|
34
|
+
soundcloud: { label: "SoundCloud", color: "#FF5500", action: "Play" },
|
|
35
|
+
bandcamp: { label: "Bandcamp", color: "#629AA9", action: "Buy" },
|
|
36
|
+
audiomack: { label: "Audiomack", color: "#FFA200", action: "Play" },
|
|
37
|
+
boomplay: { label: "Boomplay", color: "#E62E2D", action: "Play" },
|
|
38
|
+
anghami: { label: "Anghami", color: "#8A2BE2", action: "Play" },
|
|
39
|
+
pandora: { label: "Pandora", color: "#3668FF", action: "Play" },
|
|
40
|
+
napster: { label: "Napster", color: "#00B9F1", action: "Play" },
|
|
41
|
+
audius: { label: "Audius", color: "#CC0FE0", action: "Play" },
|
|
42
|
+
tiktok: { label: "TikTok", color: "#000000", action: "Open" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
|
|
46
|
+
|
|
47
|
+
export interface MusicLinkPageProps {
|
|
48
|
+
/** The slug from the route. Ignored when `link` is passed directly. */
|
|
49
|
+
slug?: string;
|
|
50
|
+
/** Server-fetched link from the route loader. Renders with no client fetch. */
|
|
51
|
+
initialLink?: IMusicLink | null;
|
|
52
|
+
/** Heading above the service list. */
|
|
53
|
+
listHeading?: string;
|
|
54
|
+
/** Shown when the slug resolves to nothing. */
|
|
55
|
+
notFoundMessage?: string;
|
|
56
|
+
className?: string;
|
|
57
|
+
style?: CSSProperties;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A music smart link page: artwork, title, and one row per streaming service.
|
|
62
|
+
*
|
|
63
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
64
|
+
* WHY THIS RENDERS AS A FIXED FULL-VIEWPORT LAYER
|
|
65
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
66
|
+
* A landing page has to own the screen, and on a deployed site there is no other
|
|
67
|
+
* way to get it. The starter's `__root.tsx` wraps every route in `<Nav/>`, a
|
|
68
|
+
* `max-width: 1040px` `<main>`, and `<SiteFooter/>`; it is tenant-owned, it is
|
|
69
|
+
* never overwritten by an update, and a route cannot escape the root route in
|
|
70
|
+
* TanStack. So a page that merely styles itself well still renders as a column
|
|
71
|
+
* in the middle of the artist's website, under their menu.
|
|
72
|
+
*
|
|
73
|
+
* Covering the viewport from inside the block is the only mechanism that works
|
|
74
|
+
* on sites that already exist, which is all of them. It is the default rather
|
|
75
|
+
* than an option for the same reason.
|
|
76
|
+
*
|
|
77
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
78
|
+
* WHY EACH SERVICE IS A REAL ANCHOR
|
|
79
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
80
|
+
* `<a href>`, not a button with a JS redirect. Fans long-press to copy, open in
|
|
81
|
+
* a new tab, and share these links onward, and a click handler pretending to be
|
|
82
|
+
* a link breaks all three. The tracking hangs off the click; it does not own the
|
|
83
|
+
* navigation, and if tracking fails the fan still reaches Spotify.
|
|
84
|
+
*/
|
|
85
|
+
export function MusicLinkPage({
|
|
86
|
+
slug,
|
|
87
|
+
initialLink,
|
|
88
|
+
listHeading = "Listen on",
|
|
89
|
+
notFoundMessage = "This link is no longer available.",
|
|
90
|
+
className,
|
|
91
|
+
style,
|
|
92
|
+
}: MusicLinkPageProps) {
|
|
93
|
+
const siteTokens = useThemeTokens();
|
|
94
|
+
const { data, isLoading } = useMusicLink(slug, { initialData: initialLink });
|
|
95
|
+
const link = data ?? initialLink ?? null;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Site theme, then the link's `mode` preset, then its explicit colours.
|
|
99
|
+
*
|
|
100
|
+
* Three layers rather than one because the three answer different questions.
|
|
101
|
+
* The site theme is what every link should look like by default, and a creator
|
|
102
|
+
* who restyles their site should not then restyle twenty links. `mode` is how
|
|
103
|
+
* people actually talk about a release page ("make this one dark"), and it has
|
|
104
|
+
* to move background, text, surface and border together or the result is
|
|
105
|
+
* unreadable. Explicit colours are the escape from both.
|
|
106
|
+
*/
|
|
107
|
+
const t = useMemo(() => {
|
|
108
|
+
const theme = link?.theme;
|
|
109
|
+
if (!theme) return siteTokens;
|
|
110
|
+
|
|
111
|
+
const preset =
|
|
112
|
+
theme.mode === "dark"
|
|
113
|
+
? { background: "#0b0b0d", text: "#ffffff", surface: "#17171b", border: "#2a2a30", muted: "#a1a1aa" }
|
|
114
|
+
: theme.mode === "light"
|
|
115
|
+
? { background: "#ffffff", text: "#111113", surface: "#f5f5f7", border: "#e4e4e7", muted: "#71717a" }
|
|
116
|
+
: {};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
...siteTokens,
|
|
120
|
+
...preset,
|
|
121
|
+
...(theme.background ? { background: theme.background } : {}),
|
|
122
|
+
...(theme.text ? { text: theme.text } : {}),
|
|
123
|
+
...(theme.surface ? { surface: theme.surface } : {}),
|
|
124
|
+
...(theme.border ? { border: theme.border } : {}),
|
|
125
|
+
...(theme.accent ? { primary: theme.accent } : {}),
|
|
126
|
+
cornerRadius: theme.buttonShape === "pill" ? 999 : theme.buttonShape === "square" ? 0 : siteTokens.cornerRadius,
|
|
127
|
+
};
|
|
128
|
+
}, [siteTokens, link?.theme]);
|
|
129
|
+
|
|
130
|
+
const pixelId = link?.metaPixelId ?? "";
|
|
131
|
+
const { fire } = usePageMetaPixel(pixelId);
|
|
132
|
+
const { trackBeacon } = useTrackEvent();
|
|
133
|
+
|
|
134
|
+
// Lock the page behind the overlay. Without this the tenant's own page keeps
|
|
135
|
+
// scrolling underneath on iOS, which reads as a rendering bug.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (typeof document === "undefined") return;
|
|
138
|
+
const previous = document.body.style.overflow;
|
|
139
|
+
document.body.style.overflow = "hidden";
|
|
140
|
+
return () => {
|
|
141
|
+
document.body.style.overflow = previous;
|
|
142
|
+
};
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const destinations = useMemo(() => link?.destinations ?? [], [link]);
|
|
146
|
+
|
|
147
|
+
const onDestinationClick = (platform: string, url: string) => {
|
|
148
|
+
// ONE id, shared by the browser pixel event and the server-side CAPI twin
|
|
149
|
+
// that `maybeSendPageCapi` emits for this click. Meta deduplicates on
|
|
150
|
+
// (event_id, event_name), so without a shared id the same tap is counted as
|
|
151
|
+
// two conversions and the campaign optimises against inflated numbers.
|
|
152
|
+
const eventId = newEventId();
|
|
153
|
+
|
|
154
|
+
fire("Lead", { content_name: serviceMeta(platform).label, content_category: "music_link" });
|
|
155
|
+
|
|
156
|
+
trackBeacon("click", {
|
|
157
|
+
pathname: typeof window !== "undefined" ? window.location.pathname : undefined,
|
|
158
|
+
destination: platform,
|
|
159
|
+
slug: link?.slug,
|
|
160
|
+
href: url,
|
|
161
|
+
eventId,
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (!link) {
|
|
166
|
+
return (
|
|
167
|
+
<Overlay t={t} className={className} style={style}>
|
|
168
|
+
<p style={{ color: t.muted, fontFamily: t.fontFamily }}>{isLoading ? "" : notFoundMessage}</p>
|
|
169
|
+
</Overlay>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Overlay
|
|
175
|
+
t={t}
|
|
176
|
+
className={className}
|
|
177
|
+
style={style}
|
|
178
|
+
artworkUrl={link.theme?.artworkBackdrop === false ? null : link.artworkUrl}
|
|
179
|
+
>
|
|
180
|
+
{pixelId ? <PageMetaPixel pixelId={pixelId} /> : null}
|
|
181
|
+
|
|
182
|
+
<div style={{ width: "100%", maxWidth: 420, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
|
183
|
+
{link.artworkUrl ? (
|
|
184
|
+
<img
|
|
185
|
+
src={link.artworkUrl}
|
|
186
|
+
alt={link.title}
|
|
187
|
+
width={260}
|
|
188
|
+
height={260}
|
|
189
|
+
style={{
|
|
190
|
+
width: 260,
|
|
191
|
+
height: 260,
|
|
192
|
+
maxWidth: "70vw",
|
|
193
|
+
maxHeight: "70vw",
|
|
194
|
+
objectFit: "cover",
|
|
195
|
+
borderRadius: t.cornerRadius,
|
|
196
|
+
boxShadow: "0 18px 50px rgba(0,0,0,0.45)",
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
) : null}
|
|
200
|
+
|
|
201
|
+
<h1
|
|
202
|
+
style={{
|
|
203
|
+
margin: "24px 0 4px",
|
|
204
|
+
fontSize: 24,
|
|
205
|
+
lineHeight: 1.25,
|
|
206
|
+
textAlign: "center",
|
|
207
|
+
color: t.text,
|
|
208
|
+
fontFamily: t.headingFontFamily || t.fontFamily,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{link.title}
|
|
212
|
+
</h1>
|
|
213
|
+
|
|
214
|
+
{link.artistName ? (
|
|
215
|
+
<p style={{ margin: 0, fontSize: 16, color: t.muted, fontFamily: t.fontFamily }}>{link.artistName}</p>
|
|
216
|
+
) : null}
|
|
217
|
+
|
|
218
|
+
{link.description ? (
|
|
219
|
+
<p
|
|
220
|
+
style={{
|
|
221
|
+
margin: "12px 0 0",
|
|
222
|
+
fontSize: 14,
|
|
223
|
+
textAlign: "center",
|
|
224
|
+
color: t.muted,
|
|
225
|
+
fontFamily: t.fontFamily,
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{link.description}
|
|
229
|
+
</p>
|
|
230
|
+
) : null}
|
|
231
|
+
|
|
232
|
+
{destinations.length > 0 ? (
|
|
233
|
+
<p
|
|
234
|
+
style={{
|
|
235
|
+
margin: "28px 0 12px",
|
|
236
|
+
fontSize: 12,
|
|
237
|
+
letterSpacing: "0.08em",
|
|
238
|
+
textTransform: "uppercase",
|
|
239
|
+
color: t.muted,
|
|
240
|
+
fontFamily: t.fontFamily,
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
{listHeading}
|
|
244
|
+
</p>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 10 }}>
|
|
248
|
+
{destinations.map((destination) => {
|
|
249
|
+
const meta = serviceMeta(destination.platform);
|
|
250
|
+
return (
|
|
251
|
+
<a
|
|
252
|
+
key={destination.platform}
|
|
253
|
+
href={destination.url}
|
|
254
|
+
target="_blank"
|
|
255
|
+
rel="noopener noreferrer"
|
|
256
|
+
// Stands the global click listener down. It would otherwise
|
|
257
|
+
// record a second, poorer event for this same tap, and its
|
|
258
|
+
// payload would drive a duplicate CAPI Lead.
|
|
259
|
+
data-track-skip=""
|
|
260
|
+
data-track={`music-link-${destination.platform}`}
|
|
261
|
+
onClick={() => onDestinationClick(destination.platform, destination.url)}
|
|
262
|
+
style={{
|
|
263
|
+
display: "flex",
|
|
264
|
+
alignItems: "center",
|
|
265
|
+
gap: 12,
|
|
266
|
+
padding: "12px 14px",
|
|
267
|
+
borderRadius: t.cornerRadius,
|
|
268
|
+
background: t.surface,
|
|
269
|
+
border: `1px solid ${t.border}`,
|
|
270
|
+
color: t.text,
|
|
271
|
+
textDecoration: "none",
|
|
272
|
+
fontFamily: t.fontFamily,
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<span
|
|
276
|
+
aria-hidden="true"
|
|
277
|
+
style={{
|
|
278
|
+
width: 34,
|
|
279
|
+
height: 34,
|
|
280
|
+
flex: "0 0 34px",
|
|
281
|
+
borderRadius: 999,
|
|
282
|
+
background: meta.color,
|
|
283
|
+
color: "#ffffff",
|
|
284
|
+
display: "flex",
|
|
285
|
+
alignItems: "center",
|
|
286
|
+
justifyContent: "center",
|
|
287
|
+
fontSize: 15,
|
|
288
|
+
fontWeight: 700,
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{meta.label.charAt(0)}
|
|
292
|
+
</span>
|
|
293
|
+
<span style={{ flex: 1, fontSize: 15, fontWeight: 600 }}>{meta.label}</span>
|
|
294
|
+
<span style={{ fontSize: 13, fontWeight: 700, color: t.primary }}>{meta.action}</span>
|
|
295
|
+
</a>
|
|
296
|
+
);
|
|
297
|
+
})}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/*
|
|
301
|
+
The creator's own additions: a newsletter form, a vinyl pre-order, a
|
|
302
|
+
lead magnet, a donation. This route is a platform-owned chassis file
|
|
303
|
+
that is re-materialized from the starter on every build, so anything
|
|
304
|
+
added to it IN CODE would be overwritten on the next deploy. Page
|
|
305
|
+
actions are data instead, resolved per link with a per-page-type
|
|
306
|
+
default, so the shell stays ours and the contents stay theirs.
|
|
307
|
+
*/}
|
|
308
|
+
<div style={{ width: "100%", marginTop: 28 }}>
|
|
309
|
+
<PageActions pageType="music_link" entityId={link.id} />
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</Overlay>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function Overlay({
|
|
317
|
+
t,
|
|
318
|
+
artworkUrl,
|
|
319
|
+
className,
|
|
320
|
+
style,
|
|
321
|
+
children,
|
|
322
|
+
}: {
|
|
323
|
+
t: ReturnType<typeof useThemeTokens>;
|
|
324
|
+
artworkUrl?: string | null;
|
|
325
|
+
className?: string;
|
|
326
|
+
style?: CSSProperties;
|
|
327
|
+
children: React.ReactNode;
|
|
328
|
+
}) {
|
|
329
|
+
return (
|
|
330
|
+
<div
|
|
331
|
+
className={className}
|
|
332
|
+
style={{
|
|
333
|
+
position: "fixed",
|
|
334
|
+
inset: 0,
|
|
335
|
+
zIndex: 60,
|
|
336
|
+
overflowY: "auto",
|
|
337
|
+
background: t.background,
|
|
338
|
+
display: "flex",
|
|
339
|
+
flexDirection: "column",
|
|
340
|
+
alignItems: "center",
|
|
341
|
+
justifyContent: "flex-start",
|
|
342
|
+
padding: "48px 20px 64px",
|
|
343
|
+
...style,
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
346
|
+
{artworkUrl ? (
|
|
347
|
+
<div
|
|
348
|
+
aria-hidden="true"
|
|
349
|
+
style={{
|
|
350
|
+
position: "absolute",
|
|
351
|
+
inset: 0,
|
|
352
|
+
backgroundImage: `url(${artworkUrl})`,
|
|
353
|
+
backgroundSize: "cover",
|
|
354
|
+
backgroundPosition: "center",
|
|
355
|
+
filter: "blur(48px) saturate(1.4)",
|
|
356
|
+
transform: "scale(1.2)",
|
|
357
|
+
opacity: 0.35,
|
|
358
|
+
pointerEvents: "none",
|
|
359
|
+
}}
|
|
360
|
+
/>
|
|
361
|
+
) : null}
|
|
362
|
+
<div style={{ position: "relative", width: "100%", display: "flex", justifyContent: "center" }}>{children}</div>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|