@likyly/react 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,180 @@
1
- # Temporary Holding Version
1
+ # @likyly/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React bindings for [`@likyly/sdk`](../js): `useRecommendations` (headless) and
4
+ `<LikylyRecommendations>` (ready-to-use) for rendering a LIKYLY [placement](../../docs/placements.md)
5
+ and tracking the impression/click events it needs to learn from.
6
+
7
+ ## Why a separate package (not React support inside `@likyly/sdk`)
8
+
9
+ - `@likyly/sdk` is dependency-free and runs in Node, edge runtimes and browsers alike (it's used
10
+ server-side for `items`/`users` too). Adding React to it would force that dependency - and the
11
+ `"use client"` boundary Next.js's App Router needs - onto every consumer, including
12
+ server-side-only integrations that never render anything.
13
+ - This repo's own convention is one package per surface (`sdk/js`, `sdk/python`, `sdk/php`,
14
+ `sdk/mcp`, ...) - `@likyly/react` is that same pattern applied to a second JS surface, not a
15
+ new idea.
16
+ - It matches how this kind of binding is normally split in the ecosystem (a core client plus a
17
+ thin framework package: `stripe` / `@stripe/react-stripe-js`, `algoliasearch` /
18
+ `react-instantsearch`).
19
+ - `@likyly/react` depends on `@likyly/sdk` and adds **zero** wire/HTTP logic of its own - every
20
+ method here calls straight through to the `Likyly` client underneath (see `src/controller.ts`,
21
+ the plain-JS state machine both `useRecommendations` and `<LikylyRecommendations>` share - it
22
+ has no React import at all, which is also why it's unit-tested with no DOM/renderer needed).
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ npm install @likyly/react @likyly/sdk react
28
+ ```
29
+
30
+ > During local development in the `likyly-recsys` monorepo (before `@likyly/sdk` is published
31
+ > to npm), this package's `dependencies` point at it via a relative `file:../js` reference.
32
+ > Once published, that becomes a normal semver range - nothing a consumer needs to think about.
33
+ > One consequence in the meantime: npm installs a `file:` dependency as a symlink pointing
34
+ > outside the consuming app's own folder, which **Turbopack fails to resolve** ("Module not
35
+ > found"). Run `next dev`/`next build` with `--webpack` until both packages are on npm - see
36
+ > `examples/demo-storefront` for a working setup with this already applied.
37
+
38
+ ## Quick start
39
+
40
+ ```tsx
41
+ import { LikylyProvider, LikylyRecommendations } from "@likyly/react";
42
+
43
+ export function App() {
44
+ return (
45
+ <LikylyProvider apiKey={process.env.NEXT_PUBLIC_LIKYLY_KEY!}>
46
+ <ProductPage />
47
+ </LikylyProvider>
48
+ );
49
+ }
50
+
51
+ function ProductPage({ product, user, sessionId }) {
52
+ return (
53
+ <LikylyRecommendations
54
+ placement="pdp-related"
55
+ itemId={product.id}
56
+ userId={user?.id}
57
+ sessionId={sessionId}
58
+ limit={4}
59
+ />
60
+ );
61
+ }
62
+ ```
63
+
64
+ `apiKey` must be your **restricted public key** - this runs in the browser. `Likyly`'s own
65
+ constructor (from `@likyly/sdk`) refuses a secret- or developer-shaped key when it detects it's
66
+ running in one, so passing the wrong key here fails loudly and immediately rather than shipping
67
+ quietly. See [Security](#security) below.
68
+
69
+ Configure the placement itself (`pdp-related` above) once via the LIKYLY MCP admin tools
70
+ (`create_placement`) or dashboard - never from this package, which is intentionally
71
+ runtime-only. `get_placement_requirements` tells you exactly which of `itemId`/`userId`/
72
+ `sessionId`/... a given placement actually needs.
73
+
74
+ ## Level 1: headless (`useRecommendations`)
75
+
76
+ 100% of the markup is yours; the hook fetches and gives you tracking helpers to call yourself:
77
+
78
+ ```tsx
79
+ import { useRecommendations } from "@likyly/react";
80
+
81
+ function RelatedProducts({ product, user, sessionId }) {
82
+ const { items, isLoading, strategyUsed, trackImpression, trackClick } = useRecommendations({
83
+ placement: "pdp-related",
84
+ context: { itemId: product.id, userId: user?.id, sessionId },
85
+ limit: 4,
86
+ });
87
+
88
+ if (isLoading) return <Skeleton />;
89
+ return (
90
+ <div className="my-own-grid">
91
+ {items.map((item) => (
92
+ <MyOwnCard
93
+ key={item.itemId}
94
+ item={item}
95
+ onVisible={() => trackImpression(item.itemId)}
96
+ onClick={() => trackClick(item.itemId)}
97
+ />
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+ ```
103
+
104
+ `trackImpression`/`trackClick` are manual here - a headless hook has no DOM for LIKYLY to
105
+ observe, so it can't tell what the visitor actually saw. Call `trackImpression` once your own
106
+ card is actually visible (e.g. from your own `IntersectionObserver`, or eagerly on render if
107
+ that's an acceptable approximation for your UI), and `trackClick` from your card's click handler.
108
+
109
+ ## Level 2: ready-to-use (`<LikylyRecommendations>`)
110
+
111
+ Fetches, renders, **and auto-tracks** `recommendationImpression` (via `IntersectionObserver` -
112
+ fired when a card is actually scrolled into view, not merely fetched) and `recommendationClick`.
113
+ Doesn't lock you out of styling it:
114
+
115
+ ```tsx
116
+ // Default markup, styled via className / the data-likyly-* attributes it sets:
117
+ <LikylyRecommendations placement="pdp-related" itemId={product.id} userId={user?.id} className="related-grid" />
118
+
119
+ // Full control over each card, while impression/click tracking stays automatic:
120
+ <LikylyRecommendations
121
+ placement="pdp-related"
122
+ itemId={product.id}
123
+ userId={user?.id}
124
+ renderItem={(item, { onClick }) => (
125
+ <MyExistingProductCard product={item} onClick={onClick} />
126
+ )}
127
+ />
128
+ ```
129
+
130
+ ## What's never auto-tracked
131
+
132
+ `product_view`, `add_to_cart` and `purchase` are **never** fired by this package, on purpose -
133
+ LIKYLY has no reliable, universal way to know when your app navigated to a product page, added
134
+ something to a cart, or completed a checkout across every possible stack. Instrument these
135
+ yourself with the plain SDK helpers, at the point in your own code where each actually happens:
136
+
137
+ ```ts
138
+ await likyly.events.productView({ itemId: product.id, userId: user?.id, sessionId });
139
+ await likyly.events.addToCart({ itemId: product.id, userId: user?.id, sessionId, quantity: 1 });
140
+ await likyly.events.purchase({ eventId: `purchase_${orderId}_${item.id}`, itemId: item.id, userId: user?.id, quantity: item.quantity, properties: { price: item.price, currency: "EUR", orderId } });
141
+ ```
142
+
143
+ Call `get_tracking_requirements` (an MCP tool, or `GET /placements/{slug}/tracking-requirements`)
144
+ for exactly which events a given placement needs, and `get_placement_health` /
145
+ `validate_integration` to confirm they're actually arriving once wired up.
146
+
147
+ ## Anonymous sessions
148
+
149
+ `@likyly/sdk`'s `LikylySession` (held by `<LikylyProvider>`, reachable via `useLikyly().session`)
150
+ is a first-party, cookieless anonymous id - no need to invent your own:
151
+
152
+ ```tsx
153
+ import { useLikyly } from "@likyly/react";
154
+
155
+ function LoginForm() {
156
+ const { client, session } = useLikyly();
157
+ async function onLogin(user) {
158
+ await session.identify(client, user.id); // attaches this session's past interactions to them
159
+ }
160
+ }
161
+ ```
162
+
163
+ ## Security
164
+
165
+ - `<LikylyProvider apiKey>` must be the **public** key. `@likyly/sdk`'s `Likyly` constructor
166
+ throws immediately if it detects a secret/developer-shaped key (`sk_`/`lk_` prefix) running
167
+ where `window`/`document` exist - i.e. exactly the "this ended up in a browser" mistake.
168
+ - Never read that key from a server-only env var, and never name the env var something like
169
+ `NEXT_PUBLIC_LIKYLY_SECRET_KEY` - only the public key belongs in a `NEXT_PUBLIC_*`/
170
+ `VITE_PUBLIC_*`-style variable at all.
171
+ - This package has no method that accepts or needs a secret/developer key - there is
172
+ structurally nothing here that would tempt using one. Placement/data-source configuration is
173
+ the LIKYLY MCP admin tools' job (secret/developer key, server-side, from your coding agent),
174
+ never this runtime package's.
175
+
176
+ ## Requirements
177
+
178
+ React 18 or 19 (`useSyncExternalStore`). Server-rendered frameworks (Next.js, Remix, ...): the
179
+ files that use hooks or browser APIs are marked `"use client"` already - use `<LikylyRecommendations>`/
180
+ `useRecommendations` from a Client Component.
@@ -0,0 +1,46 @@
1
+ import type { Likyly, PlacementContext, RecommendedItem } from "@likyly/sdk";
2
+ /**
3
+ * Framework-agnostic state machine behind `useRecommendations` - kept separate from any React
4
+ * API on purpose: it's what makes the hook's fetch/race-condition/tracking logic testable with
5
+ * plain Node tests, no DOM or React renderer needed (see test/controller.test.ts). The hook
6
+ * itself (`use-recommendations.ts`) is a thin `useSyncExternalStore` wrapper around one instance
7
+ * of this per component.
8
+ */
9
+ export interface RecommendationsControllerOptions {
10
+ placement: string;
11
+ context?: PlacementContext;
12
+ limit?: number;
13
+ /** Skip fetching - e.g. while a signal the placement needs (itemId, userId) isn't known yet. */
14
+ enabled?: boolean;
15
+ }
16
+ export interface RecommendationsState {
17
+ items: RecommendedItem[];
18
+ strategyUsed?: string;
19
+ recommendationId?: string;
20
+ isLoading: boolean;
21
+ error?: Error;
22
+ }
23
+ type TrackableLikyly = Pick<Likyly, "recommend" | "events">;
24
+ export declare class RecommendationsController {
25
+ private readonly likyly;
26
+ private state;
27
+ private readonly listeners;
28
+ private lastPlacement;
29
+ private lastContext;
30
+ private generation;
31
+ private lastRequestKey;
32
+ constructor(likyly: TrackableLikyly);
33
+ getState(): RecommendationsState;
34
+ subscribe(listener: () => void): () => void;
35
+ private setState;
36
+ run(opts: RecommendationsControllerOptions): Promise<void>;
37
+ /** `recommendationImpression` for one rendered item, attributed to the last `recommend()`
38
+ * call - manual in headless mode (there's no DOM for LIKYLY to observe); `<LikylyRecommendations>`
39
+ * calls this automatically via IntersectionObserver. A no-op before the first result arrives,
40
+ * or if neither a user nor a session was in the context (nothing to attribute the event to). */
41
+ trackImpression(itemId: string): void;
42
+ /** `recommendationClick` for one item - same attribution/no-op rules as {@link trackImpression}. */
43
+ trackClick(itemId: string): void;
44
+ private trackRecommendationEvent;
45
+ }
46
+ export {};
@@ -0,0 +1,79 @@
1
+ const INITIAL_STATE = { items: [], isLoading: false };
2
+ export class RecommendationsController {
3
+ likyly;
4
+ state = INITIAL_STATE;
5
+ listeners = new Set();
6
+ lastPlacement;
7
+ lastContext;
8
+ // Bumped on every run() - a response for a superseded call (placement/context changed while
9
+ // it was in flight) is dropped instead of clobbering a newer, already-resolved state.
10
+ generation = 0;
11
+ // What the current items answer. A different request (another product, user, placement...)
12
+ // must not keep showing the previous answer while it loads - that flashed the last product's
13
+ // recommendations on the next product page. A refetch of the same request keeps them.
14
+ lastRequestKey;
15
+ constructor(likyly) {
16
+ this.likyly = likyly;
17
+ }
18
+ getState() {
19
+ return this.state;
20
+ }
21
+ subscribe(listener) {
22
+ this.listeners.add(listener);
23
+ return () => this.listeners.delete(listener);
24
+ }
25
+ setState(patch) {
26
+ this.state = { ...this.state, ...patch };
27
+ for (const listener of this.listeners)
28
+ listener();
29
+ }
30
+ async run(opts) {
31
+ this.lastPlacement = opts.placement;
32
+ this.lastContext = opts.context;
33
+ if (opts.enabled === false) {
34
+ this.generation += 1; // invalidate any call still in flight from before enabled became false
35
+ this.lastRequestKey = undefined;
36
+ this.setState(INITIAL_STATE);
37
+ return;
38
+ }
39
+ const generation = ++this.generation;
40
+ const requestKey = JSON.stringify([opts.placement, opts.context ?? {}, opts.limit ?? null]);
41
+ const sameRequest = requestKey === this.lastRequestKey;
42
+ this.lastRequestKey = requestKey;
43
+ this.setState(sameRequest
44
+ ? { isLoading: true, error: undefined }
45
+ : { items: [], strategyUsed: undefined, recommendationId: undefined, isLoading: true, error: undefined });
46
+ try {
47
+ const result = await this.likyly.recommend({ placement: opts.placement, context: opts.context, limit: opts.limit });
48
+ if (generation !== this.generation)
49
+ return; // a newer call already started - drop this result
50
+ this.setState({ items: result.items, strategyUsed: result.strategyUsed, recommendationId: result.recommendationId, isLoading: false });
51
+ }
52
+ catch (error) {
53
+ if (generation !== this.generation)
54
+ return;
55
+ this.setState({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });
56
+ }
57
+ }
58
+ /** `recommendationImpression` for one rendered item, attributed to the last `recommend()`
59
+ * call - manual in headless mode (there's no DOM for LIKYLY to observe); `<LikylyRecommendations>`
60
+ * calls this automatically via IntersectionObserver. A no-op before the first result arrives,
61
+ * or if neither a user nor a session was in the context (nothing to attribute the event to). */
62
+ trackImpression(itemId) {
63
+ this.trackRecommendationEvent("recommendationImpression", itemId);
64
+ }
65
+ /** `recommendationClick` for one item - same attribution/no-op rules as {@link trackImpression}. */
66
+ trackClick(itemId) {
67
+ this.trackRecommendationEvent("recommendationClick", itemId);
68
+ }
69
+ trackRecommendationEvent(method, itemId) {
70
+ const { recommendationId } = this.state;
71
+ if (!recommendationId || !this.lastPlacement)
72
+ return;
73
+ const userId = this.lastContext?.userId;
74
+ const sessionId = this.lastContext?.sessionId ?? this.lastContext?.anonymousId;
75
+ if (!userId && !sessionId)
76
+ return; // an event needs a user or a session - see @likyly/sdk's EventInput
77
+ void this.likyly.events[method]({ itemId, recommendationId, placement: this.lastPlacement, userId, sessionId });
78
+ }
79
+ }
@@ -0,0 +1,8 @@
1
+ export { LikylyProvider, useLikyly } from "./provider.js";
2
+ export type { LikylyContextValue, LikylyProviderProps } from "./provider.js";
3
+ export { useRecommendations } from "./use-recommendations.js";
4
+ export type { UseRecommendationsOptions, UseRecommendationsResult } from "./use-recommendations.js";
5
+ export { LikylyRecommendations } from "./likyly-recommendations.js";
6
+ export type { LikylyRecommendationsProps } from "./likyly-recommendations.js";
7
+ export { RecommendationsController } from "./controller.js";
8
+ export type { RecommendationsControllerOptions, RecommendationsState } from "./controller.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { LikylyProvider, useLikyly } from "./provider.js";
2
+ export { useRecommendations } from "./use-recommendations.js";
3
+ export { LikylyRecommendations } from "./likyly-recommendations.js";
4
+ export { RecommendationsController } from "./controller.js";
@@ -0,0 +1,45 @@
1
+ import { type ReactNode } from "react";
2
+ import type { RecommendedItem } from "@likyly/sdk";
3
+ export interface LikylyRecommendationsProps {
4
+ /** The placement's slug, e.g. "pdp-related". */
5
+ placement: string;
6
+ itemId?: string;
7
+ userId?: string;
8
+ sessionId?: string;
9
+ anonymousId?: string;
10
+ categoryId?: string;
11
+ collectionId?: string;
12
+ cartItemIds?: string[];
13
+ limit?: number;
14
+ /** Skip fetching - e.g. while `itemId`/`userId` aren't known yet. Default true. */
15
+ enabled?: boolean;
16
+ /** Applied to the wrapping `<ul>` - the only styling hook when using the default markup. */
17
+ className?: string;
18
+ /**
19
+ * Full control over each card's markup - this component still fetches the data and
20
+ * auto-tracks impression/click, you own every pixel of the card itself. `onClick` fires
21
+ * `recommendationClick`; wire it to whatever element the visitor actually interacts with.
22
+ */
23
+ renderItem?: (item: RecommendedItem, helpers: {
24
+ onClick: () => void;
25
+ }) => ReactNode;
26
+ /** Rendered while the first request is in flight. */
27
+ loadingFallback?: ReactNode;
28
+ /** Rendered on a request error, or once loaded with zero items. Nothing, by default - a
29
+ * recommendations block that has nothing to show should usually just not be there. */
30
+ emptyFallback?: ReactNode;
31
+ }
32
+ /**
33
+ * Ready-to-use: fetches a placement's recommendations and renders them, auto-tracking
34
+ * `recommendationImpression` (via `IntersectionObserver` - fired when a card is actually seen,
35
+ * not merely fetched) and `recommendationClick`. Needs a `<LikylyProvider>` above it in the tree.
36
+ *
37
+ * ```tsx
38
+ * <LikylyRecommendations placement="pdp-related" itemId={product.id} userId={user?.id} limit={4} />
39
+ * ```
40
+ *
41
+ * `product_view`/`add_to_cart`/`purchase` are never tracked here - see docs/placements.md for
42
+ * why (this component can't reliably know when your own checkout completes) and the SDK
43
+ * helpers (`likyly.events.productView`/`addToCart`/`purchase`) for instrumenting them yourself.
44
+ */
45
+ export declare function LikylyRecommendations({ placement, itemId, userId, sessionId, anonymousId, categoryId, collectionId, cartItemIds, limit, enabled, className, renderItem, loadingFallback, emptyFallback, }: LikylyRecommendationsProps): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from "react";
4
+ import { useRecommendations } from "./use-recommendations.js";
5
+ /**
6
+ * Ready-to-use: fetches a placement's recommendations and renders them, auto-tracking
7
+ * `recommendationImpression` (via `IntersectionObserver` - fired when a card is actually seen,
8
+ * not merely fetched) and `recommendationClick`. Needs a `<LikylyProvider>` above it in the tree.
9
+ *
10
+ * ```tsx
11
+ * <LikylyRecommendations placement="pdp-related" itemId={product.id} userId={user?.id} limit={4} />
12
+ * ```
13
+ *
14
+ * `product_view`/`add_to_cart`/`purchase` are never tracked here - see docs/placements.md for
15
+ * why (this component can't reliably know when your own checkout completes) and the SDK
16
+ * helpers (`likyly.events.productView`/`addToCart`/`purchase`) for instrumenting them yourself.
17
+ */
18
+ export function LikylyRecommendations({ placement, itemId, userId, sessionId, anonymousId, categoryId, collectionId, cartItemIds, limit, enabled, className, renderItem, loadingFallback = null, emptyFallback = null, }) {
19
+ const { items, isLoading, error, trackImpression, trackClick } = useRecommendations({
20
+ placement,
21
+ context: { itemId, userId, sessionId, anonymousId, categoryId, collectionId, cartItemIds },
22
+ limit,
23
+ enabled,
24
+ });
25
+ if (isLoading && items.length === 0)
26
+ return _jsx(_Fragment, { children: loadingFallback });
27
+ if (error || items.length === 0)
28
+ return _jsx(_Fragment, { children: emptyFallback });
29
+ return (_jsx("ul", { className: className, "data-likyly-placement": placement, children: items.map((item) => (_jsx(RecommendationCard, { item: item, renderItem: renderItem, onImpression: () => trackImpression(item.itemId), onClick: () => trackClick(item.itemId) }, item.itemId))) }));
30
+ }
31
+ function RecommendationCard({ item, renderItem, onImpression, onClick, }) {
32
+ const ref = useRef(null);
33
+ const trackedImpression = useRef(false);
34
+ useEffect(() => {
35
+ const el = ref.current;
36
+ if (!el || trackedImpression.current)
37
+ return;
38
+ // No IntersectionObserver (very old browsers, some SSR-adjacent test environments): fire
39
+ // once eagerly rather than never tracking the impression at all.
40
+ if (typeof IntersectionObserver === "undefined") {
41
+ trackedImpression.current = true;
42
+ onImpression();
43
+ return;
44
+ }
45
+ const observer = new IntersectionObserver((entries) => {
46
+ for (const entry of entries) {
47
+ if (entry.isIntersecting && !trackedImpression.current) {
48
+ trackedImpression.current = true;
49
+ onImpression();
50
+ observer.disconnect();
51
+ }
52
+ }
53
+ }, { threshold: 0.5 });
54
+ observer.observe(el);
55
+ return () => observer.disconnect();
56
+ // onImpression is re-created each render (it closes over `item`) but always calls the same
57
+ // underlying tracked item - re-subscribing on every render would still only ever fire once
58
+ // thanks to trackedImpression, so omitting it from deps is deliberate, not a bug.
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, []);
61
+ return (_jsx("li", { ref: ref, "data-likyly-item-id": item.itemId, onClick: onClick, children: renderItem ? (renderItem(item, { onClick })) : (_jsxs("a", { href: typeof item.properties.url === "string" ? item.properties.url : undefined, children: [typeof item.properties.image === "string" ? _jsx("img", { src: item.properties.image, alt: item.title ?? "" }) : null, _jsx("div", { children: item.title }), item.properties.price !== undefined ? _jsx("div", { children: String(item.properties.price) }) : null] })) }));
62
+ }
@@ -0,0 +1,28 @@
1
+ import { type ReactNode } from "react";
2
+ import { Likyly, LikylySession, type LikylyOptions } from "@likyly/sdk";
3
+ export interface LikylyProviderProps extends Pick<LikylyOptions, "apiKey" | "baseUrl" | "catalog"> {
4
+ children: ReactNode;
5
+ /** Namespaces the anonymous-session localStorage key - see @likyly/sdk's LikylySession. */
6
+ sessionStorageKey?: string;
7
+ }
8
+ export interface LikylyContextValue {
9
+ client: Likyly;
10
+ session: LikylySession;
11
+ }
12
+ /**
13
+ * Wrap your app (or the part of it that shows recommendations) once. `apiKey` must be your
14
+ * **restricted public key** - this runs in the browser, and `Likyly`'s own constructor refuses
15
+ * a secret/developer-shaped key there (see @likyly/sdk's README, "Which key?"). Never read it
16
+ * from a server-only env var by mistake.
17
+ *
18
+ * ```tsx
19
+ * <LikylyProvider apiKey={process.env.NEXT_PUBLIC_LIKYLY_KEY!}>
20
+ * <ProductPage />
21
+ * </LikylyProvider>
22
+ * ```
23
+ */
24
+ export declare function LikylyProvider({ apiKey, baseUrl, catalog, sessionStorageKey, children }: LikylyProviderProps): import("react").JSX.Element;
25
+ /** The `Likyly` client and `LikylySession` this component tree's `<LikylyProvider>` holds -
26
+ * what `useRecommendations`/`<LikylyRecommendations>` use internally; call it yourself for
27
+ * anything else the SDK offers (`client.events.identify`, `session.getAnonymousId()`, ...). */
28
+ export declare function useLikyly(): LikylyContextValue;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ import { Likyly, LikylySession } from "@likyly/sdk";
5
+ const LikylyContext = createContext(undefined);
6
+ /**
7
+ * Wrap your app (or the part of it that shows recommendations) once. `apiKey` must be your
8
+ * **restricted public key** - this runs in the browser, and `Likyly`'s own constructor refuses
9
+ * a secret/developer-shaped key there (see @likyly/sdk's README, "Which key?"). Never read it
10
+ * from a server-only env var by mistake.
11
+ *
12
+ * ```tsx
13
+ * <LikylyProvider apiKey={process.env.NEXT_PUBLIC_LIKYLY_KEY!}>
14
+ * <ProductPage />
15
+ * </LikylyProvider>
16
+ * ```
17
+ */
18
+ export function LikylyProvider({ apiKey, baseUrl, catalog, sessionStorageKey, children }) {
19
+ const value = useMemo(() => ({
20
+ client: new Likyly({ apiKey, baseUrl, catalog, userAgent: "react" }),
21
+ session: new LikylySession({ storageKey: sessionStorageKey }),
22
+ }),
23
+ // apiKey/baseUrl/catalog/sessionStorageKey are configuration, not per-render values - a real
24
+ // change (e.g. switching accounts at runtime) intentionally creates a fresh client/session.
25
+ [apiKey, baseUrl, catalog, sessionStorageKey]);
26
+ return _jsx(LikylyContext.Provider, { value: value, children: children });
27
+ }
28
+ /** The `Likyly` client and `LikylySession` this component tree's `<LikylyProvider>` holds -
29
+ * what `useRecommendations`/`<LikylyRecommendations>` use internally; call it yourself for
30
+ * anything else the SDK offers (`client.events.identify`, `session.getAnonymousId()`, ...). */
31
+ export function useLikyly() {
32
+ const value = useContext(LikylyContext);
33
+ if (!value) {
34
+ throw new Error("useLikyly (and useRecommendations / <LikylyRecommendations>) must be used inside <LikylyProvider>");
35
+ }
36
+ return value;
37
+ }
@@ -0,0 +1,79 @@
1
+ const INITIAL_STATE = { items: [], isLoading: false };
2
+ export class RecommendationsController {
3
+ likyly;
4
+ state = INITIAL_STATE;
5
+ listeners = new Set();
6
+ lastPlacement;
7
+ lastContext;
8
+ // Bumped on every run() - a response for a superseded call (placement/context changed while
9
+ // it was in flight) is dropped instead of clobbering a newer, already-resolved state.
10
+ generation = 0;
11
+ // What the current items answer. A different request (another product, user, placement...)
12
+ // must not keep showing the previous answer while it loads - that flashed the last product's
13
+ // recommendations on the next product page. A refetch of the same request keeps them.
14
+ lastRequestKey;
15
+ constructor(likyly) {
16
+ this.likyly = likyly;
17
+ }
18
+ getState() {
19
+ return this.state;
20
+ }
21
+ subscribe(listener) {
22
+ this.listeners.add(listener);
23
+ return () => this.listeners.delete(listener);
24
+ }
25
+ setState(patch) {
26
+ this.state = { ...this.state, ...patch };
27
+ for (const listener of this.listeners)
28
+ listener();
29
+ }
30
+ async run(opts) {
31
+ this.lastPlacement = opts.placement;
32
+ this.lastContext = opts.context;
33
+ if (opts.enabled === false) {
34
+ this.generation += 1; // invalidate any call still in flight from before enabled became false
35
+ this.lastRequestKey = undefined;
36
+ this.setState(INITIAL_STATE);
37
+ return;
38
+ }
39
+ const generation = ++this.generation;
40
+ const requestKey = JSON.stringify([opts.placement, opts.context ?? {}, opts.limit ?? null]);
41
+ const sameRequest = requestKey === this.lastRequestKey;
42
+ this.lastRequestKey = requestKey;
43
+ this.setState(sameRequest
44
+ ? { isLoading: true, error: undefined }
45
+ : { items: [], strategyUsed: undefined, recommendationId: undefined, isLoading: true, error: undefined });
46
+ try {
47
+ const result = await this.likyly.recommend({ placement: opts.placement, context: opts.context, limit: opts.limit });
48
+ if (generation !== this.generation)
49
+ return; // a newer call already started - drop this result
50
+ this.setState({ items: result.items, strategyUsed: result.strategyUsed, recommendationId: result.recommendationId, isLoading: false });
51
+ }
52
+ catch (error) {
53
+ if (generation !== this.generation)
54
+ return;
55
+ this.setState({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });
56
+ }
57
+ }
58
+ /** `recommendationImpression` for one rendered item, attributed to the last `recommend()`
59
+ * call - manual in headless mode (there's no DOM for LIKYLY to observe); `<LikylyRecommendations>`
60
+ * calls this automatically via IntersectionObserver. A no-op before the first result arrives,
61
+ * or if neither a user nor a session was in the context (nothing to attribute the event to). */
62
+ trackImpression(itemId) {
63
+ this.trackRecommendationEvent("recommendationImpression", itemId);
64
+ }
65
+ /** `recommendationClick` for one item - same attribution/no-op rules as {@link trackImpression}. */
66
+ trackClick(itemId) {
67
+ this.trackRecommendationEvent("recommendationClick", itemId);
68
+ }
69
+ trackRecommendationEvent(method, itemId) {
70
+ const { recommendationId } = this.state;
71
+ if (!recommendationId || !this.lastPlacement)
72
+ return;
73
+ const userId = this.lastContext?.userId;
74
+ const sessionId = this.lastContext?.sessionId ?? this.lastContext?.anonymousId;
75
+ if (!userId && !sessionId)
76
+ return; // an event needs a user or a session - see @likyly/sdk's EventInput
77
+ void this.likyly.events[method]({ itemId, recommendationId, placement: this.lastPlacement, userId, sessionId });
78
+ }
79
+ }
@@ -0,0 +1,4 @@
1
+ export { LikylyProvider, useLikyly } from "./provider.js";
2
+ export { useRecommendations } from "./use-recommendations.js";
3
+ export { LikylyRecommendations } from "./likyly-recommendations.js";
4
+ export { RecommendationsController } from "./controller.js";
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from "react";
4
+ import { useRecommendations } from "./use-recommendations.js";
5
+ /**
6
+ * Ready-to-use: fetches a placement's recommendations and renders them, auto-tracking
7
+ * `recommendationImpression` (via `IntersectionObserver` - fired when a card is actually seen,
8
+ * not merely fetched) and `recommendationClick`. Needs a `<LikylyProvider>` above it in the tree.
9
+ *
10
+ * ```tsx
11
+ * <LikylyRecommendations placement="pdp-related" itemId={product.id} userId={user?.id} limit={4} />
12
+ * ```
13
+ *
14
+ * `product_view`/`add_to_cart`/`purchase` are never tracked here - see docs/placements.md for
15
+ * why (this component can't reliably know when your own checkout completes) and the SDK
16
+ * helpers (`likyly.events.productView`/`addToCart`/`purchase`) for instrumenting them yourself.
17
+ */
18
+ export function LikylyRecommendations({ placement, itemId, userId, sessionId, anonymousId, categoryId, collectionId, cartItemIds, limit, enabled, className, renderItem, loadingFallback = null, emptyFallback = null, }) {
19
+ const { items, isLoading, error, trackImpression, trackClick } = useRecommendations({
20
+ placement,
21
+ context: { itemId, userId, sessionId, anonymousId, categoryId, collectionId, cartItemIds },
22
+ limit,
23
+ enabled,
24
+ });
25
+ if (isLoading && items.length === 0)
26
+ return _jsx(_Fragment, { children: loadingFallback });
27
+ if (error || items.length === 0)
28
+ return _jsx(_Fragment, { children: emptyFallback });
29
+ return (_jsx("ul", { className: className, "data-likyly-placement": placement, children: items.map((item) => (_jsx(RecommendationCard, { item: item, renderItem: renderItem, onImpression: () => trackImpression(item.itemId), onClick: () => trackClick(item.itemId) }, item.itemId))) }));
30
+ }
31
+ function RecommendationCard({ item, renderItem, onImpression, onClick, }) {
32
+ const ref = useRef(null);
33
+ const trackedImpression = useRef(false);
34
+ useEffect(() => {
35
+ const el = ref.current;
36
+ if (!el || trackedImpression.current)
37
+ return;
38
+ // No IntersectionObserver (very old browsers, some SSR-adjacent test environments): fire
39
+ // once eagerly rather than never tracking the impression at all.
40
+ if (typeof IntersectionObserver === "undefined") {
41
+ trackedImpression.current = true;
42
+ onImpression();
43
+ return;
44
+ }
45
+ const observer = new IntersectionObserver((entries) => {
46
+ for (const entry of entries) {
47
+ if (entry.isIntersecting && !trackedImpression.current) {
48
+ trackedImpression.current = true;
49
+ onImpression();
50
+ observer.disconnect();
51
+ }
52
+ }
53
+ }, { threshold: 0.5 });
54
+ observer.observe(el);
55
+ return () => observer.disconnect();
56
+ // onImpression is re-created each render (it closes over `item`) but always calls the same
57
+ // underlying tracked item - re-subscribing on every render would still only ever fire once
58
+ // thanks to trackedImpression, so omitting it from deps is deliberate, not a bug.
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, []);
61
+ return (_jsx("li", { ref: ref, "data-likyly-item-id": item.itemId, onClick: onClick, children: renderItem ? (renderItem(item, { onClick })) : (_jsxs("a", { href: typeof item.properties.url === "string" ? item.properties.url : undefined, children: [typeof item.properties.image === "string" ? _jsx("img", { src: item.properties.image, alt: item.title ?? "" }) : null, _jsx("div", { children: item.title }), item.properties.price !== undefined ? _jsx("div", { children: String(item.properties.price) }) : null] })) }));
62
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ import { Likyly, LikylySession } from "@likyly/sdk";
5
+ const LikylyContext = createContext(undefined);
6
+ /**
7
+ * Wrap your app (or the part of it that shows recommendations) once. `apiKey` must be your
8
+ * **restricted public key** - this runs in the browser, and `Likyly`'s own constructor refuses
9
+ * a secret/developer-shaped key there (see @likyly/sdk's README, "Which key?"). Never read it
10
+ * from a server-only env var by mistake.
11
+ *
12
+ * ```tsx
13
+ * <LikylyProvider apiKey={process.env.NEXT_PUBLIC_LIKYLY_KEY!}>
14
+ * <ProductPage />
15
+ * </LikylyProvider>
16
+ * ```
17
+ */
18
+ export function LikylyProvider({ apiKey, baseUrl, catalog, sessionStorageKey, children }) {
19
+ const value = useMemo(() => ({
20
+ client: new Likyly({ apiKey, baseUrl, catalog, userAgent: "react" }),
21
+ session: new LikylySession({ storageKey: sessionStorageKey }),
22
+ }),
23
+ // apiKey/baseUrl/catalog/sessionStorageKey are configuration, not per-render values - a real
24
+ // change (e.g. switching accounts at runtime) intentionally creates a fresh client/session.
25
+ [apiKey, baseUrl, catalog, sessionStorageKey]);
26
+ return _jsx(LikylyContext.Provider, { value: value, children: children });
27
+ }
28
+ /** The `Likyly` client and `LikylySession` this component tree's `<LikylyProvider>` holds -
29
+ * what `useRecommendations`/`<LikylyRecommendations>` use internally; call it yourself for
30
+ * anything else the SDK offers (`client.events.identify`, `session.getAnonymousId()`, ...). */
31
+ export function useLikyly() {
32
+ const value = useContext(LikylyContext);
33
+ if (!value) {
34
+ throw new Error("useLikyly (and useRecommendations / <LikylyRecommendations>) must be used inside <LikylyProvider>");
35
+ }
36
+ return value;
37
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ import { useEffect, useRef, useSyncExternalStore } from "react";
3
+ import { RecommendationsController } from "./controller.js";
4
+ import { useLikyly } from "./provider.js";
5
+ /**
6
+ * Headless: fetches recommendations for one placement and returns the data plus tracking
7
+ * helpers - you render 100% of the markup. See `<LikylyRecommendations>` for a ready-to-use
8
+ * component that also auto-tracks impressions/clicks.
9
+ *
10
+ * ```tsx
11
+ * const { items, isLoading, trackClick } = useRecommendations({
12
+ * placement: "pdp-related",
13
+ * context: { itemId: product.id, userId: user?.id, sessionId },
14
+ * });
15
+ * ```
16
+ */
17
+ export function useRecommendations(opts) {
18
+ const { client } = useLikyly();
19
+ const controllerRef = useRef(null);
20
+ if (!controllerRef.current)
21
+ controllerRef.current = new RecommendationsController(client);
22
+ const controller = controllerRef.current;
23
+ // Third argument (getServerSnapshot) is required for SSR/SSG (Next.js prerendering, ...) -
24
+ // without it React throws ("Missing getServerSnapshot") the moment this hook is reached
25
+ // during a server render. Safe to reuse the same getSnapshot here: run() only ever executes
26
+ // from the effect below, which never runs during a server render, so the controller's state
27
+ // is still the untouched initial one (a stable reference) at that point.
28
+ const state = useSyncExternalStore((onStoreChange) => controller.subscribe(onStoreChange), () => controller.getState(), () => controller.getState());
29
+ // Compared by value, not identity: `context` is a fresh object literal on most callers' every
30
+ // render, and re-fetching on every render (rather than on an actual change) would defeat the
31
+ // point of the hook.
32
+ const contextKey = JSON.stringify(opts.context ?? {});
33
+ const runCurrent = () => void controller.run(opts);
34
+ useEffect(() => {
35
+ runCurrent();
36
+ // contextKey stands in for opts.context; opts.placement/limit/enabled are the other real
37
+ // inputs - this intentionally does not depend on the `opts` object identity itself.
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [controller, opts.placement, contextKey, opts.limit, opts.enabled]);
40
+ return {
41
+ ...state,
42
+ refetch: runCurrent,
43
+ trackImpression: (itemId) => controller.trackImpression(itemId),
44
+ trackClick: (itemId) => controller.trackClick(itemId),
45
+ };
46
+ }
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { RecommendationsController } from "../src/controller.js";
4
+ function fakeLikyly(recommendImpl) {
5
+ const eventCalls = [];
6
+ const client = {
7
+ recommend: (call) => recommendImpl(call),
8
+ events: {
9
+ recommendationImpression: async (input) => {
10
+ eventCalls.push({ method: "recommendationImpression", input });
11
+ return { message: "ok", duplicate: false };
12
+ },
13
+ recommendationClick: async (input) => {
14
+ eventCalls.push({ method: "recommendationClick", input });
15
+ return { message: "ok", duplicate: false };
16
+ },
17
+ },
18
+ };
19
+ return { client, eventCalls };
20
+ }
21
+ const RESULT = { recommendationId: "rec_1", placement: "pdp-related", strategyUsed: "content", items: [{ itemId: "SKU-1", properties: {} }] };
22
+ describe("RecommendationsController", () => {
23
+ it("a new product never shows the previous product's items while loading; a refetch keeps them", async () => {
24
+ const { client } = fakeLikyly(async () => RESULT);
25
+ const controller = new RecommendationsController(client);
26
+ await controller.run({ placement: "pdp-related", context: { itemId: "SKU-1" } });
27
+ assert.equal(controller.getState().items.length, 1);
28
+ const next = controller.run({ placement: "pdp-related", context: { itemId: "SKU-2" } });
29
+ assert.deepEqual(controller.getState().items, [], "cleared as soon as the product changes");
30
+ assert.equal(controller.getState().recommendationId, undefined);
31
+ await next;
32
+ const again = controller.run({ placement: "pdp-related", context: { itemId: "SKU-2" } });
33
+ assert.equal(controller.getState().items.length, 1, "same request: kept while it refreshes");
34
+ await again;
35
+ });
36
+ it("starts idle, then loading, then resolved", async () => {
37
+ const { client } = fakeLikyly(async () => RESULT);
38
+ const controller = new RecommendationsController(client);
39
+ assert.deepEqual(controller.getState(), { items: [], isLoading: false });
40
+ const pending = controller.run({ placement: "pdp-related", context: { itemId: "SKU-1" } });
41
+ assert.equal(controller.getState().isLoading, true);
42
+ await pending;
43
+ assert.equal(controller.getState().isLoading, false);
44
+ assert.equal(controller.getState().strategyUsed, "content");
45
+ assert.equal(controller.getState().recommendationId, "rec_1");
46
+ assert.equal(controller.getState().items.length, 1);
47
+ });
48
+ it("notifies subscribers on every state change", async () => {
49
+ const { client } = fakeLikyly(async () => RESULT);
50
+ const controller = new RecommendationsController(client);
51
+ let notifications = 0;
52
+ const unsubscribe = controller.subscribe(() => (notifications += 1));
53
+ await controller.run({ placement: "pdp-related" });
54
+ assert.ok(notifications >= 2); // at least: loading=true, then resolved
55
+ unsubscribe();
56
+ const before = notifications;
57
+ await controller.run({ placement: "pdp-related" });
58
+ assert.equal(notifications, before); // unsubscribed - no more notifications
59
+ });
60
+ it("enabled: false resets to idle and never calls recommend", async () => {
61
+ let calls = 0;
62
+ const { client } = fakeLikyly(async () => {
63
+ calls += 1;
64
+ return RESULT;
65
+ });
66
+ const controller = new RecommendationsController(client);
67
+ await controller.run({ placement: "pdp-related", enabled: false });
68
+ assert.equal(calls, 0);
69
+ assert.deepEqual(controller.getState(), { items: [], isLoading: false });
70
+ });
71
+ it("a superseded call's response is dropped, not applied", async () => {
72
+ const resolvers = [];
73
+ const { client } = fakeLikyly(() => new Promise((resolve) => resolvers.push(resolve)));
74
+ const controller = new RecommendationsController(client);
75
+ const first = controller.run({ placement: "pdp-related", context: { itemId: "SKU-1" } });
76
+ const second = controller.run({ placement: "pdp-related", context: { itemId: "SKU-2" } });
77
+ resolvers[1]({ ...RESULT, recommendationId: "rec_second" });
78
+ await second;
79
+ resolvers[0]({ ...RESULT, recommendationId: "rec_first" });
80
+ await first;
81
+ assert.equal(controller.getState().recommendationId, "rec_second"); // the first (stale) response never overwrote it
82
+ });
83
+ it("a rejected recommend() call surfaces as state.error, not a thrown exception", async () => {
84
+ const { client } = fakeLikyly(async () => {
85
+ throw new Error("network down");
86
+ });
87
+ const controller = new RecommendationsController(client);
88
+ await controller.run({ placement: "pdp-related" });
89
+ assert.equal(controller.getState().error?.message, "network down");
90
+ assert.equal(controller.getState().isLoading, false);
91
+ });
92
+ it("trackImpression/trackClick send the right event, attributed to the last recommend() call", async () => {
93
+ const { client, eventCalls } = fakeLikyly(async () => RESULT);
94
+ const controller = new RecommendationsController(client);
95
+ await controller.run({ placement: "pdp-related", context: { itemId: "SKU-1", sessionId: "sess_1" } });
96
+ controller.trackImpression("SKU-1");
97
+ controller.trackClick("SKU-1");
98
+ assert.deepEqual(eventCalls, [
99
+ { method: "recommendationImpression", input: { itemId: "SKU-1", recommendationId: "rec_1", placement: "pdp-related", userId: undefined, sessionId: "sess_1" } },
100
+ { method: "recommendationClick", input: { itemId: "SKU-1", recommendationId: "rec_1", placement: "pdp-related", userId: undefined, sessionId: "sess_1" } },
101
+ ]);
102
+ });
103
+ it("tracking is a no-op before any result has arrived (nothing to attribute to yet)", async () => {
104
+ const { client, eventCalls } = fakeLikyly(async () => RESULT);
105
+ const controller = new RecommendationsController(client);
106
+ controller.trackImpression("SKU-1");
107
+ assert.equal(eventCalls.length, 0);
108
+ });
109
+ it("tracking is a no-op with neither a userId nor a sessionId in the context", async () => {
110
+ const { client, eventCalls } = fakeLikyly(async () => RESULT);
111
+ const controller = new RecommendationsController(client);
112
+ await controller.run({ placement: "pdp-related", context: { itemId: "SKU-1" } }); // no user/session
113
+ controller.trackClick("SKU-1");
114
+ assert.equal(eventCalls.length, 0);
115
+ });
116
+ it("anonymousId in the context is used as the session for attribution when sessionId is absent", async () => {
117
+ const { client, eventCalls } = fakeLikyly(async () => RESULT);
118
+ const controller = new RecommendationsController(client);
119
+ await controller.run({ placement: "pdp-related", context: { itemId: "SKU-1", anonymousId: "anon_1" } });
120
+ controller.trackClick("SKU-1");
121
+ assert.equal(eventCalls[0].input.sessionId, "anon_1");
122
+ });
123
+ });
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import assert from "node:assert/strict";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { describe, it } from "node:test";
5
+ import { LikylyProvider, LikylyRecommendations, useRecommendations } from "../src/index.js";
6
+ // Regression test for a real bug found while integrating @likyly/react into a Next.js app
7
+ // (examples/demo-storefront): `useSyncExternalStore` without a third (getServerSnapshot)
8
+ // argument throws "Missing getServerSnapshot" the moment a page using this hook is
9
+ // server-rendered/statically generated - `next dev` alone never catches this, since it only
10
+ // ever renders these components client-side; `next build`'s static generation does.
11
+ describe("server-side rendering", () => {
12
+ it("useRecommendations does not throw during a server render", () => {
13
+ function Probe() {
14
+ const { isLoading, items } = useRecommendations({ placement: "pdp-related", context: { itemId: "SKU-1" } });
15
+ return _jsx("div", { children: `${isLoading} ${items.length}` });
16
+ }
17
+ const html = renderToStaticMarkup(_jsx(LikylyProvider, { apiKey: "pk_test_ssr", children: _jsx(Probe, {}) }));
18
+ assert.match(html, /<div>/);
19
+ });
20
+ it("<LikylyRecommendations> does not throw during a server render", () => {
21
+ const html = renderToStaticMarkup(_jsx(LikylyProvider, { apiKey: "pk_test_ssr", children: _jsx(LikylyRecommendations, { placement: "pdp-related", itemId: "SKU-1" }) }));
22
+ assert.equal(typeof html, "string"); // isLoading, 0 items on first render -> renders its loadingFallback (null)
23
+ });
24
+ });
@@ -0,0 +1,34 @@
1
+ import type { PlacementContext } from "@likyly/sdk";
2
+ import { type RecommendationsState } from "./controller.js";
3
+ export interface UseRecommendationsOptions {
4
+ /** The placement's slug, e.g. "pdp-related" - configure it once via the LIKYLY MCP admin
5
+ * tools or dashboard, not from this hook. */
6
+ placement: string;
7
+ context?: PlacementContext;
8
+ /** Overrides the placement's own configured limit for this call. */
9
+ limit?: number;
10
+ /** Skip fetching - e.g. while a signal the placement needs (itemId, userId) isn't known yet. Default true. */
11
+ enabled?: boolean;
12
+ }
13
+ export interface UseRecommendationsResult extends RecommendationsState {
14
+ /** Re-runs the same request (e.g. a manual "refresh" action). */
15
+ refetch: () => void;
16
+ /** Fire `recommendationImpression` for one of the returned items - manual here, since a
17
+ * headless hook has no DOM for LIKYLY to observe; `<LikylyRecommendations>` does this for you. */
18
+ trackImpression: (itemId: string) => void;
19
+ /** Fire `recommendationClick` for one of the returned items. */
20
+ trackClick: (itemId: string) => void;
21
+ }
22
+ /**
23
+ * Headless: fetches recommendations for one placement and returns the data plus tracking
24
+ * helpers - you render 100% of the markup. See `<LikylyRecommendations>` for a ready-to-use
25
+ * component that also auto-tracks impressions/clicks.
26
+ *
27
+ * ```tsx
28
+ * const { items, isLoading, trackClick } = useRecommendations({
29
+ * placement: "pdp-related",
30
+ * context: { itemId: product.id, userId: user?.id, sessionId },
31
+ * });
32
+ * ```
33
+ */
34
+ export declare function useRecommendations(opts: UseRecommendationsOptions): UseRecommendationsResult;
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ import { useEffect, useRef, useSyncExternalStore } from "react";
3
+ import { RecommendationsController } from "./controller.js";
4
+ import { useLikyly } from "./provider.js";
5
+ /**
6
+ * Headless: fetches recommendations for one placement and returns the data plus tracking
7
+ * helpers - you render 100% of the markup. See `<LikylyRecommendations>` for a ready-to-use
8
+ * component that also auto-tracks impressions/clicks.
9
+ *
10
+ * ```tsx
11
+ * const { items, isLoading, trackClick } = useRecommendations({
12
+ * placement: "pdp-related",
13
+ * context: { itemId: product.id, userId: user?.id, sessionId },
14
+ * });
15
+ * ```
16
+ */
17
+ export function useRecommendations(opts) {
18
+ const { client } = useLikyly();
19
+ const controllerRef = useRef(null);
20
+ if (!controllerRef.current)
21
+ controllerRef.current = new RecommendationsController(client);
22
+ const controller = controllerRef.current;
23
+ // Third argument (getServerSnapshot) is required for SSR/SSG (Next.js prerendering, ...) -
24
+ // without it React throws ("Missing getServerSnapshot") the moment this hook is reached
25
+ // during a server render. Safe to reuse the same getSnapshot here: run() only ever executes
26
+ // from the effect below, which never runs during a server render, so the controller's state
27
+ // is still the untouched initial one (a stable reference) at that point.
28
+ const state = useSyncExternalStore((onStoreChange) => controller.subscribe(onStoreChange), () => controller.getState(), () => controller.getState());
29
+ // Compared by value, not identity: `context` is a fresh object literal on most callers' every
30
+ // render, and re-fetching on every render (rather than on an actual change) would defeat the
31
+ // point of the hook.
32
+ const contextKey = JSON.stringify(opts.context ?? {});
33
+ const runCurrent = () => void controller.run(opts);
34
+ useEffect(() => {
35
+ runCurrent();
36
+ // contextKey stands in for opts.context; opts.placement/limit/enabled are the other real
37
+ // inputs - this intentionally does not depend on the `opts` object identity itself.
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [controller, opts.placement, contextKey, opts.limit, opts.enabled]);
40
+ return {
41
+ ...state,
42
+ refetch: runCurrent,
43
+ trackImpression: (itemId) => controller.trackImpression(itemId),
44
+ trackClick: (itemId) => controller.trackClick(itemId),
45
+ };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@likyly/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "React bindings for @likyly/sdk: useRecommendations (headless) and <LikylyRecommendations> (ready-to-use) for LIKYLY placements.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "build": "rm -rf dist && tsc -p tsconfig.json",
27
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
28
+ "test": "rm -rf dist/test && tsc -p tsconfig.test.json && node --test \"dist/test/test/*.test.js\""
29
+ },
30
+ "dependencies": {
31
+ "@likyly/sdk": "^1.1.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react": "^18.0.0 || ^19.0.0",
35
+ "react-dom": "^18.0.0 || ^19.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "@types/react": "^18.3.0",
40
+ "@types/react-dom": "^18.3.0",
41
+ "react": "^18.3.0",
42
+ "react-dom": "^18.3.0",
43
+ "typescript": "^5.5.4"
44
+ }
45
+ }