shopsense-test 0.1.0

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