fragment-headless-sdk 2.3.1 → 2.3.2

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.
@@ -1,7 +1,6 @@
1
1
  import React from "react";
2
2
  import { IAnnouncementContent } from "../../types";
3
- export default function AnnouncementButton({ content, buttonHref, clickHref, }: {
3
+ export default function AnnouncementButton({ content, buttonHref, }: {
4
4
  content: IAnnouncementContent;
5
5
  buttonHref?: string;
6
- clickHref?: string;
7
6
  }): React.JSX.Element | null;
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { ButtonType, SectionType } from "../../constants";
3
3
  import { fireClickMetric, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveAnnouncementColors, } from "../../utils";
4
- export default function AnnouncementButton({ content, buttonHref, clickHref, }) {
4
+ export default function AnnouncementButton({ content, buttonHref, }) {
5
5
  // Don’t render if no button or explicitly None
6
6
  if (!content?.buttonText || content.buttonType === ButtonType.None)
7
7
  return null;
@@ -29,9 +29,7 @@ export default function AnnouncementButton({ content, buttonHref, clickHref, })
29
29
  delete attributes["aria-label"];
30
30
  }
31
31
  const handleClick = React.useCallback(() => {
32
- if (!clickHref)
33
- return;
34
- fireClickMetric(clickHref, content.measurementId, content.sectionType || SectionType.Announcement, content.sectionId);
35
- }, [clickHref, content.measurementId, content.sectionType, content.sectionId]);
32
+ fireClickMetric(content.measurementId, content.sectionType || SectionType.Announcement, content.sectionId);
33
+ }, [content.measurementId, content.sectionType, content.sectionId]);
36
34
  return (React.createElement("a", { href: buttonHref, className: className, style: style, onClick: handleClick, ...(attributes ?? {}), "aria-label": ariaLabel }, content.buttonText));
37
35
  }
@@ -1,21 +1,19 @@
1
+ "use client";
1
2
  import { XMarkIcon } from "@heroicons/react/24/outline";
2
3
  import React, { useEffect, useRef } from "react";
3
4
  import { AnnouncementType, ButtonType, SectionType } from "../../constants";
4
- import { buildClickUrl, fireImpressionWhenVisible, getThemeClasses, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveAnnouncementColors, } from "../../utils";
5
+ import { fireImpressionWhenVisible, getThemeClasses, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveAnnouncementColors, } from "../../utils";
5
6
  import AnnouncementButton from "./AnnouncementButton";
6
7
  import { AnnouncementStyles } from "./AnnouncementStyles";
7
8
  import CountdownTimer from "./CountdownTimer";
8
9
  export default function Announcement({ content, type, handleClose, }) {
9
10
  const ref = useRef(null);
10
11
  useEffect(() => {
11
- if (ref.current && content.impressionUrl) {
12
- fireImpressionWhenVisible(ref.current, content.impressionUrl, content.measurementId, content.sectionType || SectionType.Announcement, content.sectionId);
12
+ if (ref.current) {
13
+ fireImpressionWhenVisible(ref.current, content.measurementId, content.sectionType || SectionType.Announcement, content.sectionId);
13
14
  }
14
- }, [content?.impressionUrl, content?.measurementId, content?.sectionType, content?.sectionId]);
15
+ }, [content?.measurementId, content?.sectionType, content?.sectionId]);
15
16
  const buttonHref = content?.buttonLink || undefined;
16
- const clickTrackingHref = content?.buttonLink && content?.clickUrlBase
17
- ? buildClickUrl(content.clickUrlBase, content.buttonLink)
18
- : undefined;
19
17
  if (!content)
20
18
  return null;
21
19
  const styling = content.styling;
@@ -60,13 +58,13 @@ export default function Announcement({ content, type, handleClose, }) {
60
58
  React.createElement("div", { className: marqueeContentClass, style: marqueeContentStyle, ...(marqueeContentAttributes ?? {}), dangerouslySetInnerHTML: {
61
59
  __html: content.announcementHtml || "",
62
60
  } }))),
63
- content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref })))) : (React.createElement("div", { className: contentRowClass, style: contentRowStyle, ...(contentRowAttributes ?? {}) },
61
+ content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref })))) : (React.createElement("div", { className: contentRowClass, style: contentRowStyle, ...(contentRowAttributes ?? {}) },
64
62
  React.createElement("div", { className: announcementTextClass, style: announcementTextStyle, ...(announcementTextAttributes ?? {}) },
65
63
  React.createElement("div", { dangerouslySetInnerHTML: {
66
64
  __html: content.announcementHtml || "",
67
65
  } })),
68
66
  type === AnnouncementType.Countdown && (React.createElement(CountdownTimer, { content: content })),
69
- content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref })))),
67
+ content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref })))),
70
68
  React.createElement("div", { onClick: handleClose, className: closeButtonClass, style: closeButtonStyle, ...(closeButtonAttributes ?? {}) },
71
69
  React.createElement(XMarkIcon, { className: "w-5 h-5" })))));
72
70
  }
@@ -3,7 +3,6 @@ import { IHeroContent } from "../../types";
3
3
  import { resolveHeroColors, resolveHeroTypography } from "../../utils/hero-resolvers";
4
4
  interface HeroViewProps {
5
5
  buttonHref?: string;
6
- clickHref?: string;
7
6
  content: IHeroContent;
8
7
  colors: ReturnType<typeof resolveHeroColors>;
9
8
  contentWidthClass: string;
@@ -11,5 +10,5 @@ interface HeroViewProps {
11
10
  position: "left" | "center" | "right";
12
11
  height: string;
13
12
  }
14
- export default function DesktopHero({ buttonHref, clickHref, content, colors, contentWidthClass, typography, position, height, }: HeroViewProps): React.JSX.Element;
13
+ export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, height, }: HeroViewProps): React.JSX.Element;
15
14
  export {};
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { SectionType } from "../../constants";
3
3
  import { fireClickMetric } from "../../utils";
4
4
  import { DEFAULT_CONTENT_WIDTH_CLASS, joinClassNames, renderText, } from "../../utils/hero-resolvers";
5
- export default function DesktopHero({ buttonHref, clickHref, content, colors, contentWidthClass, typography, position, height, }) {
5
+ export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, height, }) {
6
6
  const getPositionClasses = () => {
7
7
  switch (position) {
8
8
  case "center":
@@ -15,15 +15,8 @@ export default function DesktopHero({ buttonHref, clickHref, content, colors, co
15
15
  }
16
16
  };
17
17
  const handleClick = React.useCallback(() => {
18
- if (!clickHref)
19
- return;
20
- fireClickMetric(clickHref, content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
21
- }, [
22
- clickHref,
23
- content.measurementId,
24
- content.sectionType,
25
- content.sectionId,
26
- ]);
18
+ fireClickMetric(content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
19
+ }, [content.measurementId, content.sectionType, content.sectionId]);
27
20
  return (React.createElement("div", { className: `relative ${height} gap-4 w-full`, style: { backgroundColor: colors.background } },
28
21
  content?.videoUrl ? (React.createElement("video", { src: content.videoUrl, autoPlay: true, muted: true, loop: true, playsInline: true, className: "absolute inset-0 z-0 object-cover w-full h-full" })) : (
29
22
  /* Image Background */
@@ -3,10 +3,9 @@ import { IHeroContent } from "../../types";
3
3
  import { resolveHeroColors, resolveHeroTypography } from "../../utils/hero-resolvers";
4
4
  interface MobileHeroProps {
5
5
  buttonHref?: string;
6
- clickHref?: string;
7
6
  content: IHeroContent;
8
7
  colors: ReturnType<typeof resolveHeroColors>;
9
8
  typography: ReturnType<typeof resolveHeroTypography>;
10
9
  }
11
- export default function MobileHero({ buttonHref, clickHref, content, colors, typography, }: MobileHeroProps): React.JSX.Element;
10
+ export default function MobileHero({ buttonHref, content, colors, typography, }: MobileHeroProps): React.JSX.Element;
12
11
  export {};
@@ -2,17 +2,10 @@ import React from "react";
2
2
  import { SectionType } from "../../constants";
3
3
  import { fireClickMetric } from "../../utils";
4
4
  import { renderText, } from "../../utils/hero-resolvers";
5
- export default function MobileHero({ buttonHref, clickHref, content, colors, typography, }) {
5
+ export default function MobileHero({ buttonHref, content, colors, typography, }) {
6
6
  const handleClick = React.useCallback(() => {
7
- if (!clickHref)
8
- return;
9
- fireClickMetric(clickHref, content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
10
- }, [
11
- clickHref,
12
- content.measurementId,
13
- content.sectionType,
14
- content.sectionId,
15
- ]);
7
+ fireClickMetric(content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
8
+ }, [content.measurementId, content.sectionType, content.sectionId]);
16
9
  return (React.createElement("div", { className: "relative z-10 mx-auto gap-4 flex max-w-screen-md flex-col items-center justify-center py-6 text-center", style: { backgroundColor: colors.background } },
17
10
  renderText({
18
11
  fontSize: typography.title.fontSize,
@@ -1,20 +1,18 @@
1
+ "use client";
1
2
  import React, { useEffect, useRef } from "react";
2
3
  import { SectionType } from "../../constants";
3
- import { buildClickUrl, fireImpressionWhenVisible, } from "../../utils";
4
+ import { fireImpressionWhenVisible, } from "../../utils";
4
5
  import { resolveContentWidthClass, resolveHeight, resolveHeroColors, resolveHeroTypography, resolvePosition, } from "../../utils/hero-resolvers";
5
6
  import DesktopHero from "./DesktopHero";
6
7
  import MobileHero from "./MobileHero";
7
8
  export default function Hero({ content }) {
8
9
  const ref = useRef(null);
9
10
  useEffect(() => {
10
- if (ref.current && content.impressionUrl) {
11
- fireImpressionWhenVisible(ref.current, content.impressionUrl, content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
11
+ if (ref.current) {
12
+ fireImpressionWhenVisible(ref.current, content.measurementId, content.sectionType || SectionType.HeroBanner, content.sectionId);
12
13
  }
13
- }, [content?.impressionUrl, content?.measurementId, content?.sectionType, content?.sectionId]);
14
+ }, [content?.measurementId, content?.sectionType, content?.sectionId]);
14
15
  const buttonHref = content?.buttonLink || undefined;
15
- const clickTrackingHref = content?.buttonLink && content?.clickUrlBase
16
- ? buildClickUrl(content.clickUrlBase, content.buttonLink)
17
- : undefined;
18
16
  if (!content)
19
17
  return null;
20
18
  const colors = resolveHeroColors(content);
@@ -24,7 +22,7 @@ export default function Hero({ content }) {
24
22
  const height = resolveHeight(content);
25
23
  return (React.createElement("div", { className: "bg-black", ref: ref, style: { backgroundColor: colors.background } },
26
24
  React.createElement("div", { className: "hidden lg:block" },
27
- React.createElement(DesktopHero, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref, colors: colors, contentWidthClass: contentWidthClass, typography: typography, position: position, height: height })),
25
+ React.createElement(DesktopHero, { content: content, buttonHref: buttonHref, colors: colors, contentWidthClass: contentWidthClass, typography: typography, position: position, height: height })),
28
26
  React.createElement("div", { className: "block lg:hidden" },
29
- React.createElement(MobileHero, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref, colors: colors, typography: typography }))));
27
+ React.createElement(MobileHero, { content: content, buttonHref: buttonHref, colors: colors, typography: typography }))));
30
28
  }
@@ -10,8 +10,6 @@ export interface IAnnouncementContent {
10
10
  buttonLink: string;
11
11
  announcementHtml: string;
12
12
  counterEndDate?: string;
13
- impressionUrl: string;
14
- clickUrlBase: string;
15
13
  measurementId?: string;
16
14
  sectionId?: string;
17
15
  sectionType?: SectionType;
@@ -13,8 +13,6 @@ export interface IHeroContent {
13
13
  imageUrl: string;
14
14
  mobileImageUrl: string;
15
15
  videoUrl?: string;
16
- impressionUrl: string;
17
- clickUrlBase: string;
18
16
  measurementId?: string;
19
17
  sectionId?: string;
20
18
  sectionType?: SectionType;
@@ -15,12 +15,14 @@ export async function revalidateFragmentCache(tags) {
15
15
  try {
16
16
  // Dynamic import to avoid issues in non-Next.js environments
17
17
  // @ts-ignore - next/cache may not be available in all environments
18
- const { revalidateTag } = await import("next/cache");
18
+ const mod = await import("next/cache");
19
+ // Cast: Next.js types vary by version (1 arg vs 2 arg with profile). Pass tag + "max" for Next 16+.
20
+ const revalidateTag = mod.revalidateTag;
19
21
  if (tags && tags.length > 0) {
20
22
  // Revalidate specific tags
21
23
  tags.forEach((tag) => {
22
24
  try {
23
- revalidateTag(tag);
25
+ revalidateTag(tag, "max");
24
26
  }
25
27
  catch (err) {
26
28
  console.warn(`Failed to revalidate tag ${tag}:`, err);
@@ -31,7 +33,7 @@ export async function revalidateFragmentCache(tags) {
31
33
  // Revalidate all Fragment resource types
32
34
  Object.values(ResourceType).forEach((type) => {
33
35
  try {
34
- revalidateTag(`fragment-${type}`);
36
+ revalidateTag(`fragment-${type}`, "max");
35
37
  }
36
38
  catch (err) {
37
39
  console.warn(`Failed to revalidate fragment-${type}:`, err);
@@ -1,7 +1,5 @@
1
1
  import { SectionType } from "../constants";
2
- export declare function toBase64Url(input: string): string;
3
- export declare function buildClickUrl(clickUrlBase: string, targetHref: string): string;
4
- export declare function fireClickMetric(clickUrl: string, measurementId?: string, sectionType?: SectionType, sectionId?: string): void;
2
+ export declare function fireClickMetric(measurementId?: string, sectionType?: SectionType, sectionId?: string): void;
5
3
  declare global {
6
4
  interface Window {
7
5
  gtag?: (command: string, targetId: string | Date, config?: Record<string, unknown>) => void;
@@ -12,4 +10,4 @@ declare global {
12
10
  * Fire a scroll past metric when user scrolls past a section
13
11
  */
14
12
  export declare function fireScrollPastMetric(measurementId: string | undefined, sectionType: SectionType, sectionId: string): void;
15
- export declare function fireImpressionWhenVisible(el: HTMLElement, pixelUrl: string, measurementId?: string, sectionType?: SectionType, sectionId?: string): void;
13
+ export declare function fireImpressionWhenVisible(el: HTMLElement, measurementId?: string, sectionType?: SectionType, sectionId?: string): void;
@@ -1,59 +1,15 @@
1
1
  import { SectionType } from "../constants";
2
2
  import { setAttribution } from "./attribution";
3
- // --- Unicode-safe Base64URL ---
4
- export function toBase64Url(input) {
5
- // Handles emojis & non-ASCII reliably:
6
- const b64 = btoa(encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))));
7
- return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
- }
9
- // --- Robust query appender ---
10
- function appendQuery(url, key, value) {
11
- const sep = url.includes("?") ? "&" : "?";
12
- return `${url}${sep}${key}=${value}`;
13
- }
14
- // Build the tracking URL that encodes the final destination for metrics
15
- export function buildClickUrl(clickUrlBase, targetHref) {
16
- const u = encodeURIComponent(toBase64Url(targetHref));
17
- return appendQuery(clickUrlBase, "u", u);
18
- }
19
- // Fire the click tracking URL without relying on a redirect
20
- // Default to GET so legacy tracking endpoints continue to accept the request
21
- export function fireClickMetric(clickUrl, measurementId, sectionType, sectionId) {
3
+ export function fireClickMetric(measurementId, sectionType, sectionId) {
22
4
  if (typeof window === "undefined")
23
5
  return;
24
- if (!clickUrl)
25
- return;
26
6
  // Store attribution for potential purchase/add-to-cart
27
7
  if (sectionType && sectionId) {
28
8
  setAttribution(sectionId, sectionType);
29
9
  }
30
- // Send to GA4 first (if available)
31
10
  if (measurementId && sectionType && sectionId) {
32
11
  sendGA4Event("click", measurementId, sectionType, sectionId);
33
12
  }
34
- try {
35
- if (typeof fetch === "function") {
36
- fetch(clickUrl, {
37
- method: "GET",
38
- mode: "no-cors",
39
- keepalive: true,
40
- }).catch(() => {
41
- /* no-op */
42
- });
43
- return;
44
- }
45
- }
46
- catch {
47
- // swallow and fall back to <img>
48
- }
49
- try {
50
- const img = new Image();
51
- img.referrerPolicy = "strict-origin-when-cross-origin";
52
- img.src = clickUrl;
53
- }
54
- catch {
55
- // nothing else we can do
56
- }
57
13
  }
58
14
  /**
59
15
  * Generates a section-specific event name for GA4 tracking.
@@ -102,25 +58,21 @@ export function fireScrollPastMetric(measurementId, sectionType, sectionId) {
102
58
  }
103
59
  // --- View tracking (once per element) ---
104
60
  const seenEls = typeof WeakSet !== "undefined" ? new WeakSet() : null;
105
- export function fireImpressionWhenVisible(el, pixelUrl, measurementId, sectionType, sectionId) {
61
+ export function fireImpressionWhenVisible(el, measurementId, sectionType, sectionId) {
106
62
  if (typeof window === "undefined")
107
63
  return; // SSR guard
108
- if (!el || !pixelUrl)
64
+ if (!el)
109
65
  return;
110
66
  if (seenEls && seenEls.has(el))
111
67
  return; // de-dupe by element
112
68
  let fired = false;
113
69
  let hasScrolledPast = false;
114
- const img = new Image();
115
70
  const fire = () => {
116
71
  if (fired)
117
72
  return;
118
73
  fired = true;
119
74
  if (seenEls)
120
75
  seenEls.add(el);
121
- img.referrerPolicy = "strict-origin-when-cross-origin";
122
- img.src = pixelUrl; // GET to your /api/v1/metrics/i?e=...&sig=...
123
- // Also send to GA4 if available
124
76
  if (measurementId && sectionType && sectionId) {
125
77
  sendGA4Event("view", measurementId, sectionType, sectionId);
126
78
  }
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ### [Unreleased]
9
+
10
+ #### Removed
11
+
12
+ ### [2.3.2] - 2026-02-27
13
+
14
+ #### Removed
15
+
16
+ - **Legacy server-side tracking** – The previous pixel/click tracking system has been fully replaced by Google Analytics 4 (GA4):
17
+ - `impressionUrl` and `clickUrlBase` on content objects are no longer used; the API now provides `measurementId`, `sectionId`, and `sectionType` for GA4 events.
18
+ - The `makeSignedMetricUrls` helper and server-side `/api/v1/t` endpoints (impression/click) have been removed from the Fragment app; the SDK sends view/click/scroll_past via `gtag` only.
19
+ - See [2.2.0] for GA4 integration details. Historical entries [1.0.5] and earlier described the old pixel-based flow.
20
+
8
21
  ### [2.3.1] - 2026-01-25
9
22
 
10
23
  ### 🔧 Technical Improvements
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "fragment-headless-sdk",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
+ "description": "Headless SDK for Fragment Shopify storefronts",
4
5
  "license": "MIT",
5
6
  "main": "dist/index.js",
6
7
  "types": "dist/index.d.ts",
@@ -15,12 +16,34 @@
15
16
  "dist",
16
17
  "docs/CHANGELOG.md"
17
18
  ],
19
+ "sideEffects": false,
18
20
  "scripts": {
19
- "build": "tsc"
21
+ "build": "tsc && mkdir -p dist/styles && cp src/styles/*.css dist/styles/",
22
+ "dev": "tsc --watch",
23
+ "type-check": "tsc --noEmit",
24
+ "clean": "rm -rf dist .turbo"
20
25
  },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/sevenbrand/fragment-monorepo",
33
+ "directory": "packages/sdk"
34
+ },
35
+ "keywords": [
36
+ "shopify",
37
+ "headless",
38
+ "fragment",
39
+ "sdk",
40
+ "storefront"
41
+ ],
21
42
  "dependencies": {
22
- "@heroicons/react": "^2.2.0",
23
- "react": "^19.1.0"
43
+ "@heroicons/react": "^2.2.0"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^18.0.0 || ^19.0.0"
24
47
  },
25
48
  "devDependencies": {
26
49
  "@types/node": "^24.7.2",
package/readme.md CHANGED
@@ -377,8 +377,9 @@ interface IHeroContent {
377
377
  imageUrl: string;
378
378
  mobileImageUrl: string;
379
379
  videoUrl?: string;
380
- impressionUrl: string;
381
- clickUrlBase: string;
380
+ measurementId?: string; // GA4 measurement ID (from app config)
381
+ sectionId?: string; // Section ID for GA4 event params
382
+ sectionType?: SectionType;
382
383
  styling?: IHeroStyling; // v2.0+ enhanced styling
383
384
  }
384
385
  ```
@@ -434,8 +435,9 @@ interface IAnnouncementContent {
434
435
  buttonLink: string;
435
436
  announcementHtml: string;
436
437
  counterEndDate?: string;
437
- impressionUrl: string;
438
- clickUrlBase: string;
438
+ measurementId?: string; // GA4 measurement ID (from app config)
439
+ sectionId?: string; // Section ID for GA4 event params
440
+ sectionType?: SectionType;
439
441
  styling?: IAnnouncementStyling; // v2.0+ enhanced styling
440
442
  }
441
443
  ```