@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/react": "^19.1.2",
|
|
42
|
+
"simple-icons": "^16.28.0",
|
|
42
43
|
"typescript": "~5.8.3"
|
|
43
44
|
},
|
|
44
45
|
"license": "ISC"
|
|
@@ -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,52 @@ 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
|
+
/**
|
|
1897
|
+
* Which of the five layouts to render. Composition, not colour: the tokens
|
|
1898
|
+
* below tune whichever one is chosen.
|
|
1899
|
+
*/
|
|
1900
|
+
style?: "classic" | "immersive" | "split" | "minimal" | "compact";
|
|
1901
|
+
mode?: "light" | "dark";
|
|
1902
|
+
background?: string;
|
|
1903
|
+
text?: string;
|
|
1904
|
+
accent?: string;
|
|
1905
|
+
surface?: string;
|
|
1906
|
+
border?: string;
|
|
1907
|
+
/** Blurred cover behind the page. Defaults to on. */
|
|
1908
|
+
artworkBackdrop?: boolean;
|
|
1909
|
+
buttonShape?: "rounded" | "pill" | "square";
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
export interface IMusicLink {
|
|
1913
|
+
id: string;
|
|
1914
|
+
slug: string;
|
|
1915
|
+
title: string;
|
|
1916
|
+
artistName: string | null;
|
|
1917
|
+
description: string | null;
|
|
1918
|
+
artworkUrl: string | null;
|
|
1919
|
+
/** 30-second preview, played over the artwork. Apple's, which does not expire. */
|
|
1920
|
+
previewUrl: string | null;
|
|
1921
|
+
releaseDate: string | null;
|
|
1922
|
+
theme: IMusicLinkTheme | null;
|
|
1923
|
+
destinations: IMusicLinkDestination[];
|
|
1924
|
+
/** Public pixel id for this route, or null when neither it nor the profile has one. */
|
|
1925
|
+
metaPixelId: string | null;
|
|
1926
|
+
}
|
|
@@ -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";
|