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.
@@ -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
+
@@ -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
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020", "DOM"],
5
+ "jsx": "react",
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["src"]
13
+ }
14
+