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.
- package/dist/components/Announcement/AnnouncementButton.d.ts +2 -1
- package/dist/components/Announcement/AnnouncementButton.js +8 -9
- package/dist/components/Announcement/index.js +6 -3
- package/dist/components/Hero/DesktopHero.d.ts +2 -1
- package/dist/components/Hero/DesktopHero.js +9 -2
- package/dist/components/Hero/MobileHero.d.ts +2 -1
- package/dist/components/Hero/MobileHero.js +13 -16
- package/dist/components/Hero/index.js +5 -4
- package/dist/utils/fetch-resource.d.ts +8 -7
- package/dist/utils/fetch-resource.js +71 -8
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.js +0 -1
- package/dist/utils/metrics.d.ts +1 -0
- package/dist/utils/metrics.js +32 -1
- package/package.json +1 -1
- package/readme.md +75 -3
- package/dist/utils/color.d.ts +0 -2
- package/dist/utils/color.js +0 -67
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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,
|
|
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 {
|
|
2
|
+
import { fireClickMetric } from "../../utils";
|
|
3
3
|
import { renderText, } from "../../utils/hero-resolvers";
|
|
4
|
-
export default function MobileHero({ buttonHref, content, colors, typography, }) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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:
|
|
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
|
|
19
|
-
color:
|
|
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:
|
|
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:
|
|
28
|
+
color: colors.text,
|
|
32
29
|
font: typography.description.font,
|
|
33
30
|
}),
|
|
34
|
-
content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref,
|
|
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
|
|
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:
|
|
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:
|
|
28
|
+
React.createElement(MobileHero, { content: content, buttonHref: buttonHref, clickHref: clickTrackingHref, colors: colors, typography: typography }))));
|
|
28
29
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
export declare
|
|
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: '
|
|
14
|
+
/** Request cache mode (default: 'force-cache' with 60s revalidation) */
|
|
14
15
|
cache?: RequestCache;
|
|
15
|
-
/** Next.js revalidation time in seconds (default:
|
|
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
|
|
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
|
|
45
|
+
// In Next.js production, default to caching with 60 second revalidation
|
|
24
46
|
return {
|
|
25
|
-
cache: "
|
|
26
|
-
revalidate:
|
|
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
|
|
51
|
-
|
|
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
|
};
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
package/dist/utils/metrics.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/metrics.js
CHANGED
|
@@ -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
|
|
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
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.
|
|
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
|
package/dist/utils/color.d.ts
DELETED
package/dist/utils/color.js
DELETED
|
@@ -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
|
-
}
|