@tribe-nest/forge 3.61.0 → 3.63.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.
@@ -6,6 +6,7 @@ import { setMetaPolicy, trackMeta } from "../../utils/metaEvents";
6
6
  import { setCurrentPageEventId, isProfilePixelSuppressed } from "../../utils/analyticsBus";
7
7
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
8
8
  import { createEngagementTracker, scrollDepthOf, type EngagementTracker } from "../../utils/engagementTracker";
9
+ import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
9
10
 
10
11
  export interface ForgeAnalyticsProps {
11
12
  /**
@@ -60,6 +61,61 @@ function currentPage(): string {
60
61
  return window.location.pathname + window.location.search;
61
62
  }
62
63
 
64
+ /**
65
+ * Web Vitals (LCP, INP, CLS), for the SEO tab's page speed report.
66
+ *
67
+ * `web-vitals` callbacks cannot be unregistered, so they are registered once
68
+ * per page load, the first time first-party analytics is allowed, and report
69
+ * through whichever `<ForgeAnalytics />` is mounted now (`vitalsSink`). With
70
+ * none mounted, or analytics switched off, a report is dropped. Observers are
71
+ * buffered, so a visitor who consents after the page painted still reports its
72
+ * LCP.
73
+ *
74
+ * Each metric instance is sent once (its first report): a page view, or a
75
+ * restore from the back/forward cache, which `web-vitals` treats as a new one.
76
+ * The path is the page the metric belongs to: the URL `web-vitals` names for
77
+ * it, else the page the document loaded on, since an SPA navigation does not
78
+ * start a new measurement.
79
+ */
80
+ type VitalReport = { name: Metric["name"]; value: number; rating: Metric["rating"]; path: string };
81
+ let vitalsSink: ((report: VitalReport) => void) | null = null;
82
+ let vitalsRegistered = false;
83
+ const vitalsSent = new Set<string>();
84
+
85
+ function pathOfMetric(metric: Metric, loadPath: string): string {
86
+ try {
87
+ if (metric.navigationURL) return new URL(metric.navigationURL, window.location.href).pathname;
88
+ } catch {
89
+ // fall through to the page the document loaded on
90
+ }
91
+ return loadPath;
92
+ }
93
+
94
+ function registerWebVitals(): void {
95
+ if (vitalsRegistered || typeof window === "undefined") return;
96
+ vitalsRegistered = true;
97
+ const loadPath = currentPathname();
98
+ const report = (metric: Metric) => {
99
+ if (vitalsSent.has(metric.id) || !vitalsSink) return;
100
+ vitalsSent.add(metric.id);
101
+ vitalsSink({ name: metric.name, value: metric.value, rating: metric.rating, path: pathOfMetric(metric, loadPath) });
102
+ };
103
+ try {
104
+ onLCP(report);
105
+ onINP(report);
106
+ onCLS(report);
107
+ } catch {
108
+ // An unsupported browser measures nothing; never break the page for it.
109
+ }
110
+ }
111
+
112
+ /** For tests: forget the registration and what was sent, as a fresh page load would. */
113
+ export function __resetWebVitalsForTests(): void {
114
+ vitalsSink = null;
115
+ vitalsRegistered = false;
116
+ vitalsSent.clear();
117
+ }
118
+
63
119
  /** Engaged means the visitor can see the page and it has focus. */
64
120
  function isEngaged(): boolean {
65
121
  return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
@@ -74,6 +130,8 @@ function isEngaged(): boolean {
74
130
  * - Auto click tracking on links/buttons (opt out with `trackClicks={false}`).
75
131
  * - Time on page and scroll depth, as `engagement` events (engaged time only:
76
132
  * visible and focused).
133
+ * - Page speed: LCP, INP and CLS (Web Vitals), as one `web_vital` event per
134
+ * metric per page view, for the SEO tab.
77
135
  * - Optional Meta Pixel: pass `metaPixelId` to init the pixel and mirror each
78
136
  * page view as a Meta `PageView` (event-id matched for CAPI de-dup).
79
137
  *
@@ -263,6 +321,18 @@ export function ForgeAnalytics({
263
321
  };
264
322
  }, [firstPartyOn]);
265
323
 
324
+ // Web Vitals: first-party only, like the page views they belong to. Sent by
325
+ // beacon, because INP and CLS are reported as the page is hidden.
326
+ useEffect(() => {
327
+ if (!firstPartyOn || typeof window === "undefined") return;
328
+ const sink = (report: VitalReport) => beaconRef.current("web_vital", report);
329
+ vitalsSink = sink;
330
+ registerWebVitals();
331
+ return () => {
332
+ if (vitalsSink === sink) vitalsSink = null;
333
+ };
334
+ }, [firstPartyOn]);
335
+
266
336
  // Click tracking: first-party only, one delegated listener for the document.
267
337
  useEffect(() => {
268
338
  if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, act, cleanup } from "@testing-library/react";
4
+
5
+ /**
6
+ * Web Vitals reporting (SEO step 4). `web-vitals` is mocked: each `onX`
7
+ * registration is captured so a test can fire the metric the browser would.
8
+ * What is asserted: one `web_vital` event per metric instance, by beacon, with
9
+ * `{ name, value, rating, path }`; nothing at all without first-party consent;
10
+ * the cookieless mode still reporting; and registration happening once per
11
+ * page load however often the component mounts.
12
+ */
13
+
14
+ type Metric = {
15
+ name: "LCP" | "INP" | "CLS";
16
+ value: number;
17
+ rating: "good" | "needs-improvement" | "poor";
18
+ id: string;
19
+ navigationURL?: string;
20
+ };
21
+ const handlers: Record<string, Array<(m: Metric) => void>> = { LCP: [], INP: [], CLS: [] };
22
+ vi.mock("web-vitals", () => ({
23
+ onLCP: (cb: (m: Metric) => void) => handlers.LCP.push(cb),
24
+ onINP: (cb: (m: Metric) => void) => handlers.INP.push(cb),
25
+ onCLS: (cb: (m: Metric) => void) => handlers.CLS.push(cb),
26
+ }));
27
+
28
+ const calls: { via: "track" | "beacon"; eventType: string; data: Record<string, unknown> }[] = [];
29
+ vi.mock("../../../data/queries/useAnalytics", () => ({
30
+ newEventId: () => "evt",
31
+ useTrackEvent: () => ({
32
+ track: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "track", eventType, data }),
33
+ trackBeacon: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "beacon", eventType, data }),
34
+ }),
35
+ }));
36
+ vi.mock("../../../data/queries/useWebsite", () => ({ useSiteConfig: () => ({ data: undefined }) }));
37
+ const consent = { performance: true, marketing: false };
38
+ vi.mock("../../headless/consent/useCookieConsent", () => ({ useCookieConsent: () => consent }));
39
+
40
+ import { ForgeAnalytics, __resetWebVitalsForTests } from "../ForgeAnalytics";
41
+
42
+ const fire = (metric: Metric) => {
43
+ for (const cb of handlers[metric.name]) cb(metric);
44
+ };
45
+ const vitals = () => calls.filter((c) => c.eventType === "web_vital").map((c) => [c.via, c.data]);
46
+
47
+ describe("<ForgeAnalytics /> web vitals", () => {
48
+ beforeEach(() => {
49
+ calls.length = 0;
50
+ for (const key of Object.keys(handlers)) handlers[key].length = 0;
51
+ consent.performance = true;
52
+ __resetWebVitalsForTests();
53
+ window.history.replaceState({}, "", "/classes?ref=x");
54
+ });
55
+
56
+ afterEach(() => {
57
+ cleanup();
58
+ });
59
+
60
+ it("sends LCP, INP and CLS once each, by beacon, with the page the document loaded on", () => {
61
+ render(<ForgeAnalytics trackClicks={false} />);
62
+ expect(handlers.LCP).toHaveLength(1);
63
+ expect(handlers.INP).toHaveLength(1);
64
+ expect(handlers.CLS).toHaveLength(1);
65
+
66
+ // An SPA navigation does not start a new measurement.
67
+ act(() => window.history.pushState({}, "", "/about"));
68
+ fire({ name: "LCP", value: 1820.4, rating: "good", id: "v5-lcp-1" });
69
+ fire({ name: "CLS", value: 0.04, rating: "good", id: "v5-cls-1" });
70
+ fire({ name: "INP", value: 260, rating: "needs-improvement", id: "v5-inp-1" });
71
+ // A later report of the same instance (CLS grows) is not sent again.
72
+ fire({ name: "CLS", value: 0.3, rating: "poor", id: "v5-cls-1" });
73
+
74
+ expect(vitals()).toEqual([
75
+ ["beacon", { name: "LCP", value: 1820.4, rating: "good", path: "/classes" }],
76
+ ["beacon", { name: "CLS", value: 0.04, rating: "good", path: "/classes" }],
77
+ ["beacon", { name: "INP", value: 260, rating: "needs-improvement", path: "/classes" }],
78
+ ]);
79
+ });
80
+
81
+ it("sends a new instance (a back/forward cache restore) with the URL web-vitals names", () => {
82
+ render(<ForgeAnalytics trackClicks={false} />);
83
+ fire({ name: "LCP", value: 900, rating: "good", id: "a" });
84
+ fire({ name: "LCP", value: 300, rating: "good", id: "b", navigationURL: "https://site.test/events/x?y=1" });
85
+ expect(vitals().map(([, data]) => (data as { path: string }).path)).toEqual(["/classes", "/events/x"]);
86
+ });
87
+
88
+ it("measures nothing without performance consent, and starts once it is given", () => {
89
+ consent.performance = false;
90
+ const { rerender } = render(<ForgeAnalytics trackClicks={false} />);
91
+ expect(handlers.LCP).toHaveLength(0);
92
+ fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
93
+ expect(vitals()).toEqual([]);
94
+
95
+ consent.performance = true;
96
+ rerender(<ForgeAnalytics trackClicks={false} />);
97
+ expect(handlers.LCP).toHaveLength(1);
98
+ // The observers are buffered, so the LCP that painted before consent still reports.
99
+ fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
100
+ expect(vitals()).toEqual([["beacon", { name: "LCP", value: 1000, rating: "good", path: "/classes" }]]);
101
+ });
102
+
103
+ it("reports in cookieless mode without performance consent", () => {
104
+ consent.performance = false;
105
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
106
+ fire({ name: "INP", value: 120, rating: "good", id: "i1" });
107
+ expect(vitals()).toEqual([["beacon", { name: "INP", value: 120, rating: "good", path: "/classes" }]]);
108
+ });
109
+
110
+ it("records nothing when disabled, and nothing after the component unmounts", () => {
111
+ const { unmount } = render(<ForgeAnalytics enabled={false} />);
112
+ expect(handlers.LCP).toHaveLength(0);
113
+ unmount();
114
+
115
+ const mounted = render(<ForgeAnalytics trackClicks={false} />);
116
+ mounted.unmount();
117
+ fire({ name: "LCP", value: 1000, rating: "good", id: "late" });
118
+ expect(vitals()).toEqual([]);
119
+ });
120
+
121
+ it("registers once per page load however often it mounts", () => {
122
+ render(<ForgeAnalytics trackClicks={false} />);
123
+ cleanup();
124
+ render(<ForgeAnalytics trackClicks={false} />);
125
+ expect(handlers.LCP).toHaveLength(1);
126
+ fire({ name: "LCP", value: 700, rating: "good", id: "once" });
127
+ expect(vitals()).toHaveLength(1);
128
+ });
129
+ });
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { readLanding } from "../landing";
4
+
5
+ /**
6
+ * SEO step 6: checkout credits a sale to the page the buyer first landed on.
7
+ * `readLanding` is spread into every Forge checkout body, so `landingPath` riding
8
+ * on it is what puts the pathname on the purchase row.
9
+ */
10
+ function store(snapshot: Record<string, unknown>) {
11
+ window.localStorage.setItem("tn:landing", JSON.stringify(snapshot));
12
+ }
13
+
14
+ describe("readLanding", () => {
15
+ beforeEach(() => window.localStorage.clear());
16
+
17
+ it("returns the pathname of the first landing URL, without its query", () => {
18
+ store({
19
+ landingUtm: { utm_source: "tribenest" },
20
+ referrer: "https://instagram.com/",
21
+ landingUrl: "https://clayroom.example/i/blog/first-class?utm_source=tribenest#top",
22
+ at: Date.now(),
23
+ });
24
+ expect(readLanding()).toEqual({
25
+ landingUtm: { utm_source: "tribenest" },
26
+ referrer: "https://instagram.com/",
27
+ landingPath: "/i/blog/first-class",
28
+ });
29
+ });
30
+
31
+ it("leaves landingPath undefined when the stored URL cannot be parsed", () => {
32
+ store({ landingUtm: {}, referrer: "", landingUrl: "not a url", at: Date.now() });
33
+ expect(readLanding()).toEqual({ landingUtm: undefined, referrer: undefined, landingPath: undefined });
34
+ });
35
+
36
+ it("returns null when nothing is stored", () => {
37
+ expect(readLanding()).toBeNull();
38
+ });
39
+ });
@@ -0,0 +1,247 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ buildBlogPostingSchema,
4
+ buildBreadcrumbSchema,
5
+ buildEventSchema,
6
+ buildIdentitySchema,
7
+ buildPodcastEpisodeSchema,
8
+ buildPodcastSeriesSchema,
9
+ buildWebSiteSchema,
10
+ type EventSchemaSource,
11
+ } from "../structuredData";
12
+
13
+ afterEach(() => vi.useRealTimers());
14
+
15
+ const URL_ = "https://claystudio.com/i/events/late-show";
16
+
17
+ const EVENT: EventSchemaSource = {
18
+ title: "Late Show",
19
+ description: "<p>An evening of <b>clay</b>.</p>",
20
+ dateTime: "2026-11-01T19:00:00.000Z",
21
+ endDateTime: "2026-11-01T22:00:00.000Z",
22
+ doorsOpenAt: "2026-11-01T18:30:00.000Z",
23
+ status: "on_sale",
24
+ cancelledAt: null,
25
+ type: "physical",
26
+ address: { name: "The Hall", street: "1 Mare St", city: "London", country: "GB", zipCode: "E8 1AA" },
27
+ media: [{ url: "https://cdn.example/poster.jpg", type: "image" }],
28
+ tickets: [
29
+ { title: "General", price: 15, quantity: 100, sold: 10 },
30
+ { title: "VIP", price: 40, quantity: 10, sold: 10 },
31
+ { title: "Hidden", price: 1, quantity: 10, sold: 0, isHidden: true },
32
+ { title: "Members", price: 5, quantity: 10, sold: 0, membershipGate: { allowed: false } as never },
33
+ ],
34
+ bookingFee: { currency: "GBP" },
35
+ };
36
+
37
+ describe("buildEventSchema", () => {
38
+ it("describes a physical event with a Place, status, mode and one offer per public tier", () => {
39
+ const s = buildEventSchema(EVENT, { url: URL_ })!;
40
+ expect(s).toMatchObject({
41
+ "@context": "https://schema.org",
42
+ "@type": "Event",
43
+ name: "Late Show",
44
+ startDate: EVENT.dateTime,
45
+ endDate: EVENT.endDateTime,
46
+ doorTime: EVENT.doorsOpenAt,
47
+ eventStatus: "https://schema.org/EventScheduled",
48
+ eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
49
+ description: "An evening of clay .",
50
+ image: "https://cdn.example/poster.jpg",
51
+ url: URL_,
52
+ location: {
53
+ "@type": "Place",
54
+ name: "The Hall",
55
+ address: {
56
+ "@type": "PostalAddress",
57
+ streetAddress: "1 Mare St",
58
+ addressLocality: "London",
59
+ postalCode: "E8 1AA",
60
+ addressCountry: "GB",
61
+ },
62
+ },
63
+ });
64
+ expect(s.offers).toEqual([
65
+ { "@type": "Offer", name: "General", price: 15, priceCurrency: "GBP", availability: "https://schema.org/InStock", url: URL_ },
66
+ { "@type": "Offer", name: "VIP", price: 40, priceCurrency: "GBP", availability: "https://schema.org/SoldOut", url: URL_ },
67
+ ]);
68
+ });
69
+
70
+ it("gives a virtual event a VirtualLocation and a hybrid event both", () => {
71
+ const v = buildEventSchema({ ...EVENT, type: "virtual" }, { url: URL_ })!;
72
+ expect(v.eventAttendanceMode).toBe("https://schema.org/OnlineEventAttendanceMode");
73
+ expect(v.location).toEqual({ "@type": "VirtualLocation", url: URL_ });
74
+ const h = buildEventSchema({ ...EVENT, type: "hybrid" }, { url: URL_ })!;
75
+ expect(h.eventAttendanceMode).toBe("https://schema.org/MixedEventAttendanceMode");
76
+ expect((h.location as unknown[]).map((l) => (l as { "@type": string })["@type"])).toEqual(["Place", "VirtualLocation"]);
77
+ });
78
+
79
+ it("marks a cancelled event and sells nothing", () => {
80
+ const s = buildEventSchema({ ...EVENT, status: "cancelled" })!;
81
+ expect(s.eventStatus).toBe("https://schema.org/EventCancelled");
82
+ expect(s).not.toHaveProperty("offers");
83
+ });
84
+
85
+ it("emits no offers without a currency, and none for a members-only event", () => {
86
+ expect(buildEventSchema({ ...EVENT, bookingFee: null })).not.toHaveProperty("offers");
87
+ expect(buildEventSchema({ ...EVENT, bookingFee: null }, { currency: "USD" })!.offers).toBeDefined();
88
+ expect(buildEventSchema({ ...EVENT, membershipGate: { allowed: false } })).not.toHaveProperty("offers");
89
+ });
90
+
91
+ it("says PreOrder for a tier that is not on sale yet", () => {
92
+ vi.useFakeTimers();
93
+ vi.setSystemTime(new Date("2026-10-01T00:00:00.000Z"));
94
+ const s = buildEventSchema({
95
+ ...EVENT,
96
+ tickets: [{ title: "Early", price: 10, quantity: 5, sold: 0, availableFrom: "2026-10-10T00:00:00.000Z" }],
97
+ })!;
98
+ expect(s.offers).toMatchObject({ availability: "https://schema.org/PreOrder", validFrom: "2026-10-10T00:00:00.000Z" });
99
+ });
100
+
101
+ it("names the organizer from the identity", () => {
102
+ const s = buildEventSchema(EVENT, { organizer: { kind: "person", name: "Ada" }, origin: "https://claystudio.com" })!;
103
+ expect(s.organizer).toEqual({ "@type": "Person", name: "Ada", url: "https://claystudio.com" });
104
+ });
105
+
106
+ it("returns null with no title or no start", () => {
107
+ expect(buildEventSchema({ ...EVENT, title: " " })).toBeNull();
108
+ expect(buildEventSchema({ ...EVENT, dateTime: "" })).toBeNull();
109
+ expect(buildEventSchema(null)).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("buildBlogPostingSchema", () => {
114
+ it("builds a BlogPosting with author and publisher from an organization identity", () => {
115
+ const s = buildBlogPostingSchema(
116
+ {
117
+ title: "Glazes",
118
+ content: "<p>How we glaze.</p>",
119
+ publishedAt: "2026-09-01T00:00:00.000Z",
120
+ media: [{ url: "https://cdn.example/g.jpg", type: "image/jpeg" }, { url: "https://cdn.example/a.mp3", type: "audio/mpeg" }],
121
+ },
122
+ {
123
+ url: "https://claystudio.com/i/blog/glazes",
124
+ identity: { kind: "organization", name: "Clay Studio", logo: "https://cdn.example/logo.png" },
125
+ origin: "https://claystudio.com",
126
+ },
127
+ )!;
128
+ const org = { "@type": "Organization", name: "Clay Studio", url: "https://claystudio.com", logo: "https://cdn.example/logo.png" };
129
+ expect(s).toEqual({
130
+ "@context": "https://schema.org",
131
+ "@type": "BlogPosting",
132
+ headline: "Glazes",
133
+ description: "How we glaze.",
134
+ datePublished: "2026-09-01T00:00:00.000Z",
135
+ dateModified: "2026-09-01T00:00:00.000Z",
136
+ image: "https://cdn.example/g.jpg",
137
+ url: "https://claystudio.com/i/blog/glazes",
138
+ mainEntityOfPage: { "@type": "WebPage", "@id": "https://claystudio.com/i/blog/glazes" },
139
+ author: org,
140
+ publisher: org,
141
+ });
142
+ });
143
+
144
+ it("uses a person as author only, and clamps a long headline", () => {
145
+ const s = buildBlogPostingSchema({ title: "x".repeat(200) }, { identity: { kind: "person", name: "Ada" } })!;
146
+ expect((s.headline as string).length).toBe(110);
147
+ expect(s.author).toEqual({ "@type": "Person", name: "Ada" });
148
+ expect(s).not.toHaveProperty("publisher");
149
+ });
150
+ });
151
+
152
+ describe("podcast schemas", () => {
153
+ it("builds a PodcastSeries, preferring the show's own author", () => {
154
+ expect(
155
+ buildPodcastSeriesSchema(
156
+ { title: "Kiln Talk", description: "Weekly.", coverImage: { url: "https://cdn.example/c.jpg" }, author: "Ada" },
157
+ { url: "https://claystudio.com/i/podcasts/kiln-talk" },
158
+ ),
159
+ ).toEqual({
160
+ "@context": "https://schema.org",
161
+ "@type": "PodcastSeries",
162
+ name: "Kiln Talk",
163
+ description: "Weekly.",
164
+ image: "https://cdn.example/c.jpg",
165
+ url: "https://claystudio.com/i/podcasts/kiln-talk",
166
+ author: { "@type": "Person", name: "Ada" },
167
+ });
168
+ });
169
+
170
+ it("builds a PodcastEpisode with its audio, duration and series", () => {
171
+ expect(
172
+ buildPodcastEpisodeSchema(
173
+ {
174
+ title: "Ep 1",
175
+ content: "<p>Hello</p>",
176
+ publishedAt: "2026-09-02T00:00:00.000Z",
177
+ media: [{ url: "https://cdn.example/1.mp3", type: "audio/mpeg", durationSec: 3725 }],
178
+ },
179
+ { url: "https://claystudio.com/i/podcasts/kiln-talk/ep-1", series: { name: "Kiln Talk", url: "https://claystudio.com/i/podcasts/kiln-talk" } },
180
+ ),
181
+ ).toEqual({
182
+ "@context": "https://schema.org",
183
+ "@type": "PodcastEpisode",
184
+ name: "Ep 1",
185
+ description: "Hello",
186
+ datePublished: "2026-09-02T00:00:00.000Z",
187
+ url: "https://claystudio.com/i/podcasts/kiln-talk/ep-1",
188
+ timeRequired: "PT1H2M",
189
+ associatedMedia: { "@type": "MediaObject", contentUrl: "https://cdn.example/1.mp3", encodingFormat: "audio/mpeg", duration: "PT1H2M" },
190
+ partOfSeries: { "@type": "PodcastSeries", name: "Kiln Talk", url: "https://claystudio.com/i/podcasts/kiln-talk" },
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("site schemas", () => {
196
+ it("builds Person, Organization and LocalBusiness identities", () => {
197
+ expect(buildIdentitySchema({ kind: "person", name: "Ada", logo: "https://x/a.jpg", sameAs: [] })).toEqual({
198
+ "@context": "https://schema.org",
199
+ "@type": "Person",
200
+ name: "Ada",
201
+ image: "https://x/a.jpg",
202
+ });
203
+ expect(buildIdentitySchema({ kind: "organization", name: "Clay", logo: "https://x/l.png", sameAs: ["https://x.com/clay"] }, { origin: "https://clay.com/" })).toEqual({
204
+ "@context": "https://schema.org",
205
+ "@type": "Organization",
206
+ "@id": "https://clay.com/#identity",
207
+ url: "https://clay.com",
208
+ name: "Clay",
209
+ logo: "https://x/l.png",
210
+ sameAs: ["https://x.com/clay"],
211
+ });
212
+ expect(buildIdentitySchema({ kind: "local_business", name: "Clay", sameAs: [], address: {}, phone: "123" })).toEqual({
213
+ "@context": "https://schema.org",
214
+ "@type": "LocalBusiness",
215
+ name: "Clay",
216
+ telephone: "123",
217
+ });
218
+ expect(buildIdentitySchema({ kind: "person", name: "", sameAs: [] })).toBeNull();
219
+ });
220
+
221
+ it("builds a WebSite linked to its publisher", () => {
222
+ expect(buildWebSiteSchema({ name: "Clay", url: "https://clay.com/", publisherId: "https://clay.com/#identity" })).toEqual({
223
+ "@context": "https://schema.org",
224
+ "@type": "WebSite",
225
+ name: "Clay",
226
+ url: "https://clay.com/",
227
+ publisher: { "@id": "https://clay.com/#identity" },
228
+ });
229
+ });
230
+
231
+ it("builds a BreadcrumbList, and nothing for a single crumb", () => {
232
+ expect(
233
+ buildBreadcrumbSchema([
234
+ { name: "Clay", url: "https://clay.com/" },
235
+ { name: "Store", url: "https://clay.com/i/store" },
236
+ ]),
237
+ ).toEqual({
238
+ "@context": "https://schema.org",
239
+ "@type": "BreadcrumbList",
240
+ itemListElement: [
241
+ { "@type": "ListItem", position: 1, name: "Clay", item: "https://clay.com/" },
242
+ { "@type": "ListItem", position: 2, name: "Store", item: "https://clay.com/i/store" },
243
+ ],
244
+ });
245
+ expect(buildBreadcrumbSchema([{ name: "Clay", url: "https://clay.com/" }])).toBeNull();
246
+ });
247
+ });
@@ -103,18 +103,31 @@ export function captureLandingFromUrl(): boolean {
103
103
  }
104
104
  }
105
105
 
106
+ /** The pathname of a stored landing URL, or undefined when it cannot be read. */
107
+ function landingPathOf(landingUrl: string | undefined): string | undefined {
108
+ if (!landingUrl) return undefined;
109
+ try {
110
+ const path = new URL(landingUrl).pathname;
111
+ return path.startsWith("/") ? path : undefined;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
106
117
  /**
107
- * Returns the first-touch `landingUtm` bag + `referrer` for the checkout
108
- * payload, or null when nothing is stored / it expired. `landingUtm` is
109
- * undefined when the bag is empty so we don't ship `{}`.
118
+ * Returns the first-touch `landingUtm` bag, `referrer` and `landingPath` (the
119
+ * pathname of the first page, so a sale can be credited to the blog post that
120
+ * brought the buyer) for the checkout payload, or null when nothing is stored /
121
+ * it expired. `landingUtm` is undefined when the bag is empty so we don't ship `{}`.
110
122
  */
111
- export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string } | null {
123
+ export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string; landingPath?: string } | null {
112
124
  const snapshot = readSnapshot();
113
125
  if (!snapshot) return null;
114
126
  const hasParams = Object.keys(snapshot.landingUtm).length > 0;
115
127
  return {
116
128
  landingUtm: hasParams ? snapshot.landingUtm : undefined,
117
129
  referrer: snapshot.referrer || undefined,
130
+ landingPath: landingPathOf(snapshot.landingUrl),
118
131
  };
119
132
  }
120
133