@nosto/nosto-react 2.0.0 → 2.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.
@@ -1,7 +1,8 @@
1
- import React, { useEffect, isValidElement } from "react"
1
+ import { isValidElement } from "react"
2
2
  import { NostoContext, RecommendationComponent } from "../context"
3
- import { NostoClient } from "../types"
4
- import { isNostoLoaded } from "./helpers"
3
+ import { useLoadClientScript } from "../hooks"
4
+ import type { ReactElement } from "react"
5
+ import { ScriptLoadOptions } from "../hooks/scriptLoader"
5
6
 
6
7
  /**
7
8
  * @group Components
@@ -22,7 +23,7 @@ export interface NostoProviderProps {
22
23
  /**
23
24
  * children
24
25
  */
25
- children: React.ReactElement | React.ReactElement[]
26
+ children: ReactElement | ReactElement[]
26
27
  /**
27
28
  * Indicates if merchant uses multiple currencies
28
29
  */
@@ -38,6 +39,14 @@ export interface NostoProviderProps {
38
39
  language?: string
39
40
  marketId?: string | number
40
41
  }
42
+ /**
43
+ * Load nosto script (should be false if loading the script outside of nosto-react)
44
+ */
45
+ loadScript?: boolean
46
+ /**
47
+ * Custom script loader
48
+ */
49
+ scriptLoader?: (scriptSrc: string, options?: ScriptLoadOptions) => Promise<void>
41
50
  }
42
51
 
43
52
  /**
@@ -65,88 +74,23 @@ export default function NostoProvider(props: NostoProviderProps) {
65
74
  const {
66
75
  account,
67
76
  multiCurrency = false,
68
- host,
69
77
  children,
70
78
  recommendationComponent,
71
- shopifyMarkets
72
79
  } = props
73
- const [clientScriptLoadedState, setClientScriptLoadedState] = React.useState(false)
74
- const clientScriptLoaded = React.useMemo(() => clientScriptLoadedState, [clientScriptLoadedState])
75
80
 
76
81
  // Pass currentVariation as empty string if multiCurrency is disabled
77
82
  const currentVariation = multiCurrency ? props.currentVariation : ""
78
83
 
79
- // Set responseMode for loading campaigns:
80
- const responseMode = isValidElement(recommendationComponent) ? "JSON_ORIGINAL" : "HTML"
81
-
82
- useEffect(() => {
83
- if (!window.nostojs) {
84
- window.nostojs = (cb: (api: NostoClient) => void) => {
85
- (window.nostojs.q = window.nostojs.q || []).push(cb)
86
- }
87
- window.nostojs(api => api.setAutoLoad(false))
88
- }
89
-
90
- if (!isNostoLoaded() && !shopifyMarkets) {
91
- const script = document.createElement("script")
92
- script.type = "text/javascript"
93
- script.src = "//" + (host || "connect.nosto.com") + "/include/" + account
94
- script.async = true
95
- script.setAttribute("nosto-client-script", "")
96
-
97
- script.onload = () => {
98
- if (typeof jest !== "undefined") {
99
- window.nosto?.reload({
100
- site: "localhost",
101
- })
102
- }
103
- setClientScriptLoadedState(true)
104
- }
105
- document.body.appendChild(script)
106
- }
107
-
108
- // Enable Shopify markets functionality:
109
- if (shopifyMarkets) {
110
- const existingScript = document.querySelector("[nosto-client-script]")
111
- const nostoSandbox = document.querySelector("#nosto-sandbox")
112
-
113
- if (
114
- !existingScript ||
115
- existingScript?.getAttribute("nosto-language") !== shopifyMarkets?.language ||
116
- existingScript?.getAttribute("nosto-market-id") !== shopifyMarkets?.marketId
117
- ) {
118
- if (clientScriptLoadedState) {
119
- setClientScriptLoadedState(false)
120
- }
121
-
122
- existingScript?.parentNode?.removeChild(existingScript)
123
- nostoSandbox?.parentNode?.removeChild(nostoSandbox)
84
+ if (recommendationComponent && !isValidElement(recommendationComponent)) {
85
+ throw new Error(
86
+ "The recommendationComponent prop must be a valid React element. Please provide a valid React element."
87
+ )
88
+ }
124
89
 
125
- const script = document.createElement("script")
126
- script.type = "text/javascript"
127
- script.src =
128
- "//" +
129
- (host || "connect.nosto.com") +
130
- `/script/shopify/market/nosto.js?merchant=${account}&market=${
131
- shopifyMarkets.marketId || ""
132
- }&locale=${shopifyMarkets?.language?.toLowerCase() || ""}`
133
- script.async = true
134
- script.setAttribute("nosto-client-script", "")
135
- script.setAttribute("nosto-language", shopifyMarkets?.language || "")
136
- script.setAttribute("nosto-market-id", String(shopifyMarkets?.marketId))
90
+ // Set responseMode for loading campaigns:
91
+ const responseMode = recommendationComponent ? "JSON_ORIGINAL" : "HTML"
137
92
 
138
- script.onload = () => {
139
- if (typeof jest !== "undefined") {
140
- window.nosto?.reload({
141
- site: "localhost",
142
- })
143
- }
144
- setClientScriptLoadedState(true)
145
- }
146
- document.body.appendChild(script)
147
- }
148
- }
149
- }, [clientScriptLoadedState, shopifyMarkets])
93
+ const { clientScriptLoaded } = useLoadClientScript(props)
150
94
 
151
95
  return (
152
96
  <NostoContext.Provider
@@ -1,5 +1,13 @@
1
1
  import { useRenderCampaigns, useNostoApi } from "../hooks"
2
2
 
3
+ /**
4
+ * @group Components
5
+ */
6
+ export type NostoSearchProps = {
7
+ query: string
8
+ placements?: string[]
9
+ }
10
+
3
11
  /**
4
12
  * You can personalise your search pages by using the NostoSearch component.
5
13
  * The component requires that you provide it the current search term.
@@ -23,8 +31,17 @@ import { useRenderCampaigns, useNostoApi } from "../hooks"
23
31
  *
24
32
  * @group Components
25
33
  */
26
- export default function NostoSearch(props: { query: string; placements?: string[] }) {
27
- const { query, placements } = props
34
+ export default function NostoSearch(props: NostoSearchProps) {
35
+ useNostoSearch(props)
36
+ return null
37
+ }
38
+
39
+ /**
40
+ * You can personalise your search pages by using the useNostoSearch hook.
41
+ *
42
+ * @group Hooks
43
+ */
44
+ export function useNostoSearch({ query, placements }: NostoSearchProps) {
28
45
  const { renderCampaigns } = useRenderCampaigns()
29
46
 
30
47
  useNostoApi(
@@ -37,5 +54,4 @@ export default function NostoSearch(props: { query: string; placements?: string[
37
54
  },
38
55
  [query]
39
56
  )
40
- return null
41
57
  }
@@ -1,6 +1,18 @@
1
1
  import { useNostoContext, useDeepCompareEffect } from "../hooks"
2
- import { Cart, Customer } from "../types"
2
+ import { Cart as CartSnakeCase, Customer as CustomerSnakeCase } from "../types"
3
3
  import { snakeize } from "../utils/snakeize"
4
+ import { ToCamelCase } from "../utils/types"
5
+
6
+ type Cart = CartSnakeCase | ToCamelCase<CartSnakeCase>
7
+ type Customer = CustomerSnakeCase | ToCamelCase<CustomerSnakeCase>
8
+
9
+ /**
10
+ * @group Components
11
+ */
12
+ export type NostoSessionProps = {
13
+ cart?: Cart
14
+ customer?: Customer
15
+ }
4
16
 
5
17
  /**
6
18
  * 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.
@@ -10,10 +22,19 @@ import { snakeize } from "../utils/snakeize"
10
22
  *
11
23
  * 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`.
12
24
  *
13
- * @group Essential Functions
25
+ * @group Components
26
+ */
27
+ export default function NostoSession(props?: NostoSessionProps) {
28
+ useNostoSession(props)
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * 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.
34
+ *
35
+ * @group Hooks
14
36
  */
15
- export default function NostoSession(props?: { cart?: Cart; customer?: Customer }) {
16
- const { cart, customer } = props ?? {}
37
+ export function useNostoSession({ cart, customer }: NostoSessionProps = {}) {
17
38
  const { clientScriptLoaded } = useNostoContext()
18
39
 
19
40
  useDeepCompareEffect(() => {
@@ -28,10 +49,9 @@ export default function NostoSession(props?: { cart?: Cart; customer?: Customer
28
49
  .setCart(currentCart)
29
50
  .setCustomer(currentCustomer)
30
51
  .viewOther()
31
- .load()
52
+ .load({ skipPageViews: true })
32
53
  })
33
54
  }
34
55
  }, [clientScriptLoaded, cart, customer])
35
56
 
36
- return <></>
37
57
  }
@@ -1,11 +1,11 @@
1
- export { default as Nosto404 } from "./Nosto404"
2
- export { default as NostoOther } from "./NostoOther"
3
- export { default as NostoCheckout } from "./NostoCheckout"
4
- export { default as NostoProduct } from "./NostoProduct"
5
- export { default as NostoCategory } from "./NostoCategory"
6
- export { default as NostoSearch } from "./NostoSearch"
7
- export { default as NostoOrder } from "./NostoOrder"
8
- export { default as NostoHome } from "./NostoHome"
9
- export { default as NostoPlacement } from "./NostoPlacement"
10
- export { default as NostoProvider } from "./NostoProvider"
11
- export { default as NostoSession } from "./NostoSession"
1
+ export { default as Nosto404, useNosto404, type Nosto404Props } from "./Nosto404"
2
+ export { default as NostoOther, useNostoOther, type NostoOtherProps } from "./NostoOther"
3
+ export { default as NostoCheckout, useNostoCheckout, type NostoCheckoutProps } from "./NostoCheckout"
4
+ export { default as NostoProduct, useNostoProduct, type NostoProductProps } from "./NostoProduct"
5
+ export { default as NostoCategory, useNostoCategory, type NostoCategoryProps } from "./NostoCategory"
6
+ export { default as NostoSearch, useNostoSearch, type NostoSearchProps } from "./NostoSearch"
7
+ export { default as NostoOrder, useNostoOrder, type NostoOrderProps } from "./NostoOrder"
8
+ export { default as NostoHome, useNostoHome, type NostoHomeProps } from "./NostoHome"
9
+ export { default as NostoPlacement, type NostoPlacementProps } from "./NostoPlacement"
10
+ export { default as NostoProvider, type NostoProviderProps } from "./NostoProvider"
11
+ export { default as NostoSession, useNostoSession, type NostoSessionProps } from "./NostoSession"
package/src/context.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { createContext } from "react"
1
+ import { createContext, ReactElement } from "react"
2
2
  import { Recommendation, RenderMode } from "./types"
3
3
 
4
4
  type AnyFunction = (...args: unknown[]) => unknown
5
5
 
6
- export type RecommendationComponent = React.ReactElement<{
6
+ export type RecommendationComponent = ReactElement<{
7
7
  nostoRecommendation: Recommendation
8
8
  }>
9
9
 
@@ -1,4 +1,5 @@
1
1
  export { useDeepCompareEffect } from "./useDeepCompareEffect"
2
2
  export { useNostoApi } from "./useNostoApi"
3
3
  export { useNostoContext } from "./useNostoContext"
4
- export { useRenderCampaigns } from "./useRenderCampaigns"
4
+ export { useRenderCampaigns } from "./useRenderCampaigns"
5
+ export { useLoadClientScript } from "./useLoadClientScript"
@@ -0,0 +1,30 @@
1
+ export default function scriptLoader(scriptSrc: string, options?: ScriptLoadOptions): Promise<void> {
2
+ return new Promise((resolve, reject) => {
3
+ const script = document.createElement("script")
4
+ script.type = "text/javascript"
5
+ script.src = scriptSrc
6
+ script.async = true
7
+ script.onload = () => resolve()
8
+ script.onerror = () => reject()
9
+ Object.entries(options?.attributes ?? {}).forEach(([k, v]) => script.setAttribute(k, v))
10
+ if (options?.position === "head") {
11
+ document.head.appendChild(script)
12
+ } else {
13
+ document.body.appendChild(script)
14
+ }
15
+ })
16
+ }
17
+
18
+ /**
19
+ * @group Types
20
+ */
21
+ export type ScriptLoadOptions = {
22
+ /**
23
+ * Indicates the position of the script, default is "body"
24
+ */
25
+ position?: "head" | "body"
26
+ /**
27
+ * Indicates the attributes of the script element
28
+ */
29
+ attributes?: Record<string, string>
30
+ }
@@ -0,0 +1,84 @@
1
+ import { useState, useEffect } from "react"
2
+ import { isNostoLoaded } from "../components/helpers"
3
+ import type { NostoClient } from "../types"
4
+ import type { NostoProviderProps } from "../components/NostoProvider"
5
+ import scriptLoaderFn from "./scriptLoader"
6
+
7
+ type NostoScriptProps = Pick<NostoProviderProps, "account" | "host" | "shopifyMarkets" | "loadScript" | "scriptLoader">
8
+
9
+ export function useLoadClientScript(props: NostoScriptProps) {
10
+ const { host = "connect.nosto.com", scriptLoader = scriptLoaderFn, account, shopifyMarkets, loadScript = true } = props
11
+ const [clientScriptLoaded, setClientScriptLoaded] = useState(false)
12
+
13
+ useEffect(() => {
14
+ function scriptOnload() {
15
+ // Override for production scripts to work in unit tests
16
+ if ("nostoReactTest" in window) {
17
+ window.nosto?.reload({
18
+ site: "localhost"
19
+ })
20
+ }
21
+ setClientScriptLoaded(true)
22
+ }
23
+
24
+ // Create and append script element
25
+ async function injectScriptElement(urlPartial: string, extraAttributes: Record<string, string> = {}) {
26
+ const scriptSrc = `//${host}${urlPartial}`
27
+ const attributes = { "nosto-client-script": "", ...extraAttributes }
28
+ await scriptLoader(scriptSrc, { attributes })
29
+ scriptOnload()
30
+ }
31
+
32
+ function prepareShopifyMarketsScript() {
33
+ const existingScript = document.querySelector("[nosto-client-script]")
34
+
35
+ const marketId = String(shopifyMarkets?.marketId || "")
36
+ const language = shopifyMarkets?.language || ""
37
+
38
+ const attributeMismatch =
39
+ existingScript?.getAttribute("nosto-language") !== language ||
40
+ existingScript?.getAttribute("nosto-market-id") !== marketId
41
+
42
+ if (!existingScript || attributeMismatch) {
43
+ if (clientScriptLoaded) {
44
+ setClientScriptLoaded(false)
45
+ }
46
+
47
+ const nostoSandbox = document.querySelector("#nosto-sandbox")
48
+
49
+ existingScript?.parentNode?.removeChild(existingScript)
50
+ nostoSandbox?.parentNode?.removeChild(nostoSandbox)
51
+
52
+ const urlPartial =
53
+ `/script/shopify/market/nosto.js?merchant=${account}&market=${marketId}&locale=${language.toLowerCase()}`
54
+ injectScriptElement(urlPartial, { "nosto-language": language, "nosto-market-id": marketId })
55
+ }
56
+ }
57
+
58
+ // Load Nosto API stub
59
+ if (!window.nostojs) {
60
+ window.nostojs = (cb: (api: NostoClient) => void) => {
61
+ (window.nostojs.q = window.nostojs.q || []).push(cb)
62
+ }
63
+ window.nostojs(api => api.setAutoLoad(false))
64
+ }
65
+
66
+ if (!loadScript) {
67
+ window.nosto ? scriptOnload() : window.nostojs(scriptOnload)
68
+ return
69
+ }
70
+
71
+ // Load Nosto client script if not already loaded externally
72
+ if (!isNostoLoaded() && !shopifyMarkets) {
73
+ const urlPartial = `/include/${account}`
74
+ injectScriptElement(urlPartial)
75
+ }
76
+
77
+ // Load Shopify Markets scripts
78
+ if (shopifyMarkets) {
79
+ prepareShopifyMarketsScript()
80
+ }
81
+ }, [shopifyMarkets?.marketId, shopifyMarkets?.language])
82
+
83
+ return { clientScriptLoaded }
84
+ }
@@ -1,11 +1,11 @@
1
- import { useEffect } from "react"
1
+ import { DependencyList, useEffect } from "react"
2
2
  import { useNostoContext } from "./useNostoContext"
3
3
  import { NostoClient } from "../types"
4
4
  import { useDeepCompareEffect } from "./useDeepCompareEffect"
5
5
 
6
6
  export function useNostoApi(
7
7
  cb: (api: NostoClient) => void,
8
- deps?: React.DependencyList,
8
+ deps?: DependencyList,
9
9
  flags?: { deep?: boolean }
10
10
  ): void {
11
11
  const { clientScriptLoaded, currentVariation, responseMode } = useNostoContext()
@@ -7,12 +7,6 @@ import { NostoContext, NostoContextType } from "../context"
7
7
  * @group Essential Functions
8
8
  */
9
9
  export function useNostoContext(): NostoContextType {
10
- const context = useContext(NostoContext)
11
-
12
- if (!context) {
13
- throw new Error("No nosto context found")
14
- }
15
-
16
- return context
10
+ return useContext(NostoContext)
17
11
  }
18
12
 
@@ -1,8 +1,7 @@
1
- import { useRef } from "react"
1
+ import { cloneElement, useRef } from "react"
2
2
  import { createRoot, Root } from "react-dom/client"
3
3
  import { ActionResponse, Recommendation } from "../types"
4
4
  import { useNostoContext } from "./useNostoContext"
5
- import React from "react"
6
5
  import { RecommendationComponent } from "../context"
7
6
 
8
7
  // RecommendationComponent for client-side rendering:
@@ -10,7 +9,7 @@ function RecommendationComponentWrapper(props: {
10
9
  recommendationComponent: RecommendationComponent,
11
10
  nostoRecommendation: Recommendation }) {
12
11
 
13
- return React.cloneElement(props.recommendationComponent, {
12
+ return cloneElement(props.recommendationComponent, {
14
13
  // eslint-disable-next-line react/prop-types
15
14
  nostoRecommendation: props.nostoRecommendation,
16
15
  })
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type { Cart, Customer, Product, Order, Recommendation } from "./types"
2
2
  export * from "./components"
3
+ export { type ScriptLoadOptions } from "./hooks/scriptLoader"
3
4
  export { NostoContext, type NostoContextType } from "./context"
4
5
  export { useNostoContext } from "./hooks/useNostoContext"
@@ -1,4 +1,8 @@
1
- export function snakeize<T>(obj: T): T {
1
+ import { ToSnakeCase } from "./types"
2
+
3
+ // signature override
4
+ export function snakeize<T>(obj: T): ToSnakeCase<T>
5
+ export function snakeize<T>(obj: T): unknown {
2
6
  if (!obj || typeof obj !== "object") {
3
7
  return obj
4
8
  }
@@ -0,0 +1,29 @@
1
+ type SnakeToCamelCase<S extends string> = S extends `${infer T}_${infer U}`
2
+ ? `${T}${Capitalize<SnakeToCamelCase<U>>}`
3
+ : S
4
+
5
+ // Recursive type to apply the conversion to all keys in an object type
6
+ export type ToCamelCase<T> = T extends (infer U)[]
7
+ ? ToCamelCase<U>[]
8
+ : T extends Date ? T
9
+ : T extends object
10
+ ? {
11
+ [K in keyof T as SnakeToCamelCase<K & string>]: ToCamelCase<T[K]>
12
+ }
13
+ : T
14
+
15
+ type CamelToSnakeCase<S extends string> = S extends `${infer T}${infer U}`
16
+ ? U extends Uncapitalize<U>
17
+ ? `${Lowercase<T>}${CamelToSnakeCase<U>}`
18
+ : `${Lowercase<T>}_${CamelToSnakeCase<Uncapitalize<U>>}`
19
+ : S
20
+
21
+ // Recursive type to apply the conversion to all keys in an object type
22
+ export type ToSnakeCase<T> = T extends (infer U)[]
23
+ ? ToSnakeCase<U>[]
24
+ : T extends Date ? T
25
+ : T extends object
26
+ ? {
27
+ [K in keyof T as CamelToSnakeCase<K & string>]: ToSnakeCase<T[K]>
28
+ }
29
+ : T