shopsense-test 0.1.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 +107 -0
- package/package.json +14 -0
- package/src/PersistentWebView.tsx +206 -0
- package/src/ShopSenseWidget.tsx +102 -0
- package/src/context.tsx +107 -0
- package/src/index.ts +12 -0
- package/src/message.ts +106 -0
- package/src/navigation.ts +9 -0
- package/src/shims.d.ts +55 -0
- package/src/types.ts +71 -0
- package/src/url.ts +39 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
## shopsense-widget (React Native)
|
|
2
|
+
|
|
3
|
+
Thin WebView shell for the ShopSense widget.
|
|
4
|
+
|
|
5
|
+
### Goals
|
|
6
|
+
|
|
7
|
+
- **No UI owned by the package**: all HTML/JS/CSS is served by your server.
|
|
8
|
+
- Package only:
|
|
9
|
+
- Renders a WebView pointing to your server page
|
|
10
|
+
- Passes widget config (zone + optional params) via query params
|
|
11
|
+
- Bridges product selections back to native (callback or default deep link)
|
|
12
|
+
- Keeps the WebView **persisted** across navigation
|
|
13
|
+
|
|
14
|
+
### Install
|
|
15
|
+
|
|
16
|
+
Add the package to your monorepo and import from it. Peer deps must exist in the app:
|
|
17
|
+
|
|
18
|
+
- `react`
|
|
19
|
+
- `react-native`
|
|
20
|
+
- `react-native-webview`
|
|
21
|
+
- `expo-router`
|
|
22
|
+
|
|
23
|
+
### Usage
|
|
24
|
+
|
|
25
|
+
#### 1) Wrap your app root once
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { ShopSenseRoot } from "shopsense-widget";
|
|
29
|
+
|
|
30
|
+
export default function RootLayout() {
|
|
31
|
+
return (
|
|
32
|
+
<ShopSenseRoot>
|
|
33
|
+
{/* your app routes */}
|
|
34
|
+
</ShopSenseRoot>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
#### 2) Mount the persistent WebView once (recommended)
|
|
40
|
+
|
|
41
|
+
Mount `PersistentWebView` **once** at the root so it never unmounts/reloads when you leave the search screen.
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { PersistentWebView } from "shopsense-widget";
|
|
45
|
+
|
|
46
|
+
export default function RootLayout() {
|
|
47
|
+
return (
|
|
48
|
+
<ShopSenseRoot>
|
|
49
|
+
<View style={{ flex: 1, position: "relative" }}>
|
|
50
|
+
{/* your app routes */}
|
|
51
|
+
<PersistentWebView />
|
|
52
|
+
</View>
|
|
53
|
+
</ShopSenseRoot>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### 3) Register config + callbacks on your Search screen
|
|
59
|
+
|
|
60
|
+
Render `ShopSenseWidget` on the screen where you want it active. It registers config/callbacks and toggles show/hide on focus.
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { ShopSenseWidget } from "shopsense-widget";
|
|
64
|
+
|
|
65
|
+
export default function SearchScreen() {
|
|
66
|
+
return (
|
|
67
|
+
<ShopSenseWidget
|
|
68
|
+
zoneId={35}
|
|
69
|
+
onProductSelect={(product) => {
|
|
70
|
+
// override default deep link navigation
|
|
71
|
+
// product = { id, alias }
|
|
72
|
+
}}
|
|
73
|
+
onDebugMessage={(msg) => {
|
|
74
|
+
// optional: WIDGET_STATUS / console / errors
|
|
75
|
+
console.log(msg);
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Configuration
|
|
83
|
+
|
|
84
|
+
Only `zoneId` is required.
|
|
85
|
+
|
|
86
|
+
All other config is optional and depends on how your widget shell is hosted (defaults are handled by the package and your shell).
|
|
87
|
+
|
|
88
|
+
### Server contract
|
|
89
|
+
|
|
90
|
+
Your server must host an HTML page (default path: `/embed/widget.html`) that:
|
|
91
|
+
|
|
92
|
+
- Reads query params such as: `zone` (and any optional parameters you support)
|
|
93
|
+
- Loads the ShopSense widget JS/CSS from your server/CDN
|
|
94
|
+
- Posts `NAVIGATE_TO_PRODUCT` to the React Native WebView bridge when a product is tapped:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "type": "NAVIGATE_TO_PRODUCT", "payload": { "id": 123, "alias": "slug" } }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Default navigation behavior
|
|
101
|
+
|
|
102
|
+
If `onProductSelect` is **not** provided, the package opens:
|
|
103
|
+
|
|
104
|
+
`app.shopsense://product/:id/:alias`
|
|
105
|
+
|
|
106
|
+
Your app must register the deep link scheme for this to work.
|
|
107
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shopsense-test",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"expo-router": "*",
|
|
10
|
+
"react": "*",
|
|
11
|
+
"react-native": "*",
|
|
12
|
+
"react-native-webview": "*"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import type { WebViewMessageEvent } from "react-native-webview";
|
|
4
|
+
import { WebView } from "react-native-webview";
|
|
5
|
+
import { useShopSense } from "./context";
|
|
6
|
+
import {
|
|
7
|
+
extractProductSelect,
|
|
8
|
+
isDebugMessage,
|
|
9
|
+
tryParseJsonMessage,
|
|
10
|
+
} from "./message";
|
|
11
|
+
import { defaultOpenProduct } from "./navigation";
|
|
12
|
+
import { buildWidgetUri } from "./url";
|
|
13
|
+
|
|
14
|
+
export const PersistentWebView = memo(function PersistentWebView() {
|
|
15
|
+
const { config, visible, webViewRef, callbacksRef, lastWebViewUrlRef } =
|
|
16
|
+
useShopSense();
|
|
17
|
+
const lastLoggedUrlRef = useRef(null as string | null);
|
|
18
|
+
|
|
19
|
+
const uri = useMemo(() => {
|
|
20
|
+
if (!config) return null;
|
|
21
|
+
return buildWidgetUri(config);
|
|
22
|
+
}, [config]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (uri) {
|
|
26
|
+
console.log("[ShopSenseWebView] initial uri:", uri);
|
|
27
|
+
}
|
|
28
|
+
}, [uri]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!visible) return;
|
|
32
|
+
const wv = webViewRef?.current as any;
|
|
33
|
+
if (!wv?.injectJavaScript) return;
|
|
34
|
+
|
|
35
|
+
// When returning from a native screen (Product → Back), the WebView doesn't get a
|
|
36
|
+
// browser "pageshow" event. If Layout 2 was hidden by the widget runtime, we need
|
|
37
|
+
// to re-show the existing container so results re-appear without requiring typing.
|
|
38
|
+
//
|
|
39
|
+
// Important: do NOT dispatch `hashchange` here — that forces a refetch. We only
|
|
40
|
+
// want to restore the already-rendered view/state.
|
|
41
|
+
wv.injectJavaScript(
|
|
42
|
+
`(function(){try{
|
|
43
|
+
var el = document.getElementById('lookin-widget-container');
|
|
44
|
+
if (el) {
|
|
45
|
+
el.style.display = 'block';
|
|
46
|
+
el.style.visibility = 'visible';
|
|
47
|
+
el.style.opacity = '1';
|
|
48
|
+
}
|
|
49
|
+
document.dispatchEvent(new CustomEvent('lookin:layout2'));
|
|
50
|
+
}catch(e){};return true;})();`
|
|
51
|
+
);
|
|
52
|
+
}, [visible, webViewRef]);
|
|
53
|
+
|
|
54
|
+
const handleMessage = useCallback(
|
|
55
|
+
(event: WebViewMessageEvent) => {
|
|
56
|
+
const raw = event?.nativeEvent?.data;
|
|
57
|
+
const msg = tryParseJsonMessage(raw);
|
|
58
|
+
|
|
59
|
+
// Debug messages
|
|
60
|
+
if (isDebugMessage(msg)) {
|
|
61
|
+
callbacksRef.current.onDebugMessage?.(msg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const o = msg && typeof msg === "object" ? (msg as any) : null;
|
|
65
|
+
if (o?.type === "NAVIGATE_TO_PRODUCT") {
|
|
66
|
+
// Snapshot the last known URL before handing off to native navigation.
|
|
67
|
+
// This helps with persistence/debug when users return via Back.
|
|
68
|
+
lastWebViewUrlRef.current =
|
|
69
|
+
lastWebViewUrlRef.current ?? (o?.payload as any)?.productUrl ?? null;
|
|
70
|
+
|
|
71
|
+
const payload = o?.payload;
|
|
72
|
+
const product = extractProductSelect(payload);
|
|
73
|
+
|
|
74
|
+
if (product) {
|
|
75
|
+
if (callbacksRef.current.onProductSelect) {
|
|
76
|
+
callbacksRef.current.onProductSelect(product);
|
|
77
|
+
} else {
|
|
78
|
+
defaultOpenProduct(product);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
callbacksRef.current.onMessage?.(msg);
|
|
84
|
+
},
|
|
85
|
+
[callbacksRef]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!uri) return null;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<View
|
|
92
|
+
pointerEvents={visible ? "auto" : "none"}
|
|
93
|
+
style={[styles.container, !visible && styles.hidden]}
|
|
94
|
+
>
|
|
95
|
+
<WebView
|
|
96
|
+
ref={(r: any) => {
|
|
97
|
+
webViewRef.current = r;
|
|
98
|
+
}}
|
|
99
|
+
source={{ uri }}
|
|
100
|
+
onMessage={handleMessage}
|
|
101
|
+
onShouldStartLoadWithRequest={(req: any) => {
|
|
102
|
+
const nextUrl = typeof req?.url === "string" ? req.url : null;
|
|
103
|
+
if (nextUrl && lastLoggedUrlRef.current !== nextUrl) {
|
|
104
|
+
lastLoggedUrlRef.current = nextUrl;
|
|
105
|
+
console.log("[ShopSenseWebView] url:", nextUrl);
|
|
106
|
+
}
|
|
107
|
+
callbacksRef.current.onDebugMessage?.({
|
|
108
|
+
type: "WEBVIEW_SHOULD_START",
|
|
109
|
+
source: "native",
|
|
110
|
+
payload: {
|
|
111
|
+
url: req?.url,
|
|
112
|
+
navigationType: (req as any)?.navigationType,
|
|
113
|
+
isTopFrame: (req as any)?.isTopFrame,
|
|
114
|
+
mainDocumentURL: (req as any)?.mainDocumentURL,
|
|
115
|
+
hasTargetFrame: (req as any)?.hasTargetFrame,
|
|
116
|
+
method: (req as any)?.method,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return true;
|
|
120
|
+
}}
|
|
121
|
+
onNavigationStateChange={(navState: any) => {
|
|
122
|
+
if (typeof navState?.url === "string") {
|
|
123
|
+
lastWebViewUrlRef.current = navState.url;
|
|
124
|
+
if (lastLoggedUrlRef.current !== navState.url) {
|
|
125
|
+
lastLoggedUrlRef.current = navState.url;
|
|
126
|
+
console.log("[ShopSenseWebView] url:", navState.url);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
callbacksRef.current.onDebugMessage?.({
|
|
130
|
+
type: "WEBVIEW_NAV_STATE",
|
|
131
|
+
source: "native",
|
|
132
|
+
payload: {
|
|
133
|
+
url: navState?.url,
|
|
134
|
+
title: (navState as any)?.title,
|
|
135
|
+
loading: navState?.loading,
|
|
136
|
+
canGoBack: navState?.canGoBack,
|
|
137
|
+
canGoForward: navState?.canGoForward,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}}
|
|
141
|
+
onLoadStart={(e: any) => {
|
|
142
|
+
callbacksRef.current.onDebugMessage?.({
|
|
143
|
+
type: "WEBVIEW_LOAD_START",
|
|
144
|
+
source: "native",
|
|
145
|
+
payload: { url: e?.nativeEvent?.url },
|
|
146
|
+
});
|
|
147
|
+
}}
|
|
148
|
+
onLoadEnd={(e: any) => {
|
|
149
|
+
callbacksRef.current.onDebugMessage?.({
|
|
150
|
+
type: "WEBVIEW_LOAD_END",
|
|
151
|
+
source: "native",
|
|
152
|
+
payload: { url: e?.nativeEvent?.url },
|
|
153
|
+
});
|
|
154
|
+
}}
|
|
155
|
+
onError={(e: any) => {
|
|
156
|
+
callbacksRef.current.onDebugMessage?.({
|
|
157
|
+
type: "WEBVIEW_ERROR",
|
|
158
|
+
source: "native",
|
|
159
|
+
payload: e?.nativeEvent,
|
|
160
|
+
});
|
|
161
|
+
}}
|
|
162
|
+
onHttpError={(e: any) => {
|
|
163
|
+
callbacksRef.current.onDebugMessage?.({
|
|
164
|
+
type: "WEBVIEW_HTTP_ERROR",
|
|
165
|
+
source: "native",
|
|
166
|
+
payload: e?.nativeEvent,
|
|
167
|
+
});
|
|
168
|
+
}}
|
|
169
|
+
javaScriptEnabled
|
|
170
|
+
domStorageEnabled
|
|
171
|
+
startInLoadingState
|
|
172
|
+
mixedContentMode="compatibility"
|
|
173
|
+
keyboardDisplayRequiresUserAction={false}
|
|
174
|
+
scrollEnabled
|
|
175
|
+
nestedScrollEnabled
|
|
176
|
+
bounces
|
|
177
|
+
showsVerticalScrollIndicator
|
|
178
|
+
// Keep the WebView mounted & stateful; hide/show via styles above.
|
|
179
|
+
style={styles.webview}
|
|
180
|
+
/>
|
|
181
|
+
</View>
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const styles = StyleSheet.create({
|
|
186
|
+
container: {
|
|
187
|
+
flex: 1,
|
|
188
|
+
minHeight: 0,
|
|
189
|
+
width: "100%",
|
|
190
|
+
backgroundColor: "#fff",
|
|
191
|
+
overflow: "hidden",
|
|
192
|
+
},
|
|
193
|
+
hidden: {
|
|
194
|
+
// Keep the WebView at full size but invisible; collapsing to 0 can
|
|
195
|
+
// cause some WebView implementations to discard their render tree.
|
|
196
|
+
opacity: 0,
|
|
197
|
+
height: 1,
|
|
198
|
+
flex: 0,
|
|
199
|
+
overflow: "hidden",
|
|
200
|
+
},
|
|
201
|
+
webview: {
|
|
202
|
+
flex: 1,
|
|
203
|
+
backgroundColor: "transparent",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { memo, useCallback, useEffect, useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { View } from "react-native";
|
|
4
|
+
import { useFocusEffect } from "expo-router";
|
|
5
|
+
import type { ShopSenseWidgetProps } from "./types";
|
|
6
|
+
import { ShopSenseProvider, useShopSense, useShopSenseOptional } from "./context";
|
|
7
|
+
import { PersistentWebView } from "./PersistentWebView";
|
|
8
|
+
|
|
9
|
+
function ShopSenseWidgetInner(props: ShopSenseWidgetProps) {
|
|
10
|
+
const {
|
|
11
|
+
onDebugMessage,
|
|
12
|
+
onMessage,
|
|
13
|
+
onProductSelect,
|
|
14
|
+
autoShowOnFocus = true,
|
|
15
|
+
...config
|
|
16
|
+
} = props;
|
|
17
|
+
|
|
18
|
+
const ctx = useShopSense();
|
|
19
|
+
const { callbacksRef, setConfig, show, hide } = ctx;
|
|
20
|
+
|
|
21
|
+
// Stabilize the config object so we don't call setConfig on every render.
|
|
22
|
+
const stableConfig = useMemo(() => {
|
|
23
|
+
const extraParamsKey = config.extraParams
|
|
24
|
+
? JSON.stringify(config.extraParams)
|
|
25
|
+
: "";
|
|
26
|
+
return {
|
|
27
|
+
baseUrl: config.baseUrl,
|
|
28
|
+
zoneId: config.zoneId,
|
|
29
|
+
apiBase: config.apiBase,
|
|
30
|
+
uiDensity: config.uiDensity,
|
|
31
|
+
widgetPath: config.widgetPath,
|
|
32
|
+
extraParams: config.extraParams,
|
|
33
|
+
// internal key for memo deps
|
|
34
|
+
__extraParamsKey: extraParamsKey,
|
|
35
|
+
} as const;
|
|
36
|
+
}, [
|
|
37
|
+
config.baseUrl,
|
|
38
|
+
config.zoneId,
|
|
39
|
+
config.apiBase,
|
|
40
|
+
config.uiDensity,
|
|
41
|
+
config.widgetPath,
|
|
42
|
+
config.extraParams ? JSON.stringify(config.extraParams) : "",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Keep latest callbacks without re-mounting the WebView.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
callbacksRef.current = { onProductSelect, onMessage, onDebugMessage };
|
|
48
|
+
}, [callbacksRef, onProductSelect, onMessage, onDebugMessage]);
|
|
49
|
+
|
|
50
|
+
// Update config.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// Strip internal key before storing
|
|
53
|
+
const { __extraParamsKey, ...toStore } = stableConfig as any;
|
|
54
|
+
setConfig(toStore);
|
|
55
|
+
}, [setConfig, stableConfig]);
|
|
56
|
+
|
|
57
|
+
// Auto show/hide based on screen focus (persistent WebView stays mounted).
|
|
58
|
+
useFocusEffect(
|
|
59
|
+
useCallback(() => {
|
|
60
|
+
if (!autoShowOnFocus) return () => {};
|
|
61
|
+
show();
|
|
62
|
+
return () => hide();
|
|
63
|
+
}, [autoShowOnFocus, hide, show])
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// This component itself renders nothing; the provider renders the WebView.
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Public component API.
|
|
72
|
+
*
|
|
73
|
+
* If rendered within a `ShopSenseProvider`, it registers config + callbacks and uses the
|
|
74
|
+
* provider's persistent WebView.
|
|
75
|
+
*
|
|
76
|
+
* If rendered without a provider, it self-hosts the provider and persistent WebView.
|
|
77
|
+
*/
|
|
78
|
+
export const ShopSenseWidget = memo(function ShopSenseWidget(props: ShopSenseWidgetProps) {
|
|
79
|
+
const ctx = useShopSenseOptional();
|
|
80
|
+
|
|
81
|
+
if (ctx) {
|
|
82
|
+
return <ShopSenseWidgetInner {...props} />;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<ShopSenseProvider>
|
|
87
|
+
<View style={{ flex: 1, minHeight: 0 }}>
|
|
88
|
+
<ShopSenseWidgetInner {...props} />
|
|
89
|
+
<PersistentWebView />
|
|
90
|
+
</View>
|
|
91
|
+
</ShopSenseProvider>
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Place once at app root. It only provides context — render `PersistentWebView`
|
|
97
|
+
* inside your search screen (below AppBar) so the WebView does not cover the app chrome.
|
|
98
|
+
*/
|
|
99
|
+
export function ShopSenseRoot({ children }: { children: ReactNode }) {
|
|
100
|
+
return <ShopSenseProvider>{children}</ShopSenseProvider>;
|
|
101
|
+
}
|
|
102
|
+
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type { MutableRefObject, ReactNode } from "react";
|
|
10
|
+
import { View } from "react-native";
|
|
11
|
+
import type { ShopSenseWidgetConfig, ShopSenseWidgetProps } from "./types";
|
|
12
|
+
|
|
13
|
+
type ShopSenseContextValue = {
|
|
14
|
+
/** Latest config the provider should render. */
|
|
15
|
+
config: ShopSenseWidgetConfig | null;
|
|
16
|
+
/** Whether the persistent WebView is currently visible. */
|
|
17
|
+
visible: boolean;
|
|
18
|
+
/** Replace config (does not automatically show). */
|
|
19
|
+
setConfig: (config: ShopSenseWidgetConfig) => void;
|
|
20
|
+
/** Show the persistent WebView (optionally overriding config). */
|
|
21
|
+
show: (config?: ShopSenseWidgetConfig) => void;
|
|
22
|
+
/** Hide the persistent WebView without unmounting it. */
|
|
23
|
+
hide: () => void;
|
|
24
|
+
/** Ref to the mounted WebView instance. */
|
|
25
|
+
webViewRef: MutableRefObject<unknown>;
|
|
26
|
+
/** Last known navigation URL inside the WebView (for persistence/debug). */
|
|
27
|
+
lastWebViewUrlRef: MutableRefObject<string | null>;
|
|
28
|
+
/** Props/callbacks from the last mounted ShopSenseWidget. */
|
|
29
|
+
callbacksRef: MutableRefObject<
|
|
30
|
+
Pick<ShopSenseWidgetProps, "onProductSelect" | "onMessage" | "onDebugMessage">
|
|
31
|
+
>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const ShopSenseContext = createContext(null as ShopSenseContextValue | null);
|
|
35
|
+
|
|
36
|
+
export function ShopSenseProvider({ children }: { children?: ReactNode }) {
|
|
37
|
+
const [config, setConfigState] = useState(null as ShopSenseWidgetConfig | null);
|
|
38
|
+
const [visible, setVisible] = useState(false);
|
|
39
|
+
|
|
40
|
+
const webViewRef = useRef(null as unknown);
|
|
41
|
+
const lastWebViewUrlRef = useRef(null as string | null);
|
|
42
|
+
const callbacksRef = useRef(
|
|
43
|
+
{} as Pick<
|
|
44
|
+
ShopSenseWidgetProps,
|
|
45
|
+
"onProductSelect" | "onMessage" | "onDebugMessage"
|
|
46
|
+
>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const lastConfigKeyRef = useRef("");
|
|
50
|
+
const setConfig = useCallback((next: ShopSenseWidgetConfig) => {
|
|
51
|
+
// Avoid pointless state updates that can cause focus-effect churn.
|
|
52
|
+
const key = JSON.stringify(next);
|
|
53
|
+
if (key === lastConfigKeyRef.current) return;
|
|
54
|
+
lastConfigKeyRef.current = key;
|
|
55
|
+
setConfigState(next);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const show = useCallback((next?: ShopSenseWidgetConfig) => {
|
|
59
|
+
if (next) {
|
|
60
|
+
const key = JSON.stringify(next);
|
|
61
|
+
if (key !== lastConfigKeyRef.current) {
|
|
62
|
+
lastConfigKeyRef.current = key;
|
|
63
|
+
setConfigState(next);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
setVisible(true);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const hide = useCallback(() => {
|
|
70
|
+
setVisible(false);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const value = useMemo(
|
|
74
|
+
() => ({
|
|
75
|
+
config,
|
|
76
|
+
visible,
|
|
77
|
+
setConfig,
|
|
78
|
+
show,
|
|
79
|
+
hide,
|
|
80
|
+
webViewRef,
|
|
81
|
+
lastWebViewUrlRef,
|
|
82
|
+
callbacksRef,
|
|
83
|
+
}),
|
|
84
|
+
[config, visible, hide, setConfig, show]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<ShopSenseContext.Provider value={value}>
|
|
89
|
+
{/* flex:1 so the persistent WebView (absolute fill) has a bounded parent like the native root */}
|
|
90
|
+
<View style={{ flex: 1 }}>{children}</View>
|
|
91
|
+
</ShopSenseContext.Provider>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useShopSense() {
|
|
96
|
+
const ctx = useContext(ShopSenseContext);
|
|
97
|
+
if (!ctx) {
|
|
98
|
+
throw new Error("useShopSense must be used within ShopSenseProvider");
|
|
99
|
+
}
|
|
100
|
+
return ctx;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Internal: like useShopSense but returns null instead of throwing. */
|
|
104
|
+
export function useShopSenseOptional() {
|
|
105
|
+
return useContext(ShopSenseContext);
|
|
106
|
+
}
|
|
107
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { ShopSenseWidget, ShopSenseRoot } from "./ShopSenseWidget";
|
|
2
|
+
export { PersistentWebView } from "./PersistentWebView";
|
|
3
|
+
export { ShopSenseProvider, useShopSense } from "./context";
|
|
4
|
+
export { DEFAULT_BASE_URL } from "./url";
|
|
5
|
+
export type {
|
|
6
|
+
ShopSenseDebugMessage,
|
|
7
|
+
ShopSenseProductSelect,
|
|
8
|
+
ShopSenseWidgetConfig,
|
|
9
|
+
ShopSenseWidgetProps,
|
|
10
|
+
UiDensity,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ShopSenseDebugMessage, ShopSenseProductSelect } from "./types";
|
|
2
|
+
|
|
3
|
+
function asObject(v: unknown): Record<string, unknown> | null {
|
|
4
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function tryParseJsonMessage(raw: unknown): unknown {
|
|
8
|
+
if (typeof raw !== "string") return raw;
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
} catch {
|
|
12
|
+
return raw;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isDebugMessage(msg: unknown): msg is ShopSenseDebugMessage {
|
|
17
|
+
const o = asObject(msg);
|
|
18
|
+
const type = o?.type;
|
|
19
|
+
return (
|
|
20
|
+
typeof type === "string" &&
|
|
21
|
+
(type.startsWith("WIDGET_") || type.startsWith("WEBVIEW_"))
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pickPositiveInt(v: unknown): number | null {
|
|
26
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) {
|
|
27
|
+
return Math.floor(v);
|
|
28
|
+
}
|
|
29
|
+
if (typeof v === "string") {
|
|
30
|
+
const t = v.trim();
|
|
31
|
+
if (!t) return null;
|
|
32
|
+
const n = parseInt(t, 10);
|
|
33
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pickNonEmptyString(v: unknown): string | undefined {
|
|
39
|
+
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Last path segment, for `/shop/123-cool-widget` → `123-cool-widget`. */
|
|
43
|
+
function lastPathSegmentFromUrl(url: string): string | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const noHash = url.split("#")[0] ?? "";
|
|
46
|
+
const noQuery = noHash.split("?")[0] ?? "";
|
|
47
|
+
const parts = noQuery.split("/").filter(Boolean);
|
|
48
|
+
const last = parts[parts.length - 1];
|
|
49
|
+
if (!last) return undefined;
|
|
50
|
+
return decodeURIComponent(last);
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseIdFromUrlPath(url: string): number | null {
|
|
57
|
+
const seg = lastPathSegmentFromUrl(url);
|
|
58
|
+
if (!seg) return null;
|
|
59
|
+
const hyphen = /^(\d+)[-_](.+)$/.exec(seg);
|
|
60
|
+
if (hyphen) {
|
|
61
|
+
return pickPositiveInt(hyphen[1]);
|
|
62
|
+
}
|
|
63
|
+
return pickPositiveInt(seg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract `{ id, alias }` from a NAVIGATE_TO_PRODUCT payload.
|
|
68
|
+
* Accepts multiple shapes (id/productId/sku when numeric, alias/urlAlias/slug/name, URL paths).
|
|
69
|
+
*/
|
|
70
|
+
export function extractProductSelect(payload: unknown): ShopSenseProductSelect | null {
|
|
71
|
+
const o = asObject(payload);
|
|
72
|
+
if (!o) return null;
|
|
73
|
+
|
|
74
|
+
const productUrlStr = pickNonEmptyString(o.productUrl);
|
|
75
|
+
const urlAliasStr = pickNonEmptyString(o.urlAlias);
|
|
76
|
+
|
|
77
|
+
const idFromFields =
|
|
78
|
+
pickPositiveInt(o.id) ??
|
|
79
|
+
pickPositiveInt(o.productId) ??
|
|
80
|
+
pickPositiveInt(o.product_id) ??
|
|
81
|
+
pickPositiveInt(o.sku);
|
|
82
|
+
|
|
83
|
+
const idFromUrl =
|
|
84
|
+
(productUrlStr ? parseIdFromUrlPath(productUrlStr) : null) ??
|
|
85
|
+
(urlAliasStr && urlAliasStr.includes("/")
|
|
86
|
+
? parseIdFromUrlPath(urlAliasStr)
|
|
87
|
+
: null);
|
|
88
|
+
|
|
89
|
+
const id = idFromFields ?? idFromUrl;
|
|
90
|
+
|
|
91
|
+
const alias =
|
|
92
|
+
pickNonEmptyString(o.alias) ??
|
|
93
|
+
urlAliasStr ??
|
|
94
|
+
pickNonEmptyString(o.slug) ??
|
|
95
|
+
pickNonEmptyString(o.handle) ??
|
|
96
|
+
pickNonEmptyString(o.productName) ??
|
|
97
|
+
pickNonEmptyString(o.name) ??
|
|
98
|
+
pickNonEmptyString(o.title) ??
|
|
99
|
+
pickNonEmptyString(o.sku) ??
|
|
100
|
+
(productUrlStr ? lastPathSegmentFromUrl(productUrlStr) : undefined) ??
|
|
101
|
+
"";
|
|
102
|
+
|
|
103
|
+
if (!id || !alias) return null;
|
|
104
|
+
return { id, alias };
|
|
105
|
+
}
|
|
106
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Linking } from "react-native";
|
|
2
|
+
import type { ShopSenseProductSelect } from "./types";
|
|
3
|
+
|
|
4
|
+
export function defaultOpenProduct(product: ShopSenseProductSelect): void {
|
|
5
|
+
const alias = encodeURIComponent(product.alias);
|
|
6
|
+
const url = `app.shopsense://product/${product.id}/${alias}`;
|
|
7
|
+
void Linking.openURL(url);
|
|
8
|
+
}
|
|
9
|
+
|
package/src/shims.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal stubs for peer deps.
|
|
3
|
+
*
|
|
4
|
+
* Important: do NOT install a second copy of `react-native` under this package.
|
|
5
|
+
* When this package is consumed via `file:` in an RN app, Metro can pick up the
|
|
6
|
+
* nested `node_modules/react-native` and crash with codegen errors.
|
|
7
|
+
*/
|
|
8
|
+
declare module "react" {
|
|
9
|
+
// Keep these loose; the app provides real React types at build time.
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
const React: any;
|
|
12
|
+
export default React;
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
export const createContext: any;
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
export const useContext: any;
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
export type ReactNode = any;
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
export type MutableRefObject<T = any> = any;
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
export const memo: any;
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
export const useCallback: any;
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
export const useEffect: any;
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
export const useMemo: any;
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
export const useRef: any;
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
export const useState: any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare module "expo-router" {
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
export const useFocusEffect: any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
declare module "react-native" {
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
export const StyleSheet: any;
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
export const View: any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
declare module "react-native-webview" {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
export const WebView: any;
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
export type WebViewMessageEvent = any;
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
export type WebViewProps = any;
|
|
54
|
+
}
|
|
55
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type UiDensity = "compact" | "comfortable";
|
|
2
|
+
|
|
3
|
+
export type ShopSenseProductSelect = {
|
|
4
|
+
id: number;
|
|
5
|
+
alias: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ShopSenseDebugMessage =
|
|
9
|
+
| { type: "WIDGET_STATUS"; payload: unknown; source?: string }
|
|
10
|
+
| { type: "WIDGET_SCRIPT_LOADED"; payload: unknown; source?: string }
|
|
11
|
+
| { type: "WIDGET_SCRIPT_ERROR"; payload: unknown; source?: string }
|
|
12
|
+
| { type: "WIDGET_CONSOLE"; payload: unknown; source?: string }
|
|
13
|
+
| { type: "WIDGET_RUNTIME_ERROR"; payload: unknown; source?: string }
|
|
14
|
+
| { type: "WIDGET_UNHANDLED_REJECTION"; payload: unknown; source?: string }
|
|
15
|
+
| { type: "WIDGET_HTML_LOADED"; payload: unknown; source?: string }
|
|
16
|
+
| { type: "WEBVIEW_SHOULD_START"; payload: unknown; source?: string }
|
|
17
|
+
| { type: "WEBVIEW_NAV_STATE"; payload: unknown; source?: string }
|
|
18
|
+
| { type: "WEBVIEW_LOAD_START"; payload: unknown; source?: string }
|
|
19
|
+
| { type: "WEBVIEW_LOAD_END"; payload: unknown; source?: string }
|
|
20
|
+
| { type: "WEBVIEW_ERROR"; payload: unknown; source?: string }
|
|
21
|
+
| { type: "WEBVIEW_HTTP_ERROR"; payload: unknown; source?: string };
|
|
22
|
+
|
|
23
|
+
export type ShopSenseWidgetConfig = {
|
|
24
|
+
/**
|
|
25
|
+
* Server origin that hosts the widget shell HTML (and any assets your shell loads).
|
|
26
|
+
*
|
|
27
|
+
* Default: `https://app.shopsense.pro`
|
|
28
|
+
*/
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
zoneId: number;
|
|
31
|
+
apiBase?: string;
|
|
32
|
+
uiDensity?: UiDensity;
|
|
33
|
+
/**
|
|
34
|
+
* Full URL path on your server that hosts the widget shell HTML.
|
|
35
|
+
* Example: "/embed/widget.html" or "/widget/search".
|
|
36
|
+
*
|
|
37
|
+
* The package never hardcodes any UI; your server serves the full HTML/JS/CSS.
|
|
38
|
+
*/
|
|
39
|
+
widgetPath?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Additional query params to include in the URL to your server page.
|
|
42
|
+
* Useful for A/B tests or tenant routing.
|
|
43
|
+
*/
|
|
44
|
+
extraParams?: Record<string, string | number | boolean | undefined>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ShopSenseWidgetProps = ShopSenseWidgetConfig & {
|
|
48
|
+
/**
|
|
49
|
+
* Called when the user taps a product in the widget.
|
|
50
|
+
* If provided, overrides the default deep link navigation (app.shopsense://product/:id/:alias).
|
|
51
|
+
*/
|
|
52
|
+
onProductSelect?: (product: ShopSenseProductSelect) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Raw message handler for all messages coming from the WebView.
|
|
55
|
+
* Called after internal handling unless prevented by errors.
|
|
56
|
+
*/
|
|
57
|
+
onMessage?: (message: unknown) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Optional debug event handler. Receives widget diagnostics:
|
|
60
|
+
* WIDGET_STATUS, WIDGET_SCRIPT_LOADED/ERROR, WIDGET_CONSOLE, runtime errors, etc.
|
|
61
|
+
*/
|
|
62
|
+
onDebugMessage?: (msg: ShopSenseDebugMessage) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Whether the widget should show itself when the containing screen is focused,
|
|
65
|
+
* and hide when unfocused, while staying mounted for persistence.
|
|
66
|
+
*
|
|
67
|
+
* Default: true (requires ShopSenseProvider).
|
|
68
|
+
*/
|
|
69
|
+
autoShowOnFocus?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ShopSenseWidgetConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_BASE_URL = "https://app.shopsense.pro";
|
|
4
|
+
|
|
5
|
+
function trimTrailingSlash(v: string) {
|
|
6
|
+
return v.replace(/\/+$/, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function ensureLeadingSlash(v: string) {
|
|
10
|
+
if (!v) return "/";
|
|
11
|
+
return v.startsWith("/") ? v : `/${v}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildWidgetUri(config: ShopSenseWidgetConfig): string {
|
|
15
|
+
const base = trimTrailingSlash(config.baseUrl ?? DEFAULT_BASE_URL);
|
|
16
|
+
const path = ensureLeadingSlash(config.widgetPath ?? "/embed/widget.html");
|
|
17
|
+
|
|
18
|
+
const url = new URL(`${base}${path}`);
|
|
19
|
+
url.searchParams.set("zone", String(config.zoneId));
|
|
20
|
+
|
|
21
|
+
if (config.apiBase) {
|
|
22
|
+
url.searchParams.set("apiBase", config.apiBase);
|
|
23
|
+
}
|
|
24
|
+
if (config.uiDensity) {
|
|
25
|
+
url.searchParams.set("uiDensity", config.uiDensity);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const extra = config.extraParams ?? {};
|
|
29
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
30
|
+
if (v === undefined) continue;
|
|
31
|
+
url.searchParams.set(k, String(v));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Force a stable URL ordering for easier debugging.
|
|
35
|
+
url.searchParams.sort();
|
|
36
|
+
|
|
37
|
+
return url.toString();
|
|
38
|
+
}
|
|
39
|
+
|
package/tsconfig.json
ADDED