fragment-headless-sdk 2.1.1 → 2.1.3

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,6 +1,7 @@
1
1
  import React from "react";
2
2
  import { IAnnouncementContent } from "../../types";
3
- export default function AnnouncementButton({ content, buttonHref, }: {
3
+ export default function AnnouncementButton({ content, buttonHref, clickHref, }: {
4
4
  content: IAnnouncementContent;
5
5
  buttonHref?: string;
6
+ clickHref?: string;
6
7
  }): React.JSX.Element | null;
@@ -1,17 +1,13 @@
1
1
  import React from "react";
2
2
  import { ButtonType } from "../../constants";
3
- import { mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveToken, resolveTokenByCategory, } from "../../utils";
4
- export default function AnnouncementButton({ content, buttonHref, }) {
3
+ import { fireClickMetric, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveToken, resolveTokenByCategory, } from "../../utils";
4
+ export default function AnnouncementButton({ content, buttonHref, clickHref, }) {
5
5
  // Don’t render if no button or explicitly None
6
6
  if (!content?.buttonText || content.buttonType === ButtonType.None)
7
7
  return null;
8
8
  // If we weren’t given a usable href, don’t render a broken link
9
9
  if (!buttonHref)
10
10
  return null;
11
- // Decide if link should open in a new tab.
12
- // If you already have a boolean like `content.buttonLink` meaning "open in new tab",
13
- // keep using it; otherwise you can add one later.
14
- const openInNewTab = Boolean(content.buttonLink);
15
11
  const styling = content.styling;
16
12
  const baseTextColor = resolveTokenByCategory(styling, "colors", "text") ||
17
13
  resolveToken(styling, "textColor");
@@ -37,7 +33,10 @@ export default function AnnouncementButton({ content, buttonHref, }) {
37
33
  if (attributes && "aria-label" in attributes) {
38
34
  delete attributes["aria-label"];
39
35
  }
40
- return (React.createElement("a", { href: buttonHref, className: className, style: style, ...(openInNewTab
41
- ? { target: "_blank", rel: "noopener noreferrer" }
42
- : {}), ...(attributes ?? {}), "aria-label": ariaLabel }, content.buttonText));
36
+ const handleClick = React.useCallback(() => {
37
+ if (!clickHref)
38
+ return;
39
+ fireClickMetric(clickHref);
40
+ }, [clickHref]);
41
+ return (React.createElement("a", { href: buttonHref, className: className, style: style, onClick: handleClick, ...(attributes ?? {}), "aria-label": ariaLabel }, content.buttonText));
43
42
  }
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from "react";
2
2
  import { AnnouncementType, ButtonType } from "../../constants";
3
3
  import { buildClickUrl, fireImpressionWhenVisible, getThemeClasses, mergeSlotAttributes, mergeSlotClasses, mergeSlotStyles, resolveToken, resolveTokenByCategory, } from "../../utils";
4
4
  import AnnouncementButton from "./AnnouncementButton";
5
+ import { AnnouncementStyles } from "./AnnouncementStyles";
5
6
  import CountdownTimer from "./CountdownTimer";
6
7
  export default function Announcement({ content, type, handleClose, }) {
7
8
  const ref = useRef(null);
@@ -10,7 +11,8 @@ export default function Announcement({ content, type, handleClose, }) {
10
11
  fireImpressionWhenVisible(ref.current, content.impressionUrl);
11
12
  }
12
13
  }, [content?.impressionUrl]);
13
- const signedButtonHref = content?.buttonLink && content?.clickUrlBase
14
+ const buttonHref = content?.buttonLink || undefined;
15
+ const clickTrackingHref = content?.buttonLink && content?.clickUrlBase
14
16
  ? buildClickUrl(content.clickUrlBase, content.buttonLink)
15
17
  : undefined;
16
18
  if (!content)
@@ -54,6 +56,7 @@ export default function Announcement({ content, type, handleClose, }) {
54
56
  const closeButtonStyle = mergeSlotStyles({ color: closeButtonColor }, styling, "closeButton");
55
57
  const closeButtonAttributes = mergeSlotAttributes(styling, "closeButton");
56
58
  return (React.createElement("div", { ref: ref, className: rootClass, style: rootStyle, ...(rootAttributes ?? {}) },
59
+ React.createElement(AnnouncementStyles, null),
57
60
  React.createElement("div", { className: innerClass, style: innerStyle, ...(innerAttributes ?? {}) },
58
61
  type === AnnouncementType.Marquee ? (React.createElement("div", { className: marqueeContainerClass, style: marqueeContainerStyle, ...(marqueeContainerAttributes ?? {}) },
59
62
  React.createElement("div", { className: marqueeTextWrapperClass, style: marqueeTextWrapperStyle, ...(marqueeTextWrapperAttributes ?? {}) },
@@ -61,12 +64,12 @@ export default function Announcement({ content, type, handleClose, }) {
61
64
  React.createElement("div", { className: marqueeContentClass, style: marqueeContentStyle, ...(marqueeContentAttributes ?? {}), dangerouslySetInnerHTML: {
62
65
  __html: content.announcementHtml || "",
63
66
  } }))),
64
- content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: signedButtonHref })))) : (React.createElement("div", { className: contentRowClass, style: contentRowStyle, ...(contentRowAttributes ?? {}) },
67
+ content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref })))) : (React.createElement("div", { className: contentRowClass, style: contentRowStyle, ...(contentRowAttributes ?? {}) },
65
68
  React.createElement("div", { className: announcementTextClass, style: announcementTextStyle, ...(announcementTextAttributes ?? {}) },
66
69
  React.createElement("div", { dangerouslySetInnerHTML: {
67
70
  __html: content.announcementHtml || "",
68
71
  } })),
69
72
  type === AnnouncementType.Countdown ? (React.createElement(CountdownTimer, { content: content })) : (content.buttonText &&
70
- content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: signedButtonHref || content.buttonLink || "#" }))))),
73
+ content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref }))))),
71
74
  React.createElement("div", { onClick: handleClose, className: closeButtonClass, style: closeButtonStyle, ...(closeButtonAttributes ?? {}) }, "\u00D7"))));
72
75
  }
@@ -3,6 +3,7 @@ import { IHeroContent } from "../../types";
3
3
  import { resolveHeroColors, resolveHeroTypography } from "../../utils/hero-resolvers";
4
4
  interface HeroViewProps {
5
5
  buttonHref?: string;
6
+ clickHref?: string;
6
7
  content: IHeroContent;
7
8
  colors: ReturnType<typeof resolveHeroColors>;
8
9
  contentWidthClass: string;
@@ -10,5 +11,5 @@ interface HeroViewProps {
10
11
  position: "left" | "center" | "right";
11
12
  height: string;
12
13
  }
13
- export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, height, }: HeroViewProps): React.JSX.Element;
14
+ export default function DesktopHero({ buttonHref, clickHref, content, colors, contentWidthClass, typography, position, height, }: HeroViewProps): React.JSX.Element;
14
15
  export {};
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
+ import { fireClickMetric } from "../../utils";
2
3
  import { DEFAULT_CONTENT_WIDTH_CLASS, joinClassNames, renderText, } from "../../utils/hero-resolvers";
3
- export default function DesktopHero({ buttonHref, content, colors, contentWidthClass, typography, position, height, }) {
4
+ export default function DesktopHero({ buttonHref, clickHref, content, colors, contentWidthClass, typography, position, height, }) {
4
5
  const getPositionClasses = () => {
5
6
  switch (position) {
6
7
  case "center":
@@ -12,6 +13,11 @@ export default function DesktopHero({ buttonHref, content, colors, contentWidthC
12
13
  return "items-start text-left";
13
14
  }
14
15
  };
16
+ const handleClick = React.useCallback(() => {
17
+ if (!clickHref)
18
+ return;
19
+ fireClickMetric(clickHref);
20
+ }, [clickHref]);
15
21
  return (React.createElement("div", { className: `relative ${height} gap-4 w-full`, style: { backgroundColor: colors.background } },
16
22
  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" })) : (
17
23
  /* Image Background */
@@ -22,6 +28,7 @@ export default function DesktopHero({ buttonHref, content, colors, contentWidthC
22
28
  fontSize: typography.title.fontSize,
23
29
  lineHeight: typography.title.lineHeight,
24
30
  text: content?.title,
31
+ className: "mt-4",
25
32
  color: colors.title,
26
33
  font: typography.title.font,
27
34
  }),
@@ -33,7 +40,7 @@ export default function DesktopHero({ buttonHref, content, colors, contentWidthC
33
40
  color: colors.text,
34
41
  font: typography.description.font,
35
42
  }),
36
- content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref, target: "_blank", rel: "noopener noreferrer", className: "no-underline" },
43
+ content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref, onClick: handleClick, className: "no-underline" },
37
44
  React.createElement("div", { className: "mt-6 inline-block rounded-md px-8 py-2 text-2xl font-semibold drop-shadow-lg transition-all duration-200 hover:opacity-90", style: {
38
45
  color: colors.buttonText,
39
46
  backgroundColor: colors.buttonBackground,
@@ -3,9 +3,10 @@ import { IHeroContent } from "../../types";
3
3
  import { resolveHeroColors, resolveHeroTypography } from "../../utils/hero-resolvers";
4
4
  interface MobileHeroProps {
5
5
  buttonHref?: string;
6
+ clickHref?: string;
6
7
  content: IHeroContent;
7
8
  colors: ReturnType<typeof resolveHeroColors>;
8
9
  typography: ReturnType<typeof resolveHeroTypography>;
9
10
  }
10
- export default function MobileHero({ buttonHref, content, colors, typography, }: MobileHeroProps): React.JSX.Element;
11
+ export default function MobileHero({ buttonHref, clickHref, content, colors, typography, }: MobileHeroProps): React.JSX.Element;
11
12
  export {};
@@ -1,22 +1,19 @@
1
1
  import React from "react";
2
- import { ensureSafeColor } from "../../utils/color";
2
+ import { fireClickMetric } from "../../utils";
3
3
  import { renderText, } from "../../utils/hero-resolvers";
4
- export default function MobileHero({ buttonHref, content, colors, typography, }) {
5
- const safeTitleColor = ensureSafeColor(colors.title, "#ffffff");
6
- const safeTextColor = ensureSafeColor(colors.text, "#ffffff");
7
- const titleFontSize = typography.title.fontSize === "text-5xl"
8
- ? "text-3xl"
9
- : typography.title.fontSize;
10
- const descriptionFontSize = typography.description.fontSize === "text-3xl"
11
- ? "text-lg"
12
- : typography.description.fontSize;
4
+ export default function MobileHero({ buttonHref, clickHref, content, colors, typography, }) {
5
+ const handleClick = React.useCallback(() => {
6
+ if (!clickHref)
7
+ return;
8
+ fireClickMetric(clickHref);
9
+ }, [clickHref]);
13
10
  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 } },
14
11
  renderText({
15
- fontSize: titleFontSize,
12
+ fontSize: typography.title.fontSize,
16
13
  lineHeight: typography.title.lineHeight,
17
14
  text: content?.title,
18
- className: "px-4 drop-shadow-xl text-center font-bold",
19
- color: safeTitleColor,
15
+ className: "px-4 drop-shadow-xl text-center",
16
+ color: colors.title,
20
17
  font: typography.title.font,
21
18
  }),
22
19
  content?.videoUrl ? (React.createElement("div", { className: "w-full" },
@@ -24,14 +21,14 @@ export default function MobileHero({ buttonHref, content, colors, typography, })
24
21
  React.createElement("img", { src: content.mobileImageUrl, alt: content.title || "Hero", className: "h-full w-full object-cover" }))) : content?.imageUrl ? (React.createElement("div", { className: "w-full" },
25
22
  React.createElement("img", { src: content.imageUrl, alt: content.title || "Hero", className: "h-full w-full object-cover" }))) : null,
26
23
  renderText({
27
- fontSize: descriptionFontSize,
24
+ fontSize: typography.description.fontSize,
28
25
  lineHeight: typography.description.lineHeight,
29
26
  text: content?.description,
30
27
  className: "px-4 drop-shadow-lg text-center mt-4",
31
- color: safeTextColor,
28
+ color: colors.text,
32
29
  font: typography.description.font,
33
30
  }),
34
- content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref, target: "_blank", rel: "noopener noreferrer", className: "no-underline" },
31
+ content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref, onClick: handleClick, className: "no-underline" },
35
32
  React.createElement("div", { className: "mb-2 rounded-md px-6 py-2 text-lg font-semibold drop-shadow-lg transition-all duration-200 hover:opacity-90", style: {
36
33
  color: colors.buttonText,
37
34
  backgroundColor: colors.buttonBackground,
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useRef } from "react";
2
- import { buildClickUrl, fireImpressionWhenVisible } from "../../utils";
2
+ import { buildClickUrl, fireImpressionWhenVisible, } from "../../utils";
3
3
  import { resolveContentWidthClass, resolveHeight, resolveHeroColors, resolveHeroTypography, resolvePosition, } from "../../utils/hero-resolvers";
4
4
  import DesktopHero from "./DesktopHero";
5
5
  import MobileHero from "./MobileHero";
@@ -10,7 +10,8 @@ export default function Hero({ content }) {
10
10
  fireImpressionWhenVisible(ref.current, content.impressionUrl);
11
11
  }
12
12
  }, [content?.impressionUrl]);
13
- const signedButtonHref = content?.buttonLink && content?.clickUrlBase
13
+ const buttonHref = content?.buttonLink || undefined;
14
+ const clickTrackingHref = content?.buttonLink && content?.clickUrlBase
14
15
  ? buildClickUrl(content.clickUrlBase, content.buttonLink)
15
16
  : undefined;
16
17
  if (!content)
@@ -22,7 +23,7 @@ export default function Hero({ content }) {
22
23
  const height = resolveHeight(content);
23
24
  return (React.createElement("div", { className: "bg-black", ref: ref, style: { backgroundColor: colors.background } },
24
25
  React.createElement("div", { className: "hidden lg:block" },
25
- React.createElement(DesktopHero, { content: content, buttonHref: signedButtonHref, colors: colors, contentWidthClass: contentWidthClass, typography: typography, position: position, height: height })),
26
+ React.createElement(DesktopHero, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref, colors: colors, contentWidthClass: contentWidthClass, typography: typography, position: position, height: height })),
26
27
  React.createElement("div", { className: "block lg:hidden" },
27
- React.createElement(MobileHero, { content: content, buttonHref: signedButtonHref, colors: colors, typography: typography }))));
28
+ React.createElement(MobileHero, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref, colors: colors, typography: typography }))));
28
29
  }
@@ -1,7 +1,4 @@
1
- export declare enum ResourceType {
2
- HeroBanners = "hero-banners",
3
- Announcements = "announcements"
4
- }
1
+ export declare const ENABLE_REQUEST_DEDUPLICATION = true;
5
2
  export type ListParams = {
6
3
  status?: "enabled" | "disabled";
7
4
  page?: number;
@@ -9,10 +6,14 @@ export type ListParams = {
9
6
  search?: string;
10
7
  pageFilter?: string;
11
8
  };
9
+ export declare enum ResourceType {
10
+ HeroBanners = "hero-banners",
11
+ Announcements = "announcements"
12
+ }
12
13
  export type CacheOptions = {
13
- /** Request cache mode (default: 'no-store' for fresh data) */
14
+ /** Request cache mode (default: 'force-cache' with 60s revalidation) */
14
15
  cache?: RequestCache;
15
- /** Next.js revalidation time in seconds (default: 0 for always fresh) */
16
+ /** Next.js revalidation time in seconds (default: 60) */
16
17
  revalidate?: number | false;
17
18
  /** Next.js cache tags for selective invalidation */
18
19
  tags?: string[];
@@ -23,7 +24,7 @@ type FetchResourceParams = {
23
24
  type: ResourceType;
24
25
  params?: ListParams;
25
26
  fetchImpl?: typeof fetch;
26
- /** Cache configuration (defaults to no caching for fresh data) */
27
+ /** Cache configuration (defaults to force-cache with 60s revalidation) */
27
28
  cacheOptions?: CacheOptions;
28
29
  };
29
30
  /** Lists resources with optional filters (parity with client.list) */
@@ -1,8 +1,30 @@
1
+ // Simple anti-spam: prevent identical concurrent requests
2
+ export const ENABLE_REQUEST_DEDUPLICATION = true;
1
3
  export var ResourceType;
2
4
  (function (ResourceType) {
3
5
  ResourceType["HeroBanners"] = "hero-banners";
4
6
  ResourceType["Announcements"] = "announcements";
5
7
  })(ResourceType || (ResourceType = {}));
8
+ // Simple request deduplication to prevent identical concurrent requests
9
+ const pendingRequests = new Map();
10
+ /**
11
+ * Generates a cache key for request deduplication
12
+ * Normalizes params to include default status for consistent key generation
13
+ */
14
+ function generateRequestKey(baseUrl, type, params) {
15
+ // Normalize params with defaults (same as performRequest does)
16
+ const normalizedParams = {
17
+ ...params,
18
+ status: params.status ?? "enabled", // Include default status
19
+ };
20
+ const sortedParams = Object.keys(normalizedParams)
21
+ .sort()
22
+ .reduce((acc, key) => {
23
+ acc[key] = normalizedParams[key];
24
+ return acc;
25
+ }, {});
26
+ return `${baseUrl}:${type}:${JSON.stringify(sortedParams)}`;
27
+ }
6
28
  /**
7
29
  * Detects if running in Next.js environment
8
30
  */
@@ -20,10 +42,10 @@ function getDefaultCacheConfig(type) {
20
42
  const isNextJS = isNextJSEnvironment();
21
43
  const isDev = process?.env?.NODE_ENV === "development";
22
44
  if (isNextJS && !isDev) {
23
- // In Next.js production, default to no caching for fresh data
45
+ // In Next.js production, default to caching with 60 second revalidation
24
46
  return {
25
- cache: "no-store",
26
- revalidate: 0,
47
+ cache: "force-cache",
48
+ revalidate: 60,
27
49
  tags: [`fragment-${type}`],
28
50
  };
29
51
  }
@@ -38,6 +60,50 @@ export async function fetchResource({ baseUrl, apiKey, type, params = {}, fetchI
38
60
  console.warn("❌ Missing EXTERNAL_API_URL or FRAGMENT_API_KEY");
39
61
  return [];
40
62
  }
63
+ // Generate key for request deduplication
64
+ const requestKey = generateRequestKey(baseUrl, type, params);
65
+ // Request deduplication - check if identical request is already in flight
66
+ if (ENABLE_REQUEST_DEDUPLICATION) {
67
+ const existingRequest = pendingRequests.get(requestKey);
68
+ if (existingRequest) {
69
+ console.log(`🔄 Deduplicating request for ${type}`);
70
+ try {
71
+ return await existingRequest;
72
+ }
73
+ catch (err) {
74
+ // If the existing request failed, we'll try again below
75
+ pendingRequests.delete(requestKey);
76
+ }
77
+ }
78
+ }
79
+ // Create the actual request promise
80
+ const requestPromise = performRequest({
81
+ baseUrl,
82
+ apiKey,
83
+ type,
84
+ params,
85
+ fetchImpl,
86
+ cacheOptions,
87
+ });
88
+ // Store the promise for deduplication
89
+ if (ENABLE_REQUEST_DEDUPLICATION) {
90
+ pendingRequests.set(requestKey, requestPromise);
91
+ }
92
+ try {
93
+ const result = await requestPromise;
94
+ return result;
95
+ }
96
+ finally {
97
+ // Clean up the pending request
98
+ if (ENABLE_REQUEST_DEDUPLICATION) {
99
+ pendingRequests.delete(requestKey);
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Performs the actual HTTP request (separated for cleaner code organization)
105
+ */
106
+ async function performRequest({ baseUrl, apiKey, type, params = {}, fetchImpl, cacheOptions, }) {
41
107
  try {
42
108
  const f = fetchImpl ?? fetch;
43
109
  const base = baseUrl.replace(/\/+$/, "");
@@ -47,8 +113,8 @@ export async function fetchResource({ baseUrl, apiKey, type, params = {}, fetchI
47
113
  const limit = Math.min(100, Math.max(1, params.limit ?? 25));
48
114
  url.searchParams.set("pageNum", String(page));
49
115
  url.searchParams.set("limit", String(limit));
50
- if (params.status)
51
- url.searchParams.set("status", params.status);
116
+ // Default to "enabled" status if not specified
117
+ url.searchParams.set("status", params.status ?? "enabled");
52
118
  if (params.pageFilter)
53
119
  url.searchParams.set("page", params.pageFilter);
54
120
  if (params.search)
@@ -64,9 +130,6 @@ export async function fetchResource({ baseUrl, apiKey, type, params = {}, fetchI
64
130
  headers: {
65
131
  Authorization: `Bearer ${apiKey}`,
66
132
  "Content-Type": "application/json",
67
- // Add cache-busting headers for better cache control
68
- "Cache-Control": "no-cache, no-store, must-revalidate",
69
- Pragma: "no-cache",
70
133
  },
71
134
  cache: finalCacheOptions.cache,
72
135
  };
@@ -1,5 +1,4 @@
1
1
  export * from "./cache";
2
- export * from "./color";
3
2
  export * from "./fetch-resource";
4
3
  export * from "./hero-resolvers";
5
4
  export * from "./metrics";
@@ -1,5 +1,4 @@
1
1
  export * from "./cache";
2
- export * from "./color";
3
2
  export * from "./fetch-resource";
4
3
  export * from "./hero-resolvers";
5
4
  export * from "./metrics";
@@ -1,3 +1,4 @@
1
1
  export declare function toBase64Url(input: string): string;
2
2
  export declare function buildClickUrl(clickUrlBase: string, targetHref: string): string;
3
+ export declare function fireClickMetric(clickUrl: string): void;
3
4
  export declare function fireImpressionWhenVisible(el: HTMLElement, pixelUrl: string): void;
@@ -9,11 +9,42 @@ function appendQuery(url, key, value) {
9
9
  const sep = url.includes("?") ? "&" : "?";
10
10
  return `${url}${sep}${key}=${value}`;
11
11
  }
12
- // Build the final redirect URL the CTA should use
12
+ // Build the tracking URL that encodes the final destination for metrics
13
13
  export function buildClickUrl(clickUrlBase, targetHref) {
14
14
  const u = encodeURIComponent(toBase64Url(targetHref));
15
15
  return appendQuery(clickUrlBase, "u", u);
16
16
  }
17
+ // Fire the click tracking URL without relying on a redirect
18
+ // Default to GET so legacy tracking endpoints continue to accept the request
19
+ export function fireClickMetric(clickUrl) {
20
+ if (typeof window === "undefined")
21
+ return;
22
+ if (!clickUrl)
23
+ return;
24
+ try {
25
+ if (typeof fetch === "function") {
26
+ fetch(clickUrl, {
27
+ method: "GET",
28
+ mode: "no-cors",
29
+ keepalive: true,
30
+ }).catch(() => {
31
+ /* no-op */
32
+ });
33
+ return;
34
+ }
35
+ }
36
+ catch {
37
+ // swallow and fall back to <img>
38
+ }
39
+ try {
40
+ const img = new Image();
41
+ img.referrerPolicy = "strict-origin-when-cross-origin";
42
+ img.src = clickUrl;
43
+ }
44
+ catch {
45
+ // nothing else we can do
46
+ }
47
+ }
17
48
  // --- View tracking (once per element) ---
18
49
  const seenEls = typeof WeakSet !== "undefined" ? new WeakSet() : null;
19
50
  export function fireImpressionWhenVisible(el, pixelUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fragment-headless-sdk",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -2,7 +2,27 @@
2
2
 
3
3
  The official SDK for integrating with Fragment-Shopify CMS. Provides React components, TypeScript types, and utilities for rendering published sections in headless Shopify storefronts.
4
4
 
5
- ## ✨ What's New in v2.1.0
5
+ ## ✨ What's New in v2.1.3
6
+
7
+ ⚡ **Request Deduplication** - Intelligent request deduplication prevents identical concurrent API calls
8
+ 🚀 **Enhanced Caching** - Improved caching strategy with 60-second revalidation for better performance
9
+ 🔧 **Optimized Fetching** - Consistent parameter handling and smarter cache key generation
10
+ 🛡️ **Anti-Spam Protection** - Built-in protection against redundant API requests
11
+
12
+ ### Previous Release (v2.1.2)
13
+
14
+ 📚 **Enhanced Documentation** - Comprehensive documentation updates highlighting new click tracking features
15
+ 📖 **Better Examples** - Improved usage examples and feature descriptions
16
+ 🎯 **Feature Highlights** - Clear documentation of the enhanced click tracking system
17
+
18
+ ### Previous Release (v2.1.1)
19
+
20
+ 🎯 **Enhanced Click Tracking System** - Improved click tracking architecture with better user experience
21
+ ⚡ **Direct Navigation** - Users go directly to destinations without redirect delays
22
+ 🔗 **Separated Tracking** - Button destinations and click tracking are now handled separately
23
+ 🛠️ **Better Performance** - Non-blocking click tracking that doesn't delay navigation
24
+
25
+ ### Previous Release (v2.1.0)
6
26
 
7
27
  🎨 **Enhanced Hero Styling System** - New hero resolvers utility with advanced typography, positioning, and layout controls
8
28
  🔤 **Advanced Typography** - Built-in font family support with granular control over sizes and line heights
@@ -41,6 +61,7 @@ Fragment-Shopify App (CMS) → API Endpoint → fragment-headless-sdk (Consumer)
41
61
  - ✅ **TypeScript Support**: Full type definitions for all components and data structures
42
62
  - ✅ **Multiple Announcement Types**: Standard, marquee, and countdown announcement variants
43
63
  - ✅ **Hero Sections**: Desktop/mobile responsive hero components with video support
64
+ - ✅ **Advanced Click Tracking**: Separated tracking and navigation for optimal user experience
44
65
 
45
66
  ### Advanced Styling System (v2.0+)
46
67
 
@@ -62,6 +83,24 @@ Fragment-Shopify App (CMS) → API Endpoint → fragment-headless-sdk (Consumer)
62
83
  - 📝 **Type Safety**: Enhanced TypeScript interfaces for all styling options
63
84
  - 🔄 **Backward Compatible**: Works seamlessly with existing Hero implementations
64
85
 
86
+ ### Request Deduplication & Performance System (v2.1.3+)
87
+
88
+ - ⚡ **Smart Deduplication**: Prevents identical concurrent API requests automatically
89
+ - 🚀 **Optimized Caching**: 60-second cache revalidation for optimal performance vs freshness
90
+ - 🔧 **Consistent Parameters**: Normalized parameter handling ensures reliable cache keys
91
+ - 🛡️ **Anti-Spam Protection**: Built-in protection against redundant API calls
92
+ - 📊 **Performance Monitoring**: Console logging for deduplication events and debugging
93
+ - 🔄 **Graceful Fallback**: Failed requests retry automatically without affecting user experience
94
+
95
+ ### Enhanced Click Tracking System (v2.1.1+)
96
+
97
+ - 🎯 **Separated Concerns**: Button destinations and click tracking handled independently
98
+ - ⚡ **Direct Navigation**: Users go directly to destinations without redirect delays
99
+ - 🔗 **Advanced Tracking**: `fireClickMetric()` function with fetch API and image fallback
100
+ - 🛠️ **Non-Blocking**: Click tracking doesn't delay user navigation
101
+ - 🌐 **Cross-Browser**: Works across all modern browsers with appropriate fallbacks
102
+ - 🔄 **Graceful Degradation**: Tracking fails silently without affecting user experience
103
+
65
104
  ---
66
105
 
67
106
  ## 📦 Installation
@@ -160,11 +199,20 @@ const heroBanners = await fetchResource({
160
199
  type: ResourceType.HeroBanners,
161
200
  });
162
201
 
163
- // Fetch announcements
202
+ // Fetch announcements with optional parameters
164
203
  const announcements = await fetchResource({
165
204
  baseUrl: process.env.EXTERNAL_API_URL,
166
205
  apiKey: process.env.FRAGMENT_API_KEY,
167
206
  type: ResourceType.Announcements,
207
+ params: {
208
+ status: "enabled", // Only fetch enabled announcements
209
+ limit: 10, // Limit to 10 items
210
+ search: "sale", // Search for announcements containing "sale"
211
+ },
212
+ cacheOptions: {
213
+ revalidate: 30, // Custom 30-second cache revalidation
214
+ tags: ["announcements"], // Custom cache tags
215
+ },
168
216
  });
169
217
  ```
170
218
 
@@ -627,13 +675,30 @@ const advancedHeroContent = {
627
675
 
628
676
  ### `fetchResource<T>(params)`
629
677
 
630
- Fetches sections from your Fragment-Shopify app.
678
+ Fetches sections from your Fragment-Shopify app with intelligent request deduplication and caching.
631
679
 
632
680
  **Parameters:**
633
681
 
634
682
  - `baseUrl: string` - URL of your Fragment-Shopify app
635
683
  - `apiKey: string` - Fragment API key (format: `keyId:secret`)
636
684
  - `type: ResourceType` - Type of resource to fetch
685
+ - `params?: ListParams` - Optional filtering and pagination parameters
686
+ - `fetchImpl?: typeof fetch` - Optional custom fetch implementation
687
+ - `cacheOptions?: CacheOptions` - Optional cache configuration
688
+
689
+ **ListParams Options:**
690
+
691
+ - `status?: "enabled" | "disabled"` - Filter by status (defaults to "enabled")
692
+ - `page?: number` - Page number for pagination (defaults to 1)
693
+ - `limit?: number` - Items per page (defaults to 25, max 100)
694
+ - `search?: string` - Search query for filtering
695
+ - `pageFilter?: string` - Filter by page path (e.g., "/collections/sale")
696
+
697
+ **CacheOptions:**
698
+
699
+ - `cache?: RequestCache` - Request cache mode (defaults to "force-cache")
700
+ - `revalidate?: number | false` - Next.js revalidation time in seconds (defaults to 60)
701
+ - `tags?: string[]` - Next.js cache tags for selective invalidation
637
702
 
638
703
  **ResourceType Options:**
639
704
 
@@ -642,6 +707,13 @@ Fetches sections from your Fragment-Shopify app.
642
707
 
643
708
  **Returns:** `Promise<T[]>` - Array of fetched resources
644
709
 
710
+ **Performance Features:**
711
+
712
+ - **Request Deduplication**: Identical concurrent requests are automatically deduplicated
713
+ - **Smart Caching**: 60-second cache revalidation balances performance and freshness
714
+ - **Parameter Normalization**: Consistent cache key generation for reliable deduplication
715
+ - **Error Handling**: Graceful fallbacks with automatic retry on failed requests
716
+
645
717
  ---
646
718
 
647
719
  ## 🧩 Components
@@ -1,2 +0,0 @@
1
- export declare function isDarkColor(color: string | undefined | null): boolean;
2
- export declare function ensureSafeColor(color: string | undefined | null, fallback: string): string;
@@ -1,67 +0,0 @@
1
- const HEX_COLOR_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
2
- const RGB_COLOR_REGEX = /^rgba?\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d*\.?\d+))?\s*\)$/i;
3
- function hexToRgb(color) {
4
- if (!HEX_COLOR_REGEX.test(color)) {
5
- return null;
6
- }
7
- let hex = color.slice(1);
8
- if (hex.length === 3) {
9
- hex = hex
10
- .split("")
11
- .map((char) => char + char)
12
- .join("");
13
- }
14
- const bigint = parseInt(hex, 16);
15
- return {
16
- r: (bigint >> 16) & 255,
17
- g: (bigint >> 8) & 255,
18
- b: bigint & 255,
19
- };
20
- }
21
- function rgbStringToRgb(color) {
22
- const match = color.match(RGB_COLOR_REGEX);
23
- if (!match) {
24
- return null;
25
- }
26
- const [, r, g, b] = match;
27
- const red = Number(r);
28
- const green = Number(g);
29
- const blue = Number(b);
30
- if ([red, green, blue].some((value) => Number.isNaN(value))) {
31
- return null;
32
- }
33
- return { r: red, g: green, b: blue };
34
- }
35
- function normalizeColor(color) {
36
- if (!color) {
37
- return null;
38
- }
39
- const trimmed = color.trim();
40
- return hexToRgb(trimmed) ?? rgbStringToRgb(trimmed);
41
- }
42
- function getRelativeLuminance(rgb) {
43
- const transform = (value) => {
44
- const channel = value / 255;
45
- if (channel <= 0.03928) {
46
- return channel / 12.92;
47
- }
48
- return Math.pow((channel + 0.055) / 1.055, 2.4);
49
- };
50
- const r = transform(rgb.r);
51
- const g = transform(rgb.g);
52
- const b = transform(rgb.b);
53
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
54
- }
55
- export function isDarkColor(color) {
56
- const rgb = normalizeColor(color);
57
- if (!rgb) {
58
- return false;
59
- }
60
- return getRelativeLuminance(rgb) < 0.5;
61
- }
62
- export function ensureSafeColor(color, fallback) {
63
- if (!color) {
64
- return fallback;
65
- }
66
- return isDarkColor(color) ? fallback : color;
67
- }