@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.23.0",
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 track = useCallback(
70
- (eventType: string, eventData: Record<string, unknown> = {}) => {
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: payload })
86
+ .post("/public/websites/track-event", { subdomain, eventType, eventData: buildPayload(eventData) })
80
87
  .catch(() => {});
81
88
  },
82
- [client, subdomain, storeSession],
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";
@@ -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;
@@ -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";