shopsense-mobile 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/CURSOR_INTEGRATION_PROMPT.md +147 -0
- package/README.md +117 -0
- package/package.json +14 -0
- package/src/PersistentWebView.tsx +296 -0
- package/src/ShopSenseWidget.tsx +109 -0
- package/src/context.tsx +118 -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 +79 -0
- package/src/url.ts +39 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Cursor Agent: Integrate `shopsense-widget` (Expo / React Native)
|
|
2
|
+
|
|
3
|
+
Paste this file into **Cursor Agent**, or click **Open in Cursor** in `README.md` to load the same instructions via Cursor’s prompt deeplink (`cursor.com/link/prompt`).
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
`shopsense-widget` provides `ShopSenseRoot`, `ShopSenseWidget`, and `PersistentWebView` for a ShopSense WebView shell. With `ShopSenseRoot` at the app root, `ShopSenseWidget` registers config and renders **nothing**; the visible UI is `PersistentWebView`.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
1. **Dependencies**
|
|
12
|
+
Ensure the app depends on `shopsense-widget`, `react-native-webview`, and `expo-router` (peer deps).
|
|
13
|
+
|
|
14
|
+
2. **Root layout**
|
|
15
|
+
- Wrap the app in `ShopSenseRoot` once.
|
|
16
|
+
- Do **not** place `PersistentWebView` as a direct **flex sibling** of the Expo `Stack` at root — both would use `flex: 1` and split the screen ~50/50. Either remove root `PersistentWebView` and mount it on the search screen below the app bar, or use a different layout that does not share vertical flex with the navigator.
|
|
17
|
+
|
|
18
|
+
3. **Search screen**
|
|
19
|
+
- Use your existing screen wrapper (e.g. `RootLayout` with `showScrollView={false}`).
|
|
20
|
+
- Ensure the non-scroll branch wraps children in `View` with `{ flex: 1, minHeight: 0 }` so height flows to children.
|
|
21
|
+
- Inside that screen, render:
|
|
22
|
+
- `ShopSenseWidget` with `zoneId`, `baseUrl`, `apiBase`, and optional callbacks (`onProductSelect`, `onDebugMessage`).
|
|
23
|
+
- `PersistentWebView` as a **sibling** in a `View` with `{ flex: 1, minHeight: 0 }` so the WebView fills the area **below** the app bar.
|
|
24
|
+
- Implement **fallback** (native search) using widget-driven status:
|
|
25
|
+
- The package emits `WIDGET_STATUS` and `WEBVIEW_*` events via `onDebugMessage`.
|
|
26
|
+
- Use `statusTimeoutMs` (default `10000`) so a slow/down server fails fast and your UI can switch to fallback.
|
|
27
|
+
- When `WIDGET_STATUS.payload.state === "failed"` (including timeout) or when `WEBVIEW_ERROR/WEBVIEW_HTTP_ERROR` occurs, conditionally render your fallback screen (e.g. `app/app/search.jsx`) instead of leaving the user stuck.
|
|
28
|
+
|
|
29
|
+
4. **Callbacks**
|
|
30
|
+
Wire `onProductSelect` to your navigation or cart flow; optionally log `onDebugMessage` during development.
|
|
31
|
+
|
|
32
|
+
5. **Verify**
|
|
33
|
+
Open the search route: the WebView should fill the full height below the header with no large empty band above the widget.
|
|
34
|
+
|
|
35
|
+
## Minimal screen shape (illustrative)
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { PersistentWebView, ShopSenseWidget } from "shopsense-widget";
|
|
39
|
+
import { View } from "react-native";
|
|
40
|
+
|
|
41
|
+
// Inside your layout component that already renders the app bar:
|
|
42
|
+
<View style={{ flex: 1, minHeight: 0 }}>
|
|
43
|
+
<ShopSenseWidget
|
|
44
|
+
zoneId={YOUR_ZONE_ID}
|
|
45
|
+
baseUrl="https://your-widget-host"
|
|
46
|
+
apiBase="https://your-widget-host/api/api"
|
|
47
|
+
onProductSelect={(product) => {
|
|
48
|
+
/* navigate or handle */
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
<PersistentWebView />
|
|
52
|
+
</View>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Adjust imports and layout names to match your app.
|
|
56
|
+
|
|
57
|
+
## Reference implementation (copy/paste)
|
|
58
|
+
|
|
59
|
+
This is a working example from `app/app/shopsense-search.jsx` showing:
|
|
60
|
+
|
|
61
|
+
- Passing `extraParams` only when logged in
|
|
62
|
+
- Using `statusTimeoutMs` to fail fast
|
|
63
|
+
- Conditional rendering of `app/app/search.jsx` as fallback when the widget fails
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
import { router } from "expo-router";
|
|
67
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
68
|
+
import { View } from "react-native";
|
|
69
|
+
import { useSelector } from "react-redux";
|
|
70
|
+
import { PersistentWebView, ShopSenseWidget } from "shopsense-widget";
|
|
71
|
+
import RootLayout from "../src/modules/shared/RootLayout/RootLayout";
|
|
72
|
+
import { SCREENS } from "../src/modules/shared/constants";
|
|
73
|
+
import { getStateId } from "../src/modules/shared/utils";
|
|
74
|
+
import SearchFallback from "./search";
|
|
75
|
+
|
|
76
|
+
export default function ShopSenseSearchScreen() {
|
|
77
|
+
const token = useSelector((state) => state?.authReducer?.tokens?.accessToken);
|
|
78
|
+
const user = useSelector((state) => state?.authReducer?.user);
|
|
79
|
+
const [showFallback, setShowFallback] = useState(false);
|
|
80
|
+
|
|
81
|
+
const extraParams = useMemo(() => {
|
|
82
|
+
if (!token) return undefined;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
customerTier: user?.tier,
|
|
86
|
+
pricingTier: user?.pricingTier,
|
|
87
|
+
stateId: getStateId?.(user) ?? undefined,
|
|
88
|
+
viewSpecificCategory: user?.viewSpecificCategory,
|
|
89
|
+
viewSpecificProduct: user?.viewSpecificProduct,
|
|
90
|
+
};
|
|
91
|
+
}, [token, user]);
|
|
92
|
+
|
|
93
|
+
const handleDebugMessage = useCallback((msg) => {
|
|
94
|
+
if (msg?.type === "WIDGET_STATUS") {
|
|
95
|
+
const state = msg?.payload?.state;
|
|
96
|
+
if (state === "failed") {
|
|
97
|
+
setShowFallback(true);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (state === "succeeded") {
|
|
101
|
+
setShowFallback(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (msg?.type === "WEBVIEW_ERROR" || msg?.type === "WEBVIEW_HTTP_ERROR") {
|
|
106
|
+
setShowFallback(true);
|
|
107
|
+
}
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
if (showFallback) {
|
|
111
|
+
return <SearchFallback />;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<RootLayout showScrollView={false} title="Search">
|
|
116
|
+
<View style={{ flex: 1, minHeight: 0 }}>
|
|
117
|
+
<ShopSenseWidget
|
|
118
|
+
baseUrl="https://dev.shopsense.pro"
|
|
119
|
+
apiBase="https://dev.shopsense.pro/api/api"
|
|
120
|
+
zoneId={35}
|
|
121
|
+
extraParams={extraParams}
|
|
122
|
+
statusTimeoutMs={10000}
|
|
123
|
+
onProductSelect={() => {
|
|
124
|
+
const product = {
|
|
125
|
+
alias:
|
|
126
|
+
"coca-cola-beverage-soda-can-12oz-ct-12ct-bx-2bx-cs-24ct-cs-original",
|
|
127
|
+
productId: 85721,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
router.push({
|
|
131
|
+
pathname: SCREENS.PRODUCT_DETAILS,
|
|
132
|
+
params: { id: product.productId, alias: product.alias },
|
|
133
|
+
});
|
|
134
|
+
}}
|
|
135
|
+
onDebugMessage={handleDebugMessage}
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
<PersistentWebView />
|
|
139
|
+
</View>
|
|
140
|
+
</RootLayout>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
If you change this file, update the `[cursor-integration]` link at the bottom of `README.md` (re-encode the same `Task:` + reference block with `encodeURIComponent` for `https://cursor.com/link/prompt?text=…`).
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
## shopsense-widget (React Native)
|
|
2
|
+
|
|
3
|
+
Thin WebView shell for the ShopSense widget.
|
|
4
|
+
|
|
5
|
+
### Cursor: implement this package in one click
|
|
6
|
+
|
|
7
|
+
Click **[ⓘ Open integration prompt in Cursor][cursor-integration]** to open Cursor with a pre-filled Agent prompt (official [`cursor.com/link/prompt`](https://cursor.com/docs/integrations/deeplinks#prompts) deeplink). It includes the full checklist from [`CURSOR_INTEGRATION_PROMPT.md`](./CURSOR_INTEGRATION_PROMPT.md). If the link does not open the app, paste that file’s contents into **Cursor Agent** instead.
|
|
8
|
+
|
|
9
|
+
### Goals
|
|
10
|
+
|
|
11
|
+
- **No UI owned by the package**: all HTML/JS/CSS is served by your server.
|
|
12
|
+
- Package only:
|
|
13
|
+
- Renders a WebView pointing to your server page
|
|
14
|
+
- Passes widget config (zone + optional params) via query params
|
|
15
|
+
- Bridges product selections back to native (callback or default deep link)
|
|
16
|
+
- Can keep the WebView **persisted** (depends on where you mount `PersistentWebView`; root vs screen trade-offs are described below)
|
|
17
|
+
|
|
18
|
+
### Install
|
|
19
|
+
|
|
20
|
+
Add the package to your monorepo and import from it. Peer deps must exist in the app:
|
|
21
|
+
|
|
22
|
+
- `react`
|
|
23
|
+
- `react-native`
|
|
24
|
+
- `react-native-webview`
|
|
25
|
+
- `expo-router`
|
|
26
|
+
|
|
27
|
+
### Usage
|
|
28
|
+
|
|
29
|
+
#### 1) Wrap your app root once
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { ShopSenseRoot } from "shopsense-widget";
|
|
33
|
+
|
|
34
|
+
export default function RootLayout() {
|
|
35
|
+
return (
|
|
36
|
+
<ShopSenseRoot>
|
|
37
|
+
{/* your app routes */}
|
|
38
|
+
</ShopSenseRoot>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
#### 2) Mount `PersistentWebView` (layout matters)
|
|
44
|
+
|
|
45
|
+
`PersistentWebView` must not be a **flex sibling** of your root navigator (e.g. Expo Router `Stack`) with both using `flex: 1` — that splits the screen ~50/50 and shrinks the WebView.
|
|
46
|
+
|
|
47
|
+
**Recommended:** mount `PersistentWebView` on the screen that shows the widget, **below** your app bar, inside a `View` with `{ flex: 1, minHeight: 0 }`, next to `ShopSenseWidget` (see step 3). That fills the height under the header correctly.
|
|
48
|
+
|
|
49
|
+
**Alternative (single root mount):** keep one `PersistentWebView` at the app root only if you position it so it does not share vertical flex with the navigator (for example absolute fill behind transparent content — more complex). Prefer co-locating with the search screen unless you need the WebView to stay mounted across all routes.
|
|
50
|
+
|
|
51
|
+
#### 3) Register config + callbacks on your Search screen
|
|
52
|
+
|
|
53
|
+
Render `ShopSenseWidget` on the screen where you want it active. It registers config/callbacks and toggles show/hide on focus. Render `PersistentWebView` in the **same** flex container (below your header) so the WebView receives height.
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
import { PersistentWebView, ShopSenseWidget } from "shopsense-widget";
|
|
57
|
+
import { View } from "react-native";
|
|
58
|
+
|
|
59
|
+
export default function SearchScreen() {
|
|
60
|
+
return (
|
|
61
|
+
<View style={{ flex: 1, minHeight: 0 }}>
|
|
62
|
+
<ShopSenseWidget
|
|
63
|
+
zoneId={35}
|
|
64
|
+
// Optional: fail fast if the widget doesn't become ready (default: 10000ms)
|
|
65
|
+
statusTimeoutMs={10000}
|
|
66
|
+
onProductSelect={(product) => {
|
|
67
|
+
// override default deep link navigation
|
|
68
|
+
// product = { id, alias }
|
|
69
|
+
}}
|
|
70
|
+
onDebugMessage={(msg) => {
|
|
71
|
+
// optional: WIDGET_STATUS / console / errors
|
|
72
|
+
console.log(msg);
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
<PersistentWebView />
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Wrap that block in your own layout (app bar, safe area) as needed.
|
|
82
|
+
|
|
83
|
+
### Configuration
|
|
84
|
+
|
|
85
|
+
Only `zoneId` is required.
|
|
86
|
+
|
|
87
|
+
All other config is optional and depends on how your widget shell is hosted (defaults are handled by the package and your shell).
|
|
88
|
+
|
|
89
|
+
#### `statusTimeoutMs`
|
|
90
|
+
|
|
91
|
+
Optional. Maximum time (ms) to wait for the widget to report `WIDGET_STATUS: succeeded` after a WebView load starts.
|
|
92
|
+
|
|
93
|
+
- **default**: `10000`
|
|
94
|
+
- **behavior**: on timeout, the package emits `WIDGET_STATUS` with `payload.state = "failed"` and `payload.error.message = "timeout"` so your app can conditionally render a fallback UI.
|
|
95
|
+
|
|
96
|
+
### Server contract
|
|
97
|
+
|
|
98
|
+
Your server must host an HTML page (default path: `/embed/widget.html`) that:
|
|
99
|
+
|
|
100
|
+
- Reads query params such as: `zone` (and any optional parameters you support)
|
|
101
|
+
- Loads the ShopSense widget JS/CSS from your server/CDN
|
|
102
|
+
- Posts `NAVIGATE_TO_PRODUCT` to the React Native WebView bridge when a product is tapped:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{ "type": "NAVIGATE_TO_PRODUCT", "payload": { "id": 123, "alias": "slug" } }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Default navigation behavior
|
|
109
|
+
|
|
110
|
+
If `onProductSelect` is **not** provided, the package opens:
|
|
111
|
+
|
|
112
|
+
`app.shopsense://product/:id/:alias`
|
|
113
|
+
|
|
114
|
+
Your app must register the deep link scheme for this to work.
|
|
115
|
+
|
|
116
|
+
[cursor-integration]: https://cursor.com/link/prompt?text=Task%3A%20Integrate%20the%20%60shopsense-widget%60%20package%20into%20this%20Expo%20(expo-router)%20React%20Native%20app.%0A%0AFirst%20open%20and%20follow%20%60packages%2Fshopsense-widget%2FCURSOR_INTEGRATION_PROMPT.md%60%20in%20the%20workspace.%20Implement%20all%20steps%20there.%0A%0A---%20Reference%20(same%20as%20that%20file)%20---%0A%23%20Cursor%20Agent%3A%20Integrate%20%60shopsense-widget%60%20(Expo%20%2F%20React%20Native)%0A%0APaste%20this%20file%20into%20**Cursor%20Agent**%2C%20or%20click%20**Open%20in%20Cursor**%20in%20%60README.md%60%20to%20load%20the%20same%20instructions%20via%20Cursor%E2%80%99s%20prompt%20deeplink%20(%60cursor.com%2Flink%2Fprompt%60).%0A%0A%23%23%20Context%0A%0A%60shopsense-widget%60%20provides%20%60ShopSenseRoot%60%2C%20%60ShopSenseWidget%60%2C%20and%20%60PersistentWebView%60%20for%20a%20ShopSense%20WebView%20shell.%20With%20%60ShopSenseRoot%60%20at%20the%20app%20root%2C%20%60ShopSenseWidget%60%20registers%20config%20and%20renders%20**nothing**%3B%20the%20visible%20UI%20is%20%60PersistentWebView%60.%0A%0A%23%23%20Requirements%0A%0A1.%20**Dependencies**%20%20%0A%20%20%20Ensure%20the%20app%20depends%20on%20%60shopsense-widget%60%2C%20%60react-native-webview%60%2C%20and%20%60expo-router%60%20(peer%20deps).%0A%0A2.%20**Root%20layout**%20%20%0A%20%20%20-%20Wrap%20the%20app%20in%20%60ShopSenseRoot%60%20once.%20%20%0A%20%20%20-%20Do%20**not**%20place%20%60PersistentWebView%60%20as%20a%20direct%20**flex%20sibling**%20of%20the%20Expo%20%60Stack%60%20at%20root%20%E2%80%94%20both%20would%20use%20%60flex%3A%201%60%20and%20split%20the%20screen%20~50%2F50.%20Either%20remove%20root%20%60PersistentWebView%60%20and%20mount%20it%20on%20the%20search%20screen%20below%20the%20app%20bar%2C%20or%20use%20a%20different%20layout%20that%20does%20not%20share%20vertical%20flex%20with%20the%20navigator.%0A%0A3.%20**Search%20screen**%20%20%0A%20%20%20-%20Use%20your%20existing%20screen%20wrapper%20(e.g.%20%60RootLayout%60%20with%20%60showScrollView%3D%7Bfalse%7D%60).%20%20%0A%20%20%20-%20Ensure%20the%20non-scroll%20branch%20wraps%20children%20in%20%60View%60%20with%20%60%7B%20flex%3A%201%2C%20minHeight%3A%200%20%7D%60%20so%20height%20flows%20to%20children.%20%20%0A%20%20%20-%20Inside%20that%20screen%2C%20render%3A%0A%20%20%20%20%20-%20%60ShopSenseWidget%60%20with%20%60zoneId%60%2C%20%60baseUrl%60%2C%20%60apiBase%60%2C%20and%20optional%20callbacks%20(%60onProductSelect%60%2C%20%60onDebugMessage%60).%20%20%0A%20%20%20%20%20-%20%60PersistentWebView%60%20as%20a%20**sibling**%20in%20a%20%60View%60%20with%20%60%7B%20flex%3A%201%2C%20minHeight%3A%200%20%7D%60%20so%20the%20WebView%20fills%20the%20area%20**below**%20the%20app%20bar.%0A%20%20%20-%20Implement%20**fallback**%20(native%20search)%20using%20widget-driven%20status%3A%0A%20%20%20%20%20-%20The%20package%20emits%20%60WIDGET_STATUS%60%20and%20%60WEBVIEW_*%60%20events%20via%20%60onDebugMessage%60.%0A%20%20%20%20%20-%20Use%20%60statusTimeoutMs%60%20(default%20%6010000%60)%20so%20a%20slow%2Fdown%20server%20fails%20fast%20and%20your%20UI%20can%20switch%20to%20fallback.%0A%20%20%20%20%20-%20When%20%60WIDGET_STATUS.payload.state%20%3D%3D%3D%20%22failed%22%60%20(including%20timeout)%20or%20when%20%60WEBVIEW_ERROR%2FWEBVIEW_HTTP_ERROR%60%20occurs%2C%20conditionally%20render%20your%20fallback%20screen%20(e.g.%20%60app%2Fapp%2Fsearch.jsx%60)%20instead%20of%20leaving%20the%20user%20stuck.%0A%0A4.%20**Callbacks**%20%20%0A%20%20%20Wire%20%60onProductSelect%60%20to%20your%20navigation%20or%20cart%20flow%3B%20optionally%20log%20%60onDebugMessage%60%20during%20development.%0A%0A5.%20**Verify**%20%20%0A%20%20%20Open%20the%20search%20route%3A%20the%20WebView%20should%20fill%20the%20full%20height%20below%20the%20header%20with%20no%20large%20empty%20band%20above%20the%20widget.%0A%0A%23%23%20Minimal%20screen%20shape%20(illustrative)%0A%0A%60%60%60tsx%0Aimport%20%7B%20PersistentWebView%2C%20ShopSenseWidget%20%7D%20from%20%22shopsense-widget%22%3B%0Aimport%20%7B%20View%20%7D%20from%20%22react-native%22%3B%0A%0A%2F%2F%20Inside%20your%20layout%20component%20that%20already%20renders%20the%20app%20bar%3A%0A%3CView%20style%3D%7B%7B%20flex%3A%201%2C%20minHeight%3A%200%20%7D%7D%3E%0A%20%20%3CShopSenseWidget%0A%20%20%20%20zoneId%3D%7BYOUR_ZONE_ID%7D%0A%20%20%20%20baseUrl%3D%22https%3A%2F%2Fyour-widget-host%22%0A%20%20%20%20apiBase%3D%22https%3A%2F%2Fyour-widget-host%2Fapi%2Fapi%22%0A%20%20%20%20onProductSelect%3D%7B(product)%20%3D%3E%20%7B%0A%20%20%20%20%20%20%2F*%20navigate%20or%20handle%20*%2F%0A%20%20%20%20%7D%7D%0A%20%20%2F%3E%0A%20%20%3CPersistentWebView%20%2F%3E%0A%3C%2FView%3E%0A%60%60%60%0A%0AAdjust%20imports%20and%20layout%20names%20to%20match%20your%20app.%0A%0A%23%23%20Reference%20implementation%20(copy%2Fpaste)%0A%0AThis%20is%20a%20working%20example%20from%20%60app%2Fapp%2Fshopsense-search.jsx%60%20showing%3A%0A%0A-%20Passing%20%60extraParams%60%20only%20when%20logged%20in%0A-%20Using%20%60statusTimeoutMs%60%20to%20fail%20fast%0A-%20Conditional%20rendering%20of%20%60app%2Fapp%2Fsearch.jsx%60%20as%20fallback%20when%20the%20widget%20fails%0A%0A%60%60%60jsx%0Aimport%20%7B%20router%20%7D%20from%20%22expo-router%22%3B%0Aimport%20React%2C%20%7B%20useCallback%2C%20useMemo%2C%20useState%20%7D%20from%20%22react%22%3B%0Aimport%20%7B%20View%20%7D%20from%20%22react-native%22%3B%0Aimport%20%7B%20useSelector%20%7D%20from%20%22react-redux%22%3B%0Aimport%20%7B%20PersistentWebView%2C%20ShopSenseWidget%20%7D%20from%20%22shopsense-widget%22%3B%0Aimport%20RootLayout%20from%20%22..%2Fsrc%2Fmodules%2Fshared%2FRootLayout%2FRootLayout%22%3B%0Aimport%20%7B%20SCREENS%20%7D%20from%20%22..%2Fsrc%2Fmodules%2Fshared%2Fconstants%22%3B%0Aimport%20%7B%20getStateId%20%7D%20from%20%22..%2Fsrc%2Fmodules%2Fshared%2Futils%22%3B%0Aimport%20SearchFallback%20from%20%22.%2Fsearch%22%3B%0A%0Aexport%20default%20function%20ShopSenseSearchScreen()%20%7B%0A%20%20const%20token%20%3D%20useSelector((state)%20%3D%3E%20state%3F.authReducer%3F.tokens%3F.accessToken)%3B%0A%20%20const%20user%20%3D%20useSelector((state)%20%3D%3E%20state%3F.authReducer%3F.user)%3B%0A%20%20const%20%5BshowFallback%2C%20setShowFallback%5D%20%3D%20useState(false)%3B%0A%0A%20%20const%20extraParams%20%3D%20useMemo(()%20%3D%3E%20%7B%0A%20%20%20%20if%20(!token)%20return%20undefined%3B%0A%0A%20%20%20%20return%20%7B%0A%20%20%20%20%20%20customerTier%3A%20user%3F.tier%2C%0A%20%20%20%20%20%20pricingTier%3A%20user%3F.pricingTier%2C%0A%20%20%20%20%20%20stateId%3A%20getStateId%3F.(user)%20%3F%3F%20undefined%2C%0A%20%20%20%20%20%20viewSpecificCategory%3A%20user%3F.viewSpecificCategory%2C%0A%20%20%20%20%20%20viewSpecificProduct%3A%20user%3F.viewSpecificProduct%2C%0A%20%20%20%20%7D%3B%0A%20%20%7D%2C%20%5Btoken%2C%20user%5D)%3B%0A%0A%20%20const%20handleDebugMessage%20%3D%20useCallback((msg)%20%3D%3E%20%7B%0A%20%20%20%20if%20(msg%3F.type%20%3D%3D%3D%20%22WIDGET_STATUS%22)%20%7B%0A%20%20%20%20%20%20const%20state%20%3D%20msg%3F.payload%3F.state%3B%0A%20%20%20%20%20%20if%20(state%20%3D%3D%3D%20%22failed%22)%20%7B%0A%20%20%20%20%20%20%20%20setShowFallback(true)%3B%0A%20%20%20%20%20%20%20%20return%3B%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20if%20(state%20%3D%3D%3D%20%22succeeded%22)%20%7B%0A%20%20%20%20%20%20%20%20setShowFallback(false)%3B%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%0A%20%20%20%20if%20(msg%3F.type%20%3D%3D%3D%20%22WEBVIEW_ERROR%22%20%7C%7C%20msg%3F.type%20%3D%3D%3D%20%22WEBVIEW_HTTP_ERROR%22)%20%7B%0A%20%20%20%20%20%20setShowFallback(true)%3B%0A%20%20%20%20%7D%0A%20%20%7D%2C%20%5B%5D)%3B%0A%0A%20%20if%20(showFallback)%20%7B%0A%20%20%20%20return%20%3CSearchFallback%20%2F%3E%3B%0A%20%20%7D%0A%0A%20%20return%20(%0A%20%20%20%20%3CRootLayout%20showScrollView%3D%7Bfalse%7D%20title%3D%22Search%22%3E%0A%20%20%20%20%20%20%3CView%20style%3D%7B%7B%20flex%3A%201%2C%20minHeight%3A%200%20%7D%7D%3E%0A%20%20%20%20%20%20%20%20%3CShopSenseWidget%0A%20%20%20%20%20%20%20%20%20%20baseUrl%3D%22https%3A%2F%2Fdev.shopsense.pro%22%0A%20%20%20%20%20%20%20%20%20%20apiBase%3D%22https%3A%2F%2Fdev.shopsense.pro%2Fapi%2Fapi%22%0A%20%20%20%20%20%20%20%20%20%20zoneId%3D%7B35%7D%0A%20%20%20%20%20%20%20%20%20%20extraParams%3D%7BextraParams%7D%0A%20%20%20%20%20%20%20%20%20%20statusTimeoutMs%3D%7B10000%7D%0A%20%20%20%20%20%20%20%20%20%20onProductSelect%3D%7B()%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20const%20product%20%3D%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20alias%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22coca-cola-beverage-soda-can-12oz-ct-12ct-bx-2bx-cs-24ct-cs-original%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20productId%3A%2085721%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%3B%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20router.push(%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20pathname%3A%20SCREENS.PRODUCT_DETAILS%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20params%3A%20%7B%20id%3A%20product.productId%2C%20alias%3A%20product.alias%20%7D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D)%3B%0A%20%20%20%20%20%20%20%20%20%20%7D%7D%0A%20%20%20%20%20%20%20%20%20%20onDebugMessage%3D%7BhandleDebugMessage%7D%0A%20%20%20%20%20%20%20%20%2F%3E%0A%0A%20%20%20%20%20%20%20%20%3CPersistentWebView%20%2F%3E%0A%20%20%20%20%20%20%3C%2FView%3E%0A%20%20%20%20%3C%2FRootLayout%3E%0A%20%20)%3B%0A%7D%0A%60%60%60%0A%0A---%0A%0AIf%20you%20change%20this%20file%2C%20update%20the%20%60%5Bcursor-integration%5D%60%20link%20at%20the%20bottom%20of%20%60README.md%60%20(re-encode%20the%20same%20%60Task%3A%60%20%2B%20reference%20block%20with%20%60encodeURIComponent%60%20for%20%60https%3A%2F%2Fcursor.com%2Flink%2Fprompt%3Ftext%3D%E2%80%A6%60).%0A
|
|
117
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shopsense-mobile",
|
|
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,296 @@
|
|
|
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, statusTimeoutMs } =
|
|
16
|
+
useShopSense();
|
|
17
|
+
const lastLoggedUrlRef = useRef(null as string | null);
|
|
18
|
+
const statusBridgeInstalledRef = useRef(false);
|
|
19
|
+
const statusTimeoutRef = useRef(null as ReturnType<typeof setTimeout> | null);
|
|
20
|
+
const lastStatusStateRef = useRef(null as string | null);
|
|
21
|
+
|
|
22
|
+
const clearStatusTimeout = useCallback(() => {
|
|
23
|
+
if (statusTimeoutRef.current) {
|
|
24
|
+
clearTimeout(statusTimeoutRef.current);
|
|
25
|
+
statusTimeoutRef.current = null;
|
|
26
|
+
}
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const uri = useMemo(() => {
|
|
30
|
+
if (!config) return null;
|
|
31
|
+
return buildWidgetUri(config);
|
|
32
|
+
}, [config]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (uri) {
|
|
36
|
+
console.log("[ShopSenseWebView] initial uri:", uri);
|
|
37
|
+
}
|
|
38
|
+
}, [uri]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!visible) return;
|
|
42
|
+
const wv = webViewRef?.current as any;
|
|
43
|
+
if (!wv?.injectJavaScript) return;
|
|
44
|
+
|
|
45
|
+
// When returning from a native screen (Product → Back), the WebView doesn't get a
|
|
46
|
+
// browser "pageshow" event. If Layout 2 was hidden by the widget runtime, we need
|
|
47
|
+
// to re-show the existing container so results re-appear without requiring typing.
|
|
48
|
+
//
|
|
49
|
+
// Important: do NOT dispatch `hashchange` here — that forces a refetch. We only
|
|
50
|
+
// want to restore the already-rendered view/state.
|
|
51
|
+
wv.injectJavaScript(
|
|
52
|
+
`(function(){try{
|
|
53
|
+
var el = document.getElementById('lookin-widget-container');
|
|
54
|
+
if (el) {
|
|
55
|
+
el.style.display = 'block';
|
|
56
|
+
el.style.visibility = 'visible';
|
|
57
|
+
el.style.opacity = '1';
|
|
58
|
+
}
|
|
59
|
+
document.dispatchEvent(new CustomEvent('lookin:layout2'));
|
|
60
|
+
}catch(e){};return true;})();`
|
|
61
|
+
);
|
|
62
|
+
}, [visible, webViewRef]);
|
|
63
|
+
|
|
64
|
+
const handleMessage = useCallback(
|
|
65
|
+
(event: WebViewMessageEvent) => {
|
|
66
|
+
const raw = event?.nativeEvent?.data;
|
|
67
|
+
const msg = tryParseJsonMessage(raw);
|
|
68
|
+
|
|
69
|
+
// Debug messages
|
|
70
|
+
if (isDebugMessage(msg)) {
|
|
71
|
+
if (msg?.type === "WIDGET_STATUS") {
|
|
72
|
+
const nextState =
|
|
73
|
+
typeof (msg as any)?.payload?.state === "string"
|
|
74
|
+
? String((msg as any).payload.state)
|
|
75
|
+
: null;
|
|
76
|
+
if (nextState) {
|
|
77
|
+
lastStatusStateRef.current = nextState;
|
|
78
|
+
if (nextState === "succeeded" || nextState === "failed") {
|
|
79
|
+
clearStatusTimeout();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
callbacksRef.current.onDebugMessage?.(msg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const o = msg && typeof msg === "object" ? (msg as any) : null;
|
|
87
|
+
if (o?.type === "NAVIGATE_TO_PRODUCT") {
|
|
88
|
+
// Snapshot the last known URL before handing off to native navigation.
|
|
89
|
+
// This helps with persistence/debug when users return via Back.
|
|
90
|
+
lastWebViewUrlRef.current =
|
|
91
|
+
lastWebViewUrlRef.current ?? (o?.payload as any)?.productUrl ?? null;
|
|
92
|
+
|
|
93
|
+
const payload = o?.payload;
|
|
94
|
+
const product = extractProductSelect(payload);
|
|
95
|
+
|
|
96
|
+
if (product) {
|
|
97
|
+
if (callbacksRef.current.onProductSelect) {
|
|
98
|
+
callbacksRef.current.onProductSelect(product);
|
|
99
|
+
} else {
|
|
100
|
+
defaultOpenProduct(product);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
callbacksRef.current.onMessage?.(msg);
|
|
106
|
+
},
|
|
107
|
+
[callbacksRef]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const injectStatusBridge = useCallback(() => {
|
|
111
|
+
const wv = webViewRef?.current as any;
|
|
112
|
+
if (!wv?.injectJavaScript) return;
|
|
113
|
+
|
|
114
|
+
// Install once per WebView lifetime; it survives navigations within the same document.
|
|
115
|
+
if (statusBridgeInstalledRef.current) return;
|
|
116
|
+
statusBridgeInstalledRef.current = true;
|
|
117
|
+
|
|
118
|
+
wv.injectJavaScript(
|
|
119
|
+
`(function(){try{
|
|
120
|
+
if (window.__shopsenseStatusBridgeInstalled) return true;
|
|
121
|
+
window.__shopsenseStatusBridgeInstalled = true;
|
|
122
|
+
|
|
123
|
+
var lastKey = '';
|
|
124
|
+
function post(type, payload){
|
|
125
|
+
try{
|
|
126
|
+
var wv = window.ReactNativeWebView;
|
|
127
|
+
if (!wv || !wv.postMessage) return;
|
|
128
|
+
wv.postMessage(JSON.stringify({ type: type, payload: payload, source: 'web' }));
|
|
129
|
+
}catch(e){}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function snapshotStatus(){
|
|
133
|
+
try{
|
|
134
|
+
var s = window.ShopSenseWidgetStatus;
|
|
135
|
+
if (!s) return;
|
|
136
|
+
var key = '';
|
|
137
|
+
try{ key = JSON.stringify(s); }catch(e){ key = String(s && s.state); }
|
|
138
|
+
if (key && key !== lastKey) {
|
|
139
|
+
lastKey = key;
|
|
140
|
+
post('WIDGET_STATUS', s);
|
|
141
|
+
}
|
|
142
|
+
}catch(e){}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Send as soon as possible, then poll for updates.
|
|
146
|
+
snapshotStatus();
|
|
147
|
+
window.addEventListener('load', snapshotStatus, { once: true });
|
|
148
|
+
setInterval(snapshotStatus, 500);
|
|
149
|
+
}catch(e){};return true;})();`
|
|
150
|
+
);
|
|
151
|
+
}, [webViewRef]);
|
|
152
|
+
|
|
153
|
+
if (!uri) return null;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<View
|
|
157
|
+
pointerEvents={visible ? "auto" : "none"}
|
|
158
|
+
style={[styles.container, !visible && styles.hidden]}
|
|
159
|
+
>
|
|
160
|
+
<WebView
|
|
161
|
+
ref={(r: any) => {
|
|
162
|
+
webViewRef.current = r;
|
|
163
|
+
}}
|
|
164
|
+
source={{ uri }}
|
|
165
|
+
onMessage={handleMessage}
|
|
166
|
+
onShouldStartLoadWithRequest={(req: any) => {
|
|
167
|
+
const nextUrl = typeof req?.url === "string" ? req.url : null;
|
|
168
|
+
if (nextUrl && lastLoggedUrlRef.current !== nextUrl) {
|
|
169
|
+
lastLoggedUrlRef.current = nextUrl;
|
|
170
|
+
console.log("[ShopSenseWebView] url:", nextUrl);
|
|
171
|
+
}
|
|
172
|
+
callbacksRef.current.onDebugMessage?.({
|
|
173
|
+
type: "WEBVIEW_SHOULD_START",
|
|
174
|
+
source: "native",
|
|
175
|
+
payload: {
|
|
176
|
+
url: req?.url,
|
|
177
|
+
navigationType: (req as any)?.navigationType,
|
|
178
|
+
isTopFrame: (req as any)?.isTopFrame,
|
|
179
|
+
mainDocumentURL: (req as any)?.mainDocumentURL,
|
|
180
|
+
hasTargetFrame: (req as any)?.hasTargetFrame,
|
|
181
|
+
method: (req as any)?.method,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return true;
|
|
185
|
+
}}
|
|
186
|
+
onNavigationStateChange={(navState: any) => {
|
|
187
|
+
if (typeof navState?.url === "string") {
|
|
188
|
+
lastWebViewUrlRef.current = navState.url;
|
|
189
|
+
if (lastLoggedUrlRef.current !== navState.url) {
|
|
190
|
+
lastLoggedUrlRef.current = navState.url;
|
|
191
|
+
console.log("[ShopSenseWebView] url:", navState.url);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
callbacksRef.current.onDebugMessage?.({
|
|
195
|
+
type: "WEBVIEW_NAV_STATE",
|
|
196
|
+
source: "native",
|
|
197
|
+
payload: {
|
|
198
|
+
url: navState?.url,
|
|
199
|
+
title: (navState as any)?.title,
|
|
200
|
+
loading: navState?.loading,
|
|
201
|
+
canGoBack: navState?.canGoBack,
|
|
202
|
+
canGoForward: navState?.canGoForward,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}}
|
|
206
|
+
onLoadStart={(e: any) => {
|
|
207
|
+
callbacksRef.current.onDebugMessage?.({
|
|
208
|
+
type: "WEBVIEW_LOAD_START",
|
|
209
|
+
source: "native",
|
|
210
|
+
payload: { url: e?.nativeEvent?.url },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
clearStatusTimeout();
|
|
214
|
+
lastStatusStateRef.current = null;
|
|
215
|
+
|
|
216
|
+
const timeoutMs =
|
|
217
|
+
typeof statusTimeoutMs === "number" ? statusTimeoutMs : 10000;
|
|
218
|
+
if (timeoutMs > 0) {
|
|
219
|
+
statusTimeoutRef.current = setTimeout(() => {
|
|
220
|
+
// Only fail if we didn't already get a terminal status.
|
|
221
|
+
if (
|
|
222
|
+
lastStatusStateRef.current === "succeeded" ||
|
|
223
|
+
lastStatusStateRef.current === "failed"
|
|
224
|
+
) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
callbacksRef.current.onDebugMessage?.({
|
|
228
|
+
type: "WIDGET_STATUS",
|
|
229
|
+
source: "native",
|
|
230
|
+
payload: {
|
|
231
|
+
state: "failed",
|
|
232
|
+
updatedAt: Date.now(),
|
|
233
|
+
error: { message: "timeout" },
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}, timeoutMs);
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
onLoadEnd={(e: any) => {
|
|
240
|
+
callbacksRef.current.onDebugMessage?.({
|
|
241
|
+
type: "WEBVIEW_LOAD_END",
|
|
242
|
+
source: "native",
|
|
243
|
+
payload: { url: e?.nativeEvent?.url },
|
|
244
|
+
});
|
|
245
|
+
// Ensure we can observe widget loader status (window.ShopSenseWidgetStatus)
|
|
246
|
+
// and forward it as WIDGET_STATUS via the normal onDebugMessage pipeline.
|
|
247
|
+
injectStatusBridge();
|
|
248
|
+
}}
|
|
249
|
+
onError={(e: any) => {
|
|
250
|
+
clearStatusTimeout();
|
|
251
|
+
callbacksRef.current.onDebugMessage?.({
|
|
252
|
+
type: "WEBVIEW_ERROR",
|
|
253
|
+
source: "native",
|
|
254
|
+
payload: e?.nativeEvent,
|
|
255
|
+
});
|
|
256
|
+
}}
|
|
257
|
+
onHttpError={(e: any) => {
|
|
258
|
+
clearStatusTimeout();
|
|
259
|
+
callbacksRef.current.onDebugMessage?.({
|
|
260
|
+
type: "WEBVIEW_HTTP_ERROR",
|
|
261
|
+
source: "native",
|
|
262
|
+
payload: e?.nativeEvent,
|
|
263
|
+
});
|
|
264
|
+
}}
|
|
265
|
+
javaScriptEnabled
|
|
266
|
+
domStorageEnabled
|
|
267
|
+
startInLoadingState
|
|
268
|
+
mixedContentMode="compatibility"
|
|
269
|
+
keyboardDisplayRequiresUserAction={false}
|
|
270
|
+
scrollEnabled
|
|
271
|
+
nestedScrollEnabled
|
|
272
|
+
bounces
|
|
273
|
+
showsVerticalScrollIndicator
|
|
274
|
+
// Keep the WebView mounted & stateful; hide/show via styles above.
|
|
275
|
+
style={styles.webview}
|
|
276
|
+
/>
|
|
277
|
+
</View>
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const styles = StyleSheet.create({
|
|
282
|
+
container: {
|
|
283
|
+
...StyleSheet.absoluteFillObject,
|
|
284
|
+
backgroundColor: "#fff",
|
|
285
|
+
overflow: "hidden",
|
|
286
|
+
},
|
|
287
|
+
hidden: {
|
|
288
|
+
// Keep the WebView mounted & full size but invisible (so it can preload).
|
|
289
|
+
opacity: 0,
|
|
290
|
+
},
|
|
291
|
+
webview: {
|
|
292
|
+
flex: 1,
|
|
293
|
+
backgroundColor: "transparent",
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
statusTimeoutMs,
|
|
15
|
+
autoShowOnFocus = true,
|
|
16
|
+
...config
|
|
17
|
+
} = props;
|
|
18
|
+
|
|
19
|
+
const ctx = useShopSense();
|
|
20
|
+
const { callbacksRef, setConfig, setStatusTimeoutMs, show, hide } = ctx;
|
|
21
|
+
|
|
22
|
+
// Stabilize the config object so we don't call setConfig on every render.
|
|
23
|
+
const stableConfig = useMemo(() => {
|
|
24
|
+
const extraParamsKey = config.extraParams
|
|
25
|
+
? JSON.stringify(config.extraParams)
|
|
26
|
+
: "";
|
|
27
|
+
return {
|
|
28
|
+
baseUrl: config.baseUrl,
|
|
29
|
+
zoneId: config.zoneId,
|
|
30
|
+
apiBase: config.apiBase,
|
|
31
|
+
uiDensity: config.uiDensity,
|
|
32
|
+
widgetPath: config.widgetPath,
|
|
33
|
+
extraParams: config.extraParams,
|
|
34
|
+
// internal key for memo deps
|
|
35
|
+
__extraParamsKey: extraParamsKey,
|
|
36
|
+
} as const;
|
|
37
|
+
}, [
|
|
38
|
+
config.baseUrl,
|
|
39
|
+
config.zoneId,
|
|
40
|
+
config.apiBase,
|
|
41
|
+
config.uiDensity,
|
|
42
|
+
config.widgetPath,
|
|
43
|
+
config.extraParams ? JSON.stringify(config.extraParams) : "",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Keep latest callbacks without re-mounting the WebView.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
callbacksRef.current = { onProductSelect, onMessage, onDebugMessage };
|
|
49
|
+
}, [callbacksRef, onProductSelect, onMessage, onDebugMessage]);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (typeof statusTimeoutMs === "number") {
|
|
53
|
+
setStatusTimeoutMs(statusTimeoutMs);
|
|
54
|
+
}
|
|
55
|
+
}, [setStatusTimeoutMs, statusTimeoutMs]);
|
|
56
|
+
|
|
57
|
+
// Update config.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
// Strip internal key before storing
|
|
60
|
+
const { __extraParamsKey, ...toStore } = stableConfig as any;
|
|
61
|
+
setConfig(toStore);
|
|
62
|
+
}, [setConfig, stableConfig]);
|
|
63
|
+
|
|
64
|
+
// Auto show/hide based on screen focus (persistent WebView stays mounted).
|
|
65
|
+
useFocusEffect(
|
|
66
|
+
useCallback(() => {
|
|
67
|
+
if (!autoShowOnFocus) return () => {};
|
|
68
|
+
show();
|
|
69
|
+
return () => hide();
|
|
70
|
+
}, [autoShowOnFocus, hide, show])
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// This component itself renders nothing; the provider renders the WebView.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Public component API.
|
|
79
|
+
*
|
|
80
|
+
* If rendered within a `ShopSenseProvider`, it registers config + callbacks and uses the
|
|
81
|
+
* provider's persistent WebView.
|
|
82
|
+
*
|
|
83
|
+
* If rendered without a provider, it self-hosts the provider and persistent WebView.
|
|
84
|
+
*/
|
|
85
|
+
export const ShopSenseWidget = memo(function ShopSenseWidget(props: ShopSenseWidgetProps) {
|
|
86
|
+
const ctx = useShopSenseOptional();
|
|
87
|
+
|
|
88
|
+
if (ctx) {
|
|
89
|
+
return <ShopSenseWidgetInner {...props} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<ShopSenseProvider>
|
|
94
|
+
<View style={{ flex: 1, minHeight: 0 }}>
|
|
95
|
+
<ShopSenseWidgetInner {...props} />
|
|
96
|
+
<PersistentWebView />
|
|
97
|
+
</View>
|
|
98
|
+
</ShopSenseProvider>
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Place once at app root. It only provides context — render `PersistentWebView`
|
|
104
|
+
* inside your search screen (below AppBar) so the WebView does not cover the app chrome.
|
|
105
|
+
*/
|
|
106
|
+
export function ShopSenseRoot({ children }: { children: ReactNode }) {
|
|
107
|
+
return <ShopSenseProvider>{children}</ShopSenseProvider>;
|
|
108
|
+
}
|
|
109
|
+
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
/** Widget status timeout override (ms). */
|
|
33
|
+
statusTimeoutMs: number;
|
|
34
|
+
setStatusTimeoutMs: (ms: number) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const ShopSenseContext = createContext(null as ShopSenseContextValue | null);
|
|
38
|
+
|
|
39
|
+
export function ShopSenseProvider({ children }: { children?: ReactNode }) {
|
|
40
|
+
const [config, setConfigState] = useState(null as ShopSenseWidgetConfig | null);
|
|
41
|
+
const [visible, setVisible] = useState(false);
|
|
42
|
+
const [statusTimeoutMs, setStatusTimeoutMsState] = useState(10000);
|
|
43
|
+
|
|
44
|
+
const webViewRef = useRef(null as unknown);
|
|
45
|
+
const lastWebViewUrlRef = useRef(null as string | null);
|
|
46
|
+
const callbacksRef = useRef(
|
|
47
|
+
{} as Pick<
|
|
48
|
+
ShopSenseWidgetProps,
|
|
49
|
+
"onProductSelect" | "onMessage" | "onDebugMessage"
|
|
50
|
+
>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const lastConfigKeyRef = useRef("");
|
|
54
|
+
const setConfig = useCallback((next: ShopSenseWidgetConfig) => {
|
|
55
|
+
// Avoid pointless state updates that can cause focus-effect churn.
|
|
56
|
+
const key = JSON.stringify(next);
|
|
57
|
+
if (key === lastConfigKeyRef.current) return;
|
|
58
|
+
lastConfigKeyRef.current = key;
|
|
59
|
+
setConfigState(next);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const show = useCallback((next?: ShopSenseWidgetConfig) => {
|
|
63
|
+
if (next) {
|
|
64
|
+
const key = JSON.stringify(next);
|
|
65
|
+
if (key !== lastConfigKeyRef.current) {
|
|
66
|
+
lastConfigKeyRef.current = key;
|
|
67
|
+
setConfigState(next);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
setVisible(true);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const hide = useCallback(() => {
|
|
74
|
+
setVisible(false);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const setStatusTimeoutMs = useCallback((ms: number) => {
|
|
78
|
+
const next = Number.isFinite(ms) ? Math.max(0, Math.floor(ms)) : 10000;
|
|
79
|
+
setStatusTimeoutMsState(next);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const value = useMemo(
|
|
83
|
+
() => ({
|
|
84
|
+
config,
|
|
85
|
+
visible,
|
|
86
|
+
setConfig,
|
|
87
|
+
show,
|
|
88
|
+
hide,
|
|
89
|
+
webViewRef,
|
|
90
|
+
lastWebViewUrlRef,
|
|
91
|
+
callbacksRef,
|
|
92
|
+
statusTimeoutMs,
|
|
93
|
+
setStatusTimeoutMs,
|
|
94
|
+
}),
|
|
95
|
+
[callbacksRef, config, hide, lastWebViewUrlRef, setConfig, setStatusTimeoutMs, show, statusTimeoutMs, visible, webViewRef]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<ShopSenseContext.Provider value={value}>
|
|
100
|
+
{/* flex:1 so the persistent WebView (absolute fill) has a bounded parent like the native root */}
|
|
101
|
+
<View style={{ flex: 1 }}>{children}</View>
|
|
102
|
+
</ShopSenseContext.Provider>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function useShopSense() {
|
|
107
|
+
const ctx = useContext(ShopSenseContext);
|
|
108
|
+
if (!ctx) {
|
|
109
|
+
throw new Error("useShopSense must be used within ShopSenseProvider");
|
|
110
|
+
}
|
|
111
|
+
return ctx;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Internal: like useShopSense but returns null instead of throwing. */
|
|
115
|
+
export function useShopSenseOptional() {
|
|
116
|
+
return useContext(ShopSenseContext);
|
|
117
|
+
}
|
|
118
|
+
|
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,79 @@
|
|
|
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
|
+
* Max time (ms) to wait for the widget to report `WIDGET_STATUS: succeeded`
|
|
65
|
+
* after a page load begins. If exceeded, the package emits:
|
|
66
|
+
* `{ type: "WIDGET_STATUS", payload: { state: "failed", error: { message: "timeout" }, ... } }`.
|
|
67
|
+
*
|
|
68
|
+
* Default: 10000
|
|
69
|
+
*/
|
|
70
|
+
statusTimeoutMs?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Whether the widget should show itself when the containing screen is focused,
|
|
73
|
+
* and hide when unfocused, while staying mounted for persistence.
|
|
74
|
+
*
|
|
75
|
+
* Default: true (requires ShopSenseProvider).
|
|
76
|
+
*/
|
|
77
|
+
autoShowOnFocus?: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
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