@nosto/nosto-react 0.3.0 → 0.4.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.
@@ -1,24 +1,68 @@
1
1
  import React, { useEffect, isValidElement, useState, useRef } from "react";
2
2
  import { NostoContext } from "./context.client";
3
3
  import { createRoot } from "react-dom/client";
4
+ import { Recommendation } from "../../types";
4
5
 
5
- interface NostoProviderProps {
6
+ /**
7
+ * This widget is what we call the Nosto root widget, which is responsible for adding the actual Nosto script and the JS API stub.
8
+ * This widget wraps all other React Nosto widgets.
9
+ *
10
+ * ```
11
+ * <NostoProvider account="your-nosto-account-id" recommendationComponent={<NostoSlot />}>
12
+ * <App />
13
+ * </NostoProvider>
14
+ * ```
15
+ *
16
+ * **Note:** the component also accepts a prop to configure the host `host="connect.nosto.com"`.
17
+ * In advanced use-cases, the need to configure the host may surface.
18
+ *
19
+ * In order to implement client-side rendering, the requires a designated component to render the recommendations provided by Nosto.
20
+ * This component should be capable of processing the JSON response received from our backend.
21
+ * Notice the `recommendationComponent` prop passed to `<NostoProvider>` above.
22
+ *
23
+ * Learn more [here](https://github.com/Nosto/shopify-hydrogen/blob/main/README.md#client-side-rendering-for-recommendations) and see a [live example](https://github.com/Nosto/shopify-hydrogen-demo) on our demo store.
24
+ *
25
+ * @group Essential Functions
26
+ */
27
+ export default function NostoProvider(props: {
28
+ /**
29
+ * Indicates merchant id
30
+ */
6
31
  account: string;
7
- currentVariation: string;
8
- host: string;
32
+ /**
33
+ * Indicates currency
34
+ */
35
+ currentVariation?: string;
36
+ /**
37
+ * Indicates an url of a server
38
+ */
39
+ host?: string;
9
40
  children: React.ReactElement;
10
- multiCurrency: boolean;
41
+ /**
42
+ * Indicates if merchant uses multiple currencies
43
+ */
44
+ multiCurrency?: boolean;
45
+ /**
46
+ * Recommendation component which holds nostoRecommendation object
47
+ */
11
48
  recommendationComponent?: any;
12
- }
13
-
14
- const NostoProvider: React.FC<NostoProviderProps> = ({
15
- account,
16
- currentVariation = "",
17
- multiCurrency = false,
18
- host,
19
- children,
20
- recommendationComponent,
21
- }) => {
49
+ /**
50
+ * Enables Shopify markets with language and market id
51
+ */
52
+ shopifyMarkets?: {
53
+ language?: string;
54
+ marketId?: string | number;
55
+ }
56
+ }): JSX.Element {
57
+ let {
58
+ account,
59
+ currentVariation = "",
60
+ multiCurrency = false,
61
+ host,
62
+ children,
63
+ recommendationComponent,
64
+ shopifyMarkets
65
+ } = props;
22
66
  const [clientScriptLoadedState, setClientScriptLoadedState] =
23
67
  React.useState(false);
24
68
  const clientScriptLoaded = React.useMemo(
@@ -35,8 +79,10 @@ const NostoProvider: React.FC<NostoProviderProps> = ({
35
79
  : "HTML";
36
80
 
37
81
  // RecommendationComponent for client-side rendering:
38
- function RecommendationComponentWrapper(props: any) {
39
- return React.cloneElement(recommendationComponent, {
82
+ function RecommendationComponentWrapper(props: {
83
+ nostoRecommendation: Recommendation;
84
+ }) {
85
+ return React.cloneElement(recommendationComponent!, {
40
86
  nostoRecommendation: props.nostoRecommendation,
41
87
  });
42
88
  }
@@ -53,7 +99,21 @@ const NostoProvider: React.FC<NostoProviderProps> = ({
53
99
 
54
100
  const pageTypeUpdated = type == pageType;
55
101
 
56
- function renderCampaigns(data: any, api: any) {
102
+ function renderCampaigns(
103
+ data: {
104
+ recommendations: any;
105
+ campaigns: {
106
+ recommendations: {
107
+ [key: string]: any;
108
+ };
109
+ };
110
+ },
111
+ api: {
112
+ placements: {
113
+ injectCampaigns: (recommendations: any) => void;
114
+ };
115
+ }
116
+ ) {
57
117
  if (responseMode == "HTML") {
58
118
  // inject content campaigns as usual:
59
119
  api.placements.injectCampaigns(data.recommendations);
@@ -65,6 +125,7 @@ const NostoProvider: React.FC<NostoProviderProps> = ({
65
125
  let placementSelector = "#" + key;
66
126
  let placement: Function = () =>
67
127
  document.querySelector(placementSelector);
128
+
68
129
  if (!!placement()) {
69
130
  if (!placementRefs.current[key])
70
131
  placementRefs.current[key] = createRoot(placement());
@@ -81,25 +142,67 @@ const NostoProvider: React.FC<NostoProviderProps> = ({
81
142
  return { renderCampaigns, pageTypeUpdated };
82
143
  };
83
144
 
145
+
84
146
  useEffect(() => {
85
- if (!document.querySelectorAll("[nosto-client-script]").length) {
147
+ if (!window.nostojs) {
148
+ window.nostojs = (cb: Function) => {
149
+ (window.nostojs.q = window.nostojs.q || []).push(cb);
150
+ };
151
+ window.nostojs((api) => api.setAutoLoad(false));
152
+ }
153
+
154
+ if (!document.querySelectorAll("[nosto-client-script]").length && !shopifyMarkets) {
86
155
  const script = document.createElement("script");
87
156
  script.type = "text/javascript";
88
157
  script.src = "//" + (host || "connect.nosto.com") + "/include/" + account;
89
158
  script.async = true;
90
159
  script.setAttribute("nosto-client-script", "");
160
+
91
161
  script.onload = () => {
92
- console.log("Nosto client script loaded");
162
+ if (typeof jest !== "undefined") {
163
+ window.nosto?.reload({
164
+ site: "localhost",
165
+ });
166
+ }
93
167
  setClientScriptLoadedState(true);
94
168
  };
95
- document.head.appendChild(script);
169
+ document.body.appendChild(script);
96
170
  }
97
171
 
98
- window.nostojs = (cb: Function) =>
99
- (window.nostojs.q = window.nostojs.q || []).push(cb);
100
- // @ts-ignore
101
- window.nostojs((api) => api.setAutoLoad(false));
102
- }, []);
172
+ //Enable Shopify markets functionality:
173
+ if (!!shopifyMarkets) {
174
+
175
+ const existingScript = document.querySelector("[nosto-client-script]");
176
+ const nostoSandbox = document.querySelector('#nosto-sandbox');
177
+
178
+ if (!existingScript || existingScript?.getAttribute('nosto-language') != shopifyMarkets?.language || existingScript?.getAttribute('nosto-market-id') != shopifyMarkets?.marketId) {
179
+ if (clientScriptLoadedState) { setClientScriptLoadedState(false) };
180
+
181
+ existingScript?.parentNode?.removeChild(existingScript)
182
+ nostoSandbox?.parentNode?.removeChild(nostoSandbox)
183
+
184
+ const script = document.createElement("script");
185
+ script.type = "text/javascript";
186
+ script.src = "//" + (host || "connect.nosto.com") + `/script/shopify/market/nosto.js?merchant=${account}&market=${shopifyMarkets.marketId || ''}&locale=${shopifyMarkets?.language?.toLowerCase() || ''}`
187
+ script.async = true;
188
+ script.setAttribute("nosto-client-script", "");
189
+ script.setAttribute("nosto-language", shopifyMarkets?.language || '');
190
+ script.setAttribute("nosto-market-id", String(shopifyMarkets?.marketId));
191
+
192
+ script.onload = () => {
193
+ if (typeof jest !== "undefined") {
194
+ window.nosto?.reload({
195
+ site: "localhost",
196
+ });
197
+ }
198
+ setClientScriptLoadedState(true);
199
+ };
200
+ document.body.appendChild(script);
201
+ }
202
+
203
+ }
204
+
205
+ }, [clientScriptLoadedState, shopifyMarkets]);
103
206
 
104
207
  return (
105
208
  <NostoContext.Provider
@@ -116,6 +219,4 @@ const NostoProvider: React.FC<NostoProviderProps> = ({
116
219
  {children}
117
220
  </NostoContext.Provider>
118
221
  );
119
- };
120
-
121
- export default NostoProvider;
222
+ }
@@ -1,7 +1,31 @@
1
- import React, { useEffect } from "react";
1
+ import { useEffect } from "react";
2
2
  import { useNostoContext } from "../Provider/context.client";
3
3
 
4
- const NostoSearch: React.FC<{ query: string }> = ({ query }) => {
4
+ /**
5
+ * You can personalise your search pages by using the NostoSearch component.
6
+ * The component requires that you provide it the current search term.
7
+ *
8
+ * By default, your account, when created, has two search-page placements named `searchpage-nosto-1` and `searchpage-nosto-2`.
9
+ * You may omit these and use any identifier you need. The identifiers used here are simply provided to illustrate the example.
10
+ *
11
+ * @example
12
+ * ```
13
+ * <div className="search-page">
14
+ * <NostoPlacement id="searchpage-nosto-1" />
15
+ * <NostoPlacement id="searchpage-nosto-2" />
16
+ * <NostoSearch query={"black shoes"} />
17
+ * </div>
18
+ * ```
19
+ *
20
+ * **Note:** Do not encode the search term in any way.
21
+ * It should be provided an unencoded string.
22
+ * A query for `black shoes` must be provided as-is and not as `black+shoes`.
23
+ * Doing so will lead to invalid results.
24
+ *
25
+ * @group Personalisation Components
26
+ */
27
+ export default function NostoSearch(props: { query: string }): JSX.Element {
28
+ const { query } = props;
5
29
  const {
6
30
  clientScriptLoaded,
7
31
  currentVariation,
@@ -13,9 +37,8 @@ const NostoSearch: React.FC<{ query: string }> = ({ query }) => {
13
37
  const { renderCampaigns, pageTypeUpdated } = useRenderCampaigns("search");
14
38
 
15
39
  useEffect(() => {
16
- // @ts-ignore
17
40
  if (clientScriptLoaded && pageTypeUpdated) {
18
- window.nostojs((api: any) => {
41
+ window.nostojs((api) => {
19
42
  api
20
43
  .defaultSession()
21
44
  .setVariation(currentVariation)
@@ -23,7 +46,7 @@ const NostoSearch: React.FC<{ query: string }> = ({ query }) => {
23
46
  .viewSearch(query)
24
47
  .setPlacements(api.placements.getPlacements())
25
48
  .load()
26
- .then((data: object) => {
49
+ .then((data) => {
27
50
  renderCampaigns(data, api);
28
51
  });
29
52
  });
@@ -46,6 +69,4 @@ const NostoSearch: React.FC<{ query: string }> = ({ query }) => {
46
69
  </div>
47
70
  </>
48
71
  );
49
- };
50
-
51
- export default NostoSearch;
72
+ }
@@ -1,24 +1,31 @@
1
- import React from "react";
2
- import snakeize from "snakeize";
3
1
  import { useNostoContext } from "../Provider/context.client";
4
-
5
- import useDeepCompareEffect from "use-deep-compare-effect";
6
2
  import { Cart, Customer } from "../../types";
3
+ import { snakeize } from "../../utils/snakeize";
4
+ import { useDeepCompareEffect } from "../../utils/hooks";
7
5
 
8
- interface NostoSessionProps {
6
+ /**
7
+ * Nosto React requires that you pass it the details of current cart contents and the details of the currently logged-in customer, if any, on every route change.
8
+ * This makes it easier to add attribution.
9
+ *
10
+ * The `NostoSession` component makes it very easy to keep the session up to date so long as the cart and the customer are provided.
11
+ *
12
+ * The cart prop requires a value that adheres to the type `Cart`, while the customer prop requires a value that adheres to the type `Customer`.
13
+ *
14
+ * @group Essential Functions
15
+ */
16
+ export default function NostoSession(props: {
9
17
  cart: Cart;
10
18
  customer: Customer;
11
- }
12
-
13
- const NostoSession: React.FC<NostoSessionProps> = ({ cart, customer }) => {
19
+ }): JSX.Element {
20
+ const { cart, customer } = props;
14
21
  const { clientScriptLoaded } = useNostoContext();
22
+
15
23
  useDeepCompareEffect(() => {
16
24
  const currentCart = cart ? snakeize(cart) : undefined;
17
25
  const currentCustomer = customer ? snakeize(customer) : undefined;
18
26
 
19
- // @ts-ignore
20
27
  if (clientScriptLoaded) {
21
- window.nostojs((api: any) => {
28
+ window.nostojs((api) => {
22
29
  api
23
30
  .defaultSession()
24
31
  .setResponseMode("HTML")
@@ -28,9 +35,7 @@ const NostoSession: React.FC<NostoSessionProps> = ({ cart, customer }) => {
28
35
  .load();
29
36
  });
30
37
  }
31
- }, [clientScriptLoaded, cart || [], customer || {}]);
38
+ }, [clientScriptLoaded, cart, customer]);
32
39
 
33
40
  return <></>;
34
- };
35
-
36
- export default NostoSession;
41
+ }
@@ -1,12 +1,6 @@
1
- declare global {
2
- // noinspection JSUnusedGlobalSymbols
3
- interface Window {
4
- nostojs: any;
5
- nosto: any;
6
- }
7
- }
8
-
9
- export * from "./types";
1
+ export type {
2
+ Buyer, Cart, Customer, Item, Product, Purchase, Recommendation, SKU
3
+ } from "./types";
10
4
  // noinspection JSUnusedGlobalSymbols
11
5
  export { default as Nosto404 } from "./components/Fohofo/index.client";
12
6
  // noinspection JSUnusedGlobalSymbols
@@ -32,5 +26,8 @@ export {
32
26
  NostoContext,
33
27
  useNostoContext,
34
28
  } from "./components/Provider/context.client";
29
+ export type {
30
+ NostoContextType,
31
+ } from "./components/Provider/context.client";
35
32
  // noinspection JSUnusedGlobalSymbols
36
- export { default as NostoSession } from "./components/Session/index.client";
33
+ export { default as NostoSession } from "./components/Session/index.client";
package/src/types.ts CHANGED
@@ -1,3 +1,67 @@
1
+ declare global {
2
+ interface Window {
3
+ nosto?: {
4
+ reload(settings: unknown): void;
5
+ };
6
+ nostojs: {
7
+ (callback: (api: NostoClient) => void): void;
8
+ q?: unknown[];
9
+ };
10
+ }
11
+ }
12
+
13
+ /**
14
+ * @group Types
15
+ */
16
+ export interface NostoClient {
17
+ addOrder(order: { purchase: Purchase }): NostoClient;
18
+ defaultSession(): NostoClient;
19
+ setAutoLoad(autoload: boolean): NostoClient;
20
+ setCart(cart?: Cart): NostoClient;
21
+ setCustomer(customer?: Customer): NostoClient;
22
+ setPlacements(placements: string[]): NostoClient;
23
+ setResponseMode(mode: string): NostoClient;
24
+ setVariation(variation: string): NostoClient;
25
+ viewCategory(category: string): NostoClient;
26
+ viewProduct(product: string): NostoClient;
27
+ viewFrontPage(): NostoClient;
28
+ viewNotFound(): NostoClient;
29
+ viewOther(): NostoClient;
30
+ viewSearch(query: string): NostoClient;
31
+ viewCart(): NostoClient;
32
+ load(): Promise<{
33
+ affinities: Record<
34
+ string,
35
+ {
36
+ name: string;
37
+ score: number;
38
+ }[]
39
+ >;
40
+ geo_location?: string[];
41
+ page_views: number;
42
+ recommendations: Recommendation[];
43
+ }>;
44
+ placements: {
45
+ getPlacements(): string[];
46
+ };
47
+ }
48
+
49
+ /**
50
+ * @group Types
51
+ */
52
+ export interface Recommendation {
53
+ result_id: string;
54
+ products: Product[];
55
+ result_type: string;
56
+ title: string;
57
+ div_id: string;
58
+ source_product_ids: string[];
59
+ params: unknown;
60
+ }
61
+
62
+ /**
63
+ * @group Types
64
+ */
1
65
  export interface Item {
2
66
  name: string;
3
67
  price_currency_code: string;
@@ -7,10 +71,16 @@ export interface Item {
7
71
  unit_price: number;
8
72
  }
9
73
 
74
+ /**
75
+ * @group Types
76
+ */
10
77
  export interface Cart {
11
78
  items: Item[];
12
79
  }
13
80
 
81
+ /**
82
+ * @group Types
83
+ */
14
84
  export interface Customer {
15
85
  customer_reference: string;
16
86
  email: string;
@@ -19,6 +89,9 @@ export interface Customer {
19
89
  newsletter: boolean;
20
90
  }
21
91
 
92
+ /**
93
+ * @group Types
94
+ */
22
95
  export interface Buyer {
23
96
  first_name: string;
24
97
  last_name: string;
@@ -27,12 +100,18 @@ export interface Buyer {
27
100
  newsletter: boolean;
28
101
  }
29
102
 
103
+ /**
104
+ * @group Types
105
+ */
30
106
  export interface Purchase {
31
107
  number: string;
32
108
  info: Buyer;
33
109
  items: Item[];
34
110
  }
35
111
 
112
+ /**
113
+ * @group Types
114
+ */
36
115
  export interface SKU {
37
116
  id: string;
38
117
  name: string;
@@ -41,13 +120,16 @@ export interface SKU {
41
120
  url: URL;
42
121
  imageUrl: URL;
43
122
  gtin?: string;
44
- availability: 'InStock' | 'OutOfStock';
123
+ availability: "InStock" | "OutOfStock";
45
124
  customFields?: { [key: string]: string };
46
125
  }
47
126
 
127
+ /**
128
+ * @group Types
129
+ */
48
130
  export interface Product {
49
131
  alternateImageUrls?: URL[];
50
- availability: 'InStock' | 'OutOfStock';
132
+ availability: "InStock" | "OutOfStock";
51
133
  brand?: string;
52
134
  category: string[];
53
135
  categoryIds?: string[];
@@ -0,0 +1,30 @@
1
+ import { isPlainObject } from "./object";
2
+
3
+ export function deepCompare(a: unknown, b: unknown): boolean {
4
+ if (a === b) {
5
+ return true;
6
+ }
7
+
8
+ if (a instanceof Date && b instanceof Date) {
9
+ return a.getTime() === b.getTime();
10
+ }
11
+
12
+ if (a instanceof Array && b instanceof Array) {
13
+ if (a.length !== b.length) {
14
+ return false;
15
+ }
16
+
17
+ return a.every((v, i) => deepCompare(v, b[i]));
18
+ }
19
+
20
+ if (isPlainObject(a) && isPlainObject(b)) {
21
+ const entriesA = Object.entries(a);
22
+
23
+ if (entriesA.length !== Object.keys(b).length) {
24
+ return false;
25
+ }
26
+ return entriesA.every(([k, v]) => deepCompare(v, b[k]));
27
+ }
28
+
29
+ return false;
30
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useRef, useMemo } from "react";
2
+ import { deepCompare } from "./compare";
3
+
4
+ export function useDeepCompareEffect(
5
+ callback: Parameters<typeof useEffect>[0],
6
+ dependencies: Parameters<typeof useEffect>[1]
7
+ ): ReturnType<typeof useEffect> {
8
+ return useEffect(callback, useDeepCompareMemoize(dependencies));
9
+ }
10
+
11
+ function useDeepCompareMemoize<T>(value: T) {
12
+ const ref = useRef<T>(value);
13
+ const signalRef = useRef<number>(0);
14
+
15
+ if (!deepCompare(value, ref.current)) {
16
+ ref.current = value;
17
+ signalRef.current += 1;
18
+ }
19
+
20
+ return useMemo(() => ref.current, [signalRef.current]);
21
+ }
@@ -0,0 +1,20 @@
1
+ export function isPlainObject<T = Record<keyof any, unknown>>(
2
+ value: unknown
3
+ ): value is T {
4
+ const isObject = (v: unknown): v is object => String(v) === "[object Object]";
5
+
6
+ if (!isObject(value)) return false;
7
+
8
+ const constructor = value.constructor;
9
+ if (constructor === undefined) return true;
10
+
11
+ const prototype = constructor.prototype;
12
+ if (!isObject(prototype)) return false;
13
+
14
+ // Checks if it is not a class
15
+ if (!prototype.hasOwnProperty("isPrototypeOf")) {
16
+ return false;
17
+ }
18
+
19
+ return true;
20
+ }
@@ -0,0 +1,28 @@
1
+ export function snakeize<T>(obj: T): T {
2
+ if (!obj || typeof obj !== "object") {
3
+ return obj;
4
+ }
5
+ if (isDate(obj) || isRegex(obj)) {
6
+ return obj;
7
+ }
8
+ if (Array.isArray(obj)) {
9
+ return obj.map(snakeize) as T;
10
+ }
11
+ return Object.keys(obj).reduce((acc, key) => {
12
+ var camel =
13
+ key[0].toLowerCase() +
14
+ key.slice(1).replace(/([A-Z]+)/g, (_, x) => {
15
+ return "_" + x.toLowerCase();
16
+ });
17
+ acc[camel as keyof typeof acc] = snakeize(obj[key as keyof typeof acc]);
18
+ return acc;
19
+ }, {} as T);
20
+ }
21
+
22
+ function isDate(obj: unknown) {
23
+ return Object.prototype.toString.call(obj) === "[object Date]";
24
+ }
25
+
26
+ function isRegex(obj: unknown) {
27
+ return Object.prototype.toString.call(obj) === "[object RegExp]";
28
+ }
package/src/snakeize.d.ts DELETED
@@ -1 +0,0 @@
1
- declare module "snakeize";
@@ -1,16 +0,0 @@
1
- export default function stringinate(obj: {
2
- [key: string]: any;
3
- }): { [key: string]: any } {
4
- Object.keys(obj).forEach((property) => {
5
- if (obj[property] instanceof URL) {
6
- obj[property] = obj[property].toString();
7
- }
8
- if (typeof obj[property] === "object") {
9
- stringinate(obj[property]);
10
- }
11
-
12
- obj[property] = obj[property];
13
- });
14
-
15
- return obj;
16
- }