fragment-headless-sdk 1.0.3 → 1.0.5

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,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { IAnnouncementContent } from "../../types";
3
- export default function AnnouncementButton({ content, }: {
3
+ export default function AnnouncementButton({ content, buttonHref, }: {
4
4
  content: IAnnouncementContent;
5
- }): React.JSX.Element;
5
+ buttonHref?: string;
6
+ }): React.JSX.Element | null;
@@ -1,21 +1,26 @@
1
1
  import React from "react";
2
2
  import { ButtonType } from "../../constants";
3
- export default function AnnouncementButton({ content, }) {
4
- return (React.createElement("a", { href: content.buttonLink || "#", className: "whitespace-nowrap rounded-md px-3 py-2 font-semibold text-sm no-underline text-white hover:cursor-pointer hover:opacity-70 py-2", style: {
5
- ...(content.buttonType === ButtonType.Text
6
- ? {
7
- textDecoration: "underline",
8
- color: content.textColor,
9
- }
10
- : {
11
- backgroundColor: content.buttonColor,
12
- color: content.buttonTextColor,
13
- }),
14
- }, ...(content.buttonLink
3
+ export default function AnnouncementButton({ content, buttonHref, }) {
4
+ // Don’t render if no button or explicitly None
5
+ if (!content?.buttonText || content.buttonType === ButtonType.None)
6
+ return null;
7
+ // If we weren’t given a usable href, don’t render a broken link
8
+ if (!buttonHref)
9
+ return null;
10
+ // Decide if link should open in a new tab.
11
+ // If you already have a boolean like `content.buttonLink` meaning "open in new tab",
12
+ // keep using it; otherwise you can add one later.
13
+ const openInNewTab = Boolean(content.buttonLink);
14
+ const style = content.buttonType === ButtonType.Text
15
+ ? {
16
+ textDecoration: "underline",
17
+ color: content.textColor,
18
+ }
19
+ : {
20
+ backgroundColor: content.buttonColor,
21
+ color: content.buttonTextColor,
22
+ };
23
+ return (React.createElement("a", { href: buttonHref, className: "whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold no-underline hover:cursor-pointer hover:opacity-70", style: style, ...(openInNewTab
15
24
  ? { target: "_blank", rel: "noopener noreferrer" }
16
- : {}), onClick: (e) => {
17
- if (!content.buttonLink) {
18
- e.preventDefault();
19
- }
20
- } }, content.buttonText));
25
+ : {}), "aria-label": content.buttonText }, content.buttonText));
21
26
  }
@@ -1,8 +1,8 @@
1
1
  import React from "react";
2
2
  import { AnnouncementType } from "../../constants";
3
3
  import { IAnnouncementContent } from "../../types";
4
- export default function ({ content, type, handleClose, }: {
4
+ export default function Announcement({ content, type, handleClose, }: {
5
5
  content: IAnnouncementContent;
6
6
  type: AnnouncementType;
7
7
  handleClose: () => void;
8
- }): React.JSX.Element;
8
+ }): React.JSX.Element | null;
@@ -1,10 +1,22 @@
1
- import React from "react";
1
+ import React, { useEffect, useRef } from "react";
2
2
  import { AnnouncementType, ButtonType } from "../../constants";
3
+ import { buildClickUrl, fireImpressionWhenVisible } from "../../utils";
3
4
  import AnnouncementButton from "./AnnouncementButton";
4
5
  import { AnnouncementStyles } from "./AnnouncementStyles";
5
6
  import CountdownTimer from "./CountdownTimer";
6
- export default function ({ content, type, handleClose, }) {
7
- return (React.createElement("div", { className: "relative w-full", style: {
7
+ export default function Announcement({ content, type, handleClose, }) {
8
+ const ref = useRef(null);
9
+ useEffect(() => {
10
+ if (ref.current && content.impressionUrl) {
11
+ fireImpressionWhenVisible(ref.current, content.impressionUrl);
12
+ }
13
+ }, [content?.impressionUrl]);
14
+ const signedButtonHref = content?.buttonLink && content?.clickUrlBase
15
+ ? buildClickUrl(content.clickUrlBase, content.buttonLink)
16
+ : undefined;
17
+ if (!content)
18
+ return null;
19
+ return (React.createElement("div", { ref: ref, className: "relative w-full", style: {
8
20
  backgroundColor: content.bgColor,
9
21
  color: content.textColor,
10
22
  } },
@@ -16,13 +28,13 @@ export default function ({ content, type, handleClose, }) {
16
28
  React.createElement("div", { className: "inline-block max-w-none text-base", dangerouslySetInnerHTML: {
17
29
  __html: content.announcementHtml || "",
18
30
  } }))),
19
- content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content })))) : (React.createElement("div", { className: "flex flex-col md:flex-row items-center justify-center gap-2 md:gap-4 w-full" },
31
+ content.buttonText && content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: signedButtonHref })))) : (React.createElement("div", { className: "flex flex-col md:flex-row items-center justify-center gap-2 md:gap-4 w-full" },
20
32
  React.createElement("div", { className: "max-w-none text-base font-semibold" },
21
33
  React.createElement("div", { dangerouslySetInnerHTML: {
22
34
  __html: content.announcementHtml || "",
23
35
  } })),
24
36
  type === AnnouncementType.Countdown ? (React.createElement(CountdownTimer, { content: content })) : (content.buttonText &&
25
- content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content }))))),
37
+ content.buttonType !== ButtonType.None && (React.createElement(AnnouncementButton, { content: content, buttonHref: signedButtonHref || content.buttonLink || "#" }))))),
26
38
  React.createElement("div", { onClick: handleClose, className: "absolute right-4 top-1/2 -translate-y-1/2 text-3xl leading-none cursor-pointer", style: {
27
39
  color: content.textColor || "#000",
28
40
  } }, "\u00D7"))));
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { IHeroContent } from "../../types";
3
- export default function DesktopHero({ content }: {
3
+ export default function DesktopHero({ buttonHref, content, }: {
4
+ buttonHref?: string;
4
5
  content: IHeroContent;
5
6
  }): React.JSX.Element;
@@ -1,12 +1,12 @@
1
1
  import React from "react";
2
- export default function DesktopHero({ content }) {
2
+ export default function DesktopHero({ buttonHref, content, }) {
3
3
  return (React.createElement("div", { className: "relative h-[400px] gap-4 w-full" },
4
4
  content?.imageUrl && (React.createElement("img", { src: content.imageUrl, alt: content.title || "Hero", className: "absolute inset-0 z-0 object-cover w-full h-full" })),
5
5
  React.createElement("div", { className: "relative z-10 mx-auto flex h-full max-w-screen-xl flex-col items-start justify-center px-10 text-left xl:px-4" },
6
6
  React.createElement("div", { className: "w-2/5" },
7
7
  content?.title && (React.createElement("h1", { className: "text-5xl font-bold leading-tight drop-shadow-xl", style: { color: content.titleColor || "#ffffff" } }, content.title)),
8
8
  content?.description && (React.createElement("div", { className: "mt-4 text-2xl drop-shadow-lg prose", style: { color: content.textColor || undefined }, dangerouslySetInnerHTML: { __html: content.description } })),
9
- content?.buttonLink && content?.buttonText && (React.createElement("a", { href: content.buttonLink, target: "_blank", rel: "noopener noreferrer", className: "no-underline" },
9
+ content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref, target: "_blank", rel: "noopener noreferrer", className: "no-underline" },
10
10
  React.createElement("div", { className: "mt-6 rounded-md px-8 py-2 text-2xl font-semibold drop-shadow-lg transition-all duration-200 hover:bg-gray-800 inline-block", style: {
11
11
  color: content.buttonTextColor ?? undefined,
12
12
  backgroundColor: content.buttonColor ?? undefined,
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { IHeroContent } from "../../types";
3
- export default function MobileHero({ content }: {
3
+ export default function MobileHero({ buttonHref, content, }: {
4
+ buttonHref?: string;
4
5
  content: IHeroContent;
5
6
  }): React.JSX.Element;
@@ -1,11 +1,11 @@
1
1
  import React from "react";
2
- export default function MobileHero({ content }) {
2
+ export default function MobileHero({ buttonHref, content, }) {
3
3
  return (React.createElement("div", { className: "relative z-10 mx-auto gap-4 flex h-full max-w-screen-md flex-col items-center justify-center py-6 text-center" },
4
4
  content?.title && (React.createElement("h1", { className: "text-3xl font-bold drop-shadow-xl px-4", style: { color: content.titleColor || undefined } }, content.title)),
5
5
  (content?.mobileImageUrl || content?.imageUrl) && (React.createElement("div", { className: "w-full" },
6
6
  React.createElement("img", { src: content.mobileImageUrl || content.imageUrl || "", alt: content.title || "Hero", className: "h-full w-full object-cover" }))),
7
7
  content?.description && (React.createElement("div", { className: "px-4 text-2xl drop-shadow-lg prose", style: { color: content.textColor || undefined }, dangerouslySetInnerHTML: { __html: content.description } })),
8
- content?.buttonLink && content?.buttonText && (React.createElement("a", { href: content.buttonLink, target: "_blank", rel: "noopener noreferrer", className: "no-underline" },
8
+ content?.buttonLink && content?.buttonText && (React.createElement("a", { href: buttonHref, target: "_blank", rel: "noopener noreferrer", className: "no-underline" },
9
9
  React.createElement("div", { className: "mb-2 rounded-md px-6 py-2 text-lg font-semibold drop-shadow-lg transition-all duration-200 hover:bg-gray-800", style: {
10
10
  color: content.buttonTextColor ?? undefined,
11
11
  backgroundColor: content.buttonColor ?? undefined,
@@ -1,12 +1,22 @@
1
- import React from "react";
1
+ import React, { useEffect, useRef } from "react";
2
+ import { buildClickUrl, fireImpressionWhenVisible } from "../../utils";
2
3
  import DesktopHero from "./DesktopHero";
3
4
  import MobileHero from "./MobileHero";
4
5
  export default function Hero({ content }) {
6
+ const ref = useRef(null);
7
+ useEffect(() => {
8
+ if (ref.current && content.impressionUrl) {
9
+ fireImpressionWhenVisible(ref.current, content.impressionUrl);
10
+ }
11
+ }, [content?.impressionUrl]);
12
+ const signedButtonHref = content?.buttonLink && content?.clickUrlBase
13
+ ? buildClickUrl(content.clickUrlBase, content.buttonLink)
14
+ : undefined;
5
15
  if (!content)
6
16
  return null;
7
- return (React.createElement("div", { className: "bg-black" },
17
+ return (React.createElement("div", { className: "bg-black", ref: ref },
8
18
  React.createElement("div", { className: "hidden lg:block" },
9
- React.createElement(DesktopHero, { content: content })),
19
+ React.createElement(DesktopHero, { content: content, buttonHref: signedButtonHref })),
10
20
  React.createElement("div", { className: "block lg:hidden" },
11
- React.createElement(MobileHero, { content: content }))));
21
+ React.createElement(MobileHero, { content: content, buttonHref: signedButtonHref }))));
12
22
  }
@@ -15,6 +15,8 @@ export interface IAnnouncementContent {
15
15
  counterEndDate?: string;
16
16
  counterBgColor?: string;
17
17
  counterDigitColor?: string;
18
+ impressionUrl: string;
19
+ clickUrlBase: string;
18
20
  }
19
21
  export interface IAnnouncement {
20
22
  id: string;
@@ -25,4 +27,6 @@ export interface IAnnouncement {
25
27
  content: IAnnouncementContent | null;
26
28
  created_at: string;
27
29
  updated_at: string;
30
+ views_count: number;
31
+ clicks_count: number;
28
32
  }
@@ -16,6 +16,8 @@ export interface IHeroContent {
16
16
  imageUrl: string;
17
17
  mobileImageUrl: string;
18
18
  videoUrl?: string;
19
+ impressionUrl: string;
20
+ clickUrlBase: string;
19
21
  }
20
22
  export interface IHero {
21
23
  id: string;
@@ -27,4 +29,6 @@ export interface IHero {
27
29
  content: IHeroContent | null;
28
30
  created_at: string;
29
31
  updated_at: string;
32
+ views_count: number;
33
+ clicks_count: number;
30
34
  }
@@ -2,10 +2,20 @@ export declare enum ResourceType {
2
2
  HeroBanners = "hero-banners",
3
3
  Announcements = "announcements"
4
4
  }
5
+ export type ListParams = {
6
+ status?: "enabled" | "disabled";
7
+ page?: number;
8
+ limit?: number;
9
+ search?: string;
10
+ pageFilter?: string;
11
+ };
5
12
  type FetchResourceParams = {
6
13
  baseUrl: string;
7
14
  apiKey: string;
8
15
  type: ResourceType;
16
+ params?: ListParams;
17
+ fetchImpl?: typeof fetch;
9
18
  };
10
- export declare function fetchResource<T>({ baseUrl, apiKey, type, }: FetchResourceParams): Promise<T[]>;
19
+ /** Lists resources with optional filters (parity with client.list) */
20
+ export declare function fetchResource<T>({ baseUrl, apiKey, type, params, fetchImpl, }: FetchResourceParams): Promise<T[]>;
11
21
  export {};
@@ -3,15 +3,28 @@ export var ResourceType;
3
3
  ResourceType["HeroBanners"] = "hero-banners";
4
4
  ResourceType["Announcements"] = "announcements";
5
5
  })(ResourceType || (ResourceType = {}));
6
- export async function fetchResource({ baseUrl, apiKey, type, }) {
6
+ /** Lists resources with optional filters (parity with client.list) */
7
+ export async function fetchResource({ baseUrl, apiKey, type, params = {}, fetchImpl, }) {
7
8
  if (!baseUrl || !apiKey) {
8
9
  console.warn("❌ Missing EXTERNAL_API_URL or FRAGMENT_API_KEY");
9
10
  return [];
10
11
  }
11
12
  try {
12
- // Build the endpoint URL for the specific resource type
13
- const endpoint = `${baseUrl}/api/v1/${type}`;
14
- const res = await fetch(endpoint, {
13
+ const f = fetchImpl ?? fetch;
14
+ const base = baseUrl.replace(/\/+$/, "");
15
+ const url = new URL(`/api/v1/${type}`, base);
16
+ // Apply filters/pagination (clamped)
17
+ const page = Math.max(1, params.page ?? 1);
18
+ const limit = Math.min(100, Math.max(1, params.limit ?? 25));
19
+ url.searchParams.set("pageNum", String(page));
20
+ url.searchParams.set("limit", String(limit));
21
+ if (params.status)
22
+ url.searchParams.set("status", params.status);
23
+ if (params.pageFilter)
24
+ url.searchParams.set("page", params.pageFilter);
25
+ if (params.search)
26
+ url.searchParams.set("search", params.search);
27
+ const res = await f(url.toString(), {
15
28
  method: "GET",
16
29
  headers: {
17
30
  Authorization: `Bearer ${apiKey}`,
@@ -21,21 +34,21 @@ export async function fetchResource({ baseUrl, apiKey, type, }) {
21
34
  if (!res.ok) {
22
35
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
23
36
  }
24
- const json = await res.json();
25
- if (json.status === "success") {
26
- // The API returns { status: "success", data: { items: [...], total, page, limit } }
27
- if (json.data?.items && Array.isArray(json.data.items)) {
28
- return json.data.items;
29
- }
30
- // Fallback for direct array response
31
- if (Array.isArray(json.data)) {
32
- return json.data;
33
- }
37
+ const json = (await res.json());
38
+ if (json.status === "error") {
39
+ console.error(`❌ Failed to load ${type}:`, json.message);
40
+ return [];
34
41
  }
35
- console.error(`❌ Failed to load ${type}:`, json.message || "Unknown error");
42
+ const data = json.data;
43
+ if (Array.isArray(data))
44
+ return data;
45
+ if (Array.isArray(data?.items))
46
+ return data.items;
47
+ // Fallback: unknown shape
48
+ return [];
36
49
  }
37
50
  catch (err) {
38
51
  console.error(`❌ Fetch error for ${type}:`, err);
52
+ return [];
39
53
  }
40
- return [];
41
54
  }
@@ -1 +1,2 @@
1
1
  export * from "./fetch-resource";
2
+ export * from "./metrics";
@@ -1 +1,2 @@
1
1
  export * from "./fetch-resource";
2
+ export * from "./metrics";
@@ -0,0 +1,3 @@
1
+ export declare function toBase64Url(input: string): string;
2
+ export declare function buildClickUrl(clickUrlBase: string, targetHref: string): string;
3
+ export declare function fireImpressionWhenVisible(el: HTMLElement, pixelUrl: string): void;
@@ -0,0 +1,56 @@
1
+ // --- Unicode-safe Base64URL ---
2
+ export function toBase64Url(input) {
3
+ // Handles emojis & non-ASCII reliably:
4
+ const b64 = btoa(encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))));
5
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6
+ }
7
+ // --- Robust query appender ---
8
+ function appendQuery(url, key, value) {
9
+ const sep = url.includes("?") ? "&" : "?";
10
+ return `${url}${sep}${key}=${value}`;
11
+ }
12
+ // Build the final redirect URL the CTA should use
13
+ export function buildClickUrl(clickUrlBase, targetHref) {
14
+ const u = encodeURIComponent(toBase64Url(targetHref));
15
+ return appendQuery(clickUrlBase, "u", u);
16
+ }
17
+ // --- View tracking (once per element) ---
18
+ const seenEls = typeof WeakSet !== "undefined" ? new WeakSet() : null;
19
+ export function fireImpressionWhenVisible(el, pixelUrl) {
20
+ if (typeof window === "undefined")
21
+ return; // SSR guard
22
+ if (!el || !pixelUrl)
23
+ return;
24
+ if (seenEls && seenEls.has(el))
25
+ return; // de-dupe by element
26
+ let fired = false;
27
+ const img = new Image();
28
+ const fire = () => {
29
+ if (fired)
30
+ return;
31
+ fired = true;
32
+ if (seenEls)
33
+ seenEls.add(el);
34
+ img.referrerPolicy = "strict-origin-when-cross-origin";
35
+ img.src = pixelUrl; // GET to your /api/v1/metrics/i?e=...&sig=...
36
+ };
37
+ // Fallback if IntersectionObserver is missing
38
+ const fallback = () => {
39
+ // delay a tick to avoid firing during hidden render
40
+ setTimeout(fire, 0);
41
+ };
42
+ if (!("IntersectionObserver" in window)) {
43
+ fallback();
44
+ return;
45
+ }
46
+ const io = new IntersectionObserver((entries) => {
47
+ for (const e of entries) {
48
+ if (e.isIntersecting && e.intersectionRatio >= 0.3) {
49
+ fire();
50
+ io.disconnect();
51
+ break;
52
+ }
53
+ }
54
+ }, { threshold: [0, 0.3] });
55
+ io.observe(el);
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fragment-headless-sdk",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",