@likyly/react 0.0.0-stage → 0.2.1

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,106 @@
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
+ Show LIKYLY recommendations in a React app, with the impressions and clicks LIKYLY learns from
4
+ tracked for you.
5
+
6
+ The fastest way to set it up is the wizard, which installs this package, writes your public key
7
+ and connects your AI coding assistant:
8
+
9
+ ```sh
10
+ npx @likyly/wizard
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install @likyly/react
17
+ ```
18
+
19
+ React 18 or 19. Works with Next.js (App Router included), Vite, Remix and others.
20
+
21
+ ## Quick start
22
+
23
+ ```tsx
24
+ import { LikylyProvider, LikylyRecommendations, useLikyly } from "@likyly/react";
25
+
26
+ export function App() {
27
+ return (
28
+ <LikylyProvider apiKey={process.env.NEXT_PUBLIC_LIKYLY_KEY!}>
29
+ <ProductPage />
30
+ </LikylyProvider>
31
+ );
32
+ }
33
+
34
+ function ProductPage({ product, user }) {
35
+ const { session } = useLikyly();
36
+ return (
37
+ <LikylyRecommendations placement="pdp-related" itemId={product.id} limit={4}
38
+ userId={user?.id} sessionId={session.getAnonymousId()} />
39
+ );
40
+ }
41
+ ```
42
+
43
+ - `apiKey` is your **public key** (`pk_…`). A secret or developer key is refused in the browser.
44
+ - `pdp-related` is a placement you create once in LIKYLY (console or AI assistant), not in code.
45
+ - `sessionId` is the visitor's anonymous id, kept in the browser (first-party, no cookie). Pass it
46
+ so visitors who are not signed in count too.
47
+
48
+ ## Your own markup
49
+
50
+ Keep your own cards; impressions and clicks are still tracked:
51
+
52
+ ```tsx
53
+ <LikylyRecommendations
54
+ placement="pdp-related"
55
+ itemId={product.id}
56
+ renderItem={(item, { onClick }) => <ProductCard product={item} onClick={onClick} />}
57
+ />
58
+ ```
59
+
60
+ Or take full control with the hook, and report what was seen and clicked:
61
+
62
+ ```tsx
63
+ import { useLikyly, useRecommendations } from "@likyly/react";
64
+
65
+ function Related({ product }) {
66
+ const { session } = useLikyly();
67
+ const { items, isLoading, trackImpression, trackClick } = useRecommendations({
68
+ placement: "pdp-related",
69
+ context: { itemId: product.id, sessionId: session.getAnonymousId() },
70
+ limit: 4,
71
+ });
72
+ if (isLoading) return null;
73
+ return items.map((item) => (
74
+ <ProductCard key={item.itemId} product={item}
75
+ onVisible={() => trackImpression(item.itemId)} onClick={() => trackClick(item.itemId)} />
76
+ ));
77
+ }
78
+ ```
79
+
80
+ ## Other events
81
+
82
+ Product views, add-to-cart and purchases happen in your own code, so send them from there:
83
+
84
+ ```ts
85
+ const { client } = useLikyly();
86
+ await client.events.productView({ itemId: product.id, userId: user?.id });
87
+ await client.events.addToCart({ itemId: product.id, userId: user?.id, quantity: 1 });
88
+ await client.events.purchase({ eventId: `purchase_${order.id}_${item.id}`, itemId: item.id, userId: user?.id,
89
+ quantity: item.quantity, properties: { price: item.price, currency: "EUR" } });
90
+ ```
91
+
92
+ Set `eventId` on purchases: a retried purchase is then never counted twice.
93
+
94
+ ## Sign-in
95
+
96
+ At sign-in, attach what the visitor did anonymously to their account:
97
+
98
+ ```tsx
99
+ const { client, session } = useLikyly();
100
+ await session.identify(client, user.id);
101
+ ```
102
+
103
+ ## Links
104
+
105
+ - [`@likyly/sdk`](https://www.npmjs.com/package/@likyly/sdk): the JavaScript client this package is built on
106
+ - [likyly.com](https://likyly.com)
@@ -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.1",
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
+ }