@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 +179 -2
- package/dist/controller.d.ts +46 -0
- package/dist/controller.js +79 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/likyly-recommendations.d.ts +45 -0
- package/dist/likyly-recommendations.js +62 -0
- package/dist/provider.d.ts +28 -0
- package/dist/provider.js +37 -0
- package/dist/test/src/controller.js +79 -0
- package/dist/test/src/index.js +4 -0
- package/dist/test/src/likyly-recommendations.js +62 -0
- package/dist/test/src/provider.js +37 -0
- package/dist/test/src/use-recommendations.js +46 -0
- package/dist/test/test/controller.test.js +123 -0
- package/dist/test/test/ssr.test.js +24 -0
- package/dist/use-recommendations.d.ts +34 -0
- package/dist/use-recommendations.js +46 -0
- package/package.json +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,180 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @likyly/react
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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;
|
package/dist/provider.js
ADDED
|
@@ -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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|