@sendoracloud/sdk-react-native 1.0.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/contact-widget.tsx
31
+ var contact_widget_exports = {};
32
+ __export(contact_widget_exports, {
33
+ ContactWidget: () => ContactWidget,
34
+ useContactWidget: () => useContactWidget
35
+ });
36
+ module.exports = __toCommonJS(contact_widget_exports);
37
+ var React = __toESM(require("react"), 1);
38
+ var import_react_native = require("react-native");
39
+ var import_react_native_webview = require("react-native-webview");
40
+ var import_jsx_runtime = require("react/jsx-runtime");
41
+ function ContactWidget({
42
+ visible,
43
+ widgetId,
44
+ workerHost = "https://go.sendoracloud.com",
45
+ theme = "auto",
46
+ prefillName,
47
+ prefillEmail,
48
+ onClose
49
+ }) {
50
+ const didFire = React.useRef(false);
51
+ React.useEffect(() => {
52
+ if (visible) didFire.current = false;
53
+ }, [visible]);
54
+ if (!widgetId) return null;
55
+ const params = new URLSearchParams({ widgetId, theme });
56
+ if (prefillName) params.set("prefillName", prefillName);
57
+ if (prefillEmail) params.set("prefillEmail", prefillEmail);
58
+ const uri = `${workerHost}/embed/contact?${params.toString()}`;
59
+ const handleClose = (result) => {
60
+ if (didFire.current) return;
61
+ didFire.current = true;
62
+ onClose(result);
63
+ };
64
+ const onNav = (req) => {
65
+ const url = req.url || "";
66
+ if (url.startsWith("sendora://close")) {
67
+ const queryStart = url.indexOf("?");
68
+ const params2 = queryStart >= 0 ? new URLSearchParams(url.slice(queryStart + 1)) : new URLSearchParams();
69
+ const ticketId = params2.get("ticketId") || "";
70
+ const portalUrl = params2.get("portalUrl") || null;
71
+ handleClose({ ticketId, portalUrl, submitted: ticketId !== "" });
72
+ return false;
73
+ }
74
+ if (!url.startsWith("https://") && !url.startsWith("about:")) {
75
+ return false;
76
+ }
77
+ return true;
78
+ };
79
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
80
+ import_react_native.Modal,
81
+ {
82
+ visible,
83
+ animationType: "slide",
84
+ presentationStyle: "pageSheet",
85
+ onRequestClose: () => handleClose({ ticketId: "", portalUrl: null, submitted: false }),
86
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.View, { style: { flex: 1 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
87
+ import_react_native_webview.WebView,
88
+ {
89
+ source: { uri },
90
+ onShouldStartLoadWithRequest: onNav,
91
+ originWhitelist: ["https://*", "about:*", "sendora://*"],
92
+ setSupportMultipleWindows: false,
93
+ javaScriptEnabled: true,
94
+ domStorageEnabled: true,
95
+ allowsBackForwardNavigationGestures: false
96
+ }
97
+ ) })
98
+ }
99
+ );
100
+ }
101
+ function useContactWidget(widgetId, defaults) {
102
+ const [visible, setVisible] = React.useState(false);
103
+ const [lastResult, setLastResult] = React.useState(null);
104
+ const open = React.useCallback(() => setVisible(true), []);
105
+ const close = React.useCallback(() => setVisible(false), []);
106
+ const View2 = React.useCallback(
107
+ () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
108
+ ContactWidget,
109
+ {
110
+ ...defaults,
111
+ widgetId,
112
+ visible,
113
+ onClose: (r) => {
114
+ setLastResult(r);
115
+ setVisible(false);
116
+ }
117
+ }
118
+ ),
119
+ [defaults, widgetId, visible]
120
+ );
121
+ return { open, close, View: View2, lastResult };
122
+ }
123
+ // Annotate the CommonJS export names for ESM import in node:
124
+ 0 && (module.exports = {
125
+ ContactWidget,
126
+ useContactWidget
127
+ });
@@ -0,0 +1,70 @@
1
+ import * as React from 'react';
2
+ export { WebViewMessageEvent } from 'react-native-webview';
3
+
4
+ /**
5
+ * Wave 66 — ContactWidget for React Native apps.
6
+ *
7
+ * Renders the worker-hosted embed at
8
+ * `https://go.sendoracloud.com/embed/contact?widgetId=…` inside a
9
+ * `<WebView>` from `react-native-webview`. The form posts to
10
+ * `POST /widgets/:id/submit` server-side.
11
+ *
12
+ * On submit success the embed page navigates to
13
+ * `sendora://close?ticketId=…&portalUrl=…`. WebView's
14
+ * `onShouldStartLoadWithRequest` catches the scheme + fires
15
+ * `onClose(result)` so the host can hide the modal.
16
+ *
17
+ * Single source of truth = the worker route. UI updates = redeploy
18
+ * worker. Zero SDK release.
19
+ *
20
+ * Import from subpath so customers without react-native-webview can
21
+ * still use the main SDK without Metro choking on a missing dep:
22
+ *
23
+ * import { ContactWidget } from "@sendoracloud/sdk-react-native/contact-widget";
24
+ */
25
+
26
+ interface ContactWidgetResult {
27
+ /** UUID of the ticket created server-side, or empty if user closed without submitting. */
28
+ ticketId: string;
29
+ /** Operator's tracking-portal URL string, or null. */
30
+ portalUrl: string | null;
31
+ /** True if the user submitted the form; false if they closed without submitting. */
32
+ submitted: boolean;
33
+ }
34
+ interface ContactWidgetProps {
35
+ /** Whether the modal sheet is visible. */
36
+ visible: boolean;
37
+ /** UUID of the widget configured in the Sendora dashboard. */
38
+ widgetId: string;
39
+ /** Worker host. Defaults to production. */
40
+ workerHost?: string;
41
+ /** `"auto"` | `"light"` | `"dark"`. Default `"auto"`. */
42
+ theme?: "auto" | "light" | "dark";
43
+ /** Pre-fill name field for signed-in users. */
44
+ prefillName?: string;
45
+ /** Pre-fill email field for signed-in users. */
46
+ prefillEmail?: string;
47
+ /**
48
+ * Fired when the user closes the sheet — either after a successful
49
+ * submit (`submitted = true`) or via the close button
50
+ * (`submitted = false`). Host should set `visible = false` here.
51
+ */
52
+ onClose: (result: ContactWidgetResult) => void;
53
+ }
54
+ declare function ContactWidget({ visible, widgetId, workerHost, theme, prefillName, prefillEmail, onClose, }: ContactWidgetProps): React.ReactElement | null;
55
+ /**
56
+ * Helper hook so the host doesn't have to wire visibility state by
57
+ * hand.
58
+ *
59
+ * const contact = useContactWidget("widget-uuid");
60
+ * <Button onPress={contact.open} title="Contact us" />
61
+ * <contact.View />
62
+ */
63
+ declare function useContactWidget(widgetId: string, defaults?: Omit<ContactWidgetProps, "visible" | "widgetId" | "onClose">): {
64
+ open: () => void;
65
+ close: () => void;
66
+ View: () => React.ReactElement | null;
67
+ lastResult: ContactWidgetResult | null;
68
+ };
69
+
70
+ export { ContactWidget, type ContactWidgetProps, type ContactWidgetResult, useContactWidget };
@@ -0,0 +1,70 @@
1
+ import * as React from 'react';
2
+ export { WebViewMessageEvent } from 'react-native-webview';
3
+
4
+ /**
5
+ * Wave 66 — ContactWidget for React Native apps.
6
+ *
7
+ * Renders the worker-hosted embed at
8
+ * `https://go.sendoracloud.com/embed/contact?widgetId=…` inside a
9
+ * `<WebView>` from `react-native-webview`. The form posts to
10
+ * `POST /widgets/:id/submit` server-side.
11
+ *
12
+ * On submit success the embed page navigates to
13
+ * `sendora://close?ticketId=…&portalUrl=…`. WebView's
14
+ * `onShouldStartLoadWithRequest` catches the scheme + fires
15
+ * `onClose(result)` so the host can hide the modal.
16
+ *
17
+ * Single source of truth = the worker route. UI updates = redeploy
18
+ * worker. Zero SDK release.
19
+ *
20
+ * Import from subpath so customers without react-native-webview can
21
+ * still use the main SDK without Metro choking on a missing dep:
22
+ *
23
+ * import { ContactWidget } from "@sendoracloud/sdk-react-native/contact-widget";
24
+ */
25
+
26
+ interface ContactWidgetResult {
27
+ /** UUID of the ticket created server-side, or empty if user closed without submitting. */
28
+ ticketId: string;
29
+ /** Operator's tracking-portal URL string, or null. */
30
+ portalUrl: string | null;
31
+ /** True if the user submitted the form; false if they closed without submitting. */
32
+ submitted: boolean;
33
+ }
34
+ interface ContactWidgetProps {
35
+ /** Whether the modal sheet is visible. */
36
+ visible: boolean;
37
+ /** UUID of the widget configured in the Sendora dashboard. */
38
+ widgetId: string;
39
+ /** Worker host. Defaults to production. */
40
+ workerHost?: string;
41
+ /** `"auto"` | `"light"` | `"dark"`. Default `"auto"`. */
42
+ theme?: "auto" | "light" | "dark";
43
+ /** Pre-fill name field for signed-in users. */
44
+ prefillName?: string;
45
+ /** Pre-fill email field for signed-in users. */
46
+ prefillEmail?: string;
47
+ /**
48
+ * Fired when the user closes the sheet — either after a successful
49
+ * submit (`submitted = true`) or via the close button
50
+ * (`submitted = false`). Host should set `visible = false` here.
51
+ */
52
+ onClose: (result: ContactWidgetResult) => void;
53
+ }
54
+ declare function ContactWidget({ visible, widgetId, workerHost, theme, prefillName, prefillEmail, onClose, }: ContactWidgetProps): React.ReactElement | null;
55
+ /**
56
+ * Helper hook so the host doesn't have to wire visibility state by
57
+ * hand.
58
+ *
59
+ * const contact = useContactWidget("widget-uuid");
60
+ * <Button onPress={contact.open} title="Contact us" />
61
+ * <contact.View />
62
+ */
63
+ declare function useContactWidget(widgetId: string, defaults?: Omit<ContactWidgetProps, "visible" | "widgetId" | "onClose">): {
64
+ open: () => void;
65
+ close: () => void;
66
+ View: () => React.ReactElement | null;
67
+ lastResult: ContactWidgetResult | null;
68
+ };
69
+
70
+ export { ContactWidget, type ContactWidgetProps, type ContactWidgetResult, useContactWidget };
@@ -0,0 +1,91 @@
1
+ // src/contact-widget.tsx
2
+ import * as React from "react";
3
+ import { Modal, View } from "react-native";
4
+ import { WebView } from "react-native-webview";
5
+ import { jsx } from "react/jsx-runtime";
6
+ function ContactWidget({
7
+ visible,
8
+ widgetId,
9
+ workerHost = "https://go.sendoracloud.com",
10
+ theme = "auto",
11
+ prefillName,
12
+ prefillEmail,
13
+ onClose
14
+ }) {
15
+ const didFire = React.useRef(false);
16
+ React.useEffect(() => {
17
+ if (visible) didFire.current = false;
18
+ }, [visible]);
19
+ if (!widgetId) return null;
20
+ const params = new URLSearchParams({ widgetId, theme });
21
+ if (prefillName) params.set("prefillName", prefillName);
22
+ if (prefillEmail) params.set("prefillEmail", prefillEmail);
23
+ const uri = `${workerHost}/embed/contact?${params.toString()}`;
24
+ const handleClose = (result) => {
25
+ if (didFire.current) return;
26
+ didFire.current = true;
27
+ onClose(result);
28
+ };
29
+ const onNav = (req) => {
30
+ const url = req.url || "";
31
+ if (url.startsWith("sendora://close")) {
32
+ const queryStart = url.indexOf("?");
33
+ const params2 = queryStart >= 0 ? new URLSearchParams(url.slice(queryStart + 1)) : new URLSearchParams();
34
+ const ticketId = params2.get("ticketId") || "";
35
+ const portalUrl = params2.get("portalUrl") || null;
36
+ handleClose({ ticketId, portalUrl, submitted: ticketId !== "" });
37
+ return false;
38
+ }
39
+ if (!url.startsWith("https://") && !url.startsWith("about:")) {
40
+ return false;
41
+ }
42
+ return true;
43
+ };
44
+ return /* @__PURE__ */ jsx(
45
+ Modal,
46
+ {
47
+ visible,
48
+ animationType: "slide",
49
+ presentationStyle: "pageSheet",
50
+ onRequestClose: () => handleClose({ ticketId: "", portalUrl: null, submitted: false }),
51
+ children: /* @__PURE__ */ jsx(View, { style: { flex: 1 }, children: /* @__PURE__ */ jsx(
52
+ WebView,
53
+ {
54
+ source: { uri },
55
+ onShouldStartLoadWithRequest: onNav,
56
+ originWhitelist: ["https://*", "about:*", "sendora://*"],
57
+ setSupportMultipleWindows: false,
58
+ javaScriptEnabled: true,
59
+ domStorageEnabled: true,
60
+ allowsBackForwardNavigationGestures: false
61
+ }
62
+ ) })
63
+ }
64
+ );
65
+ }
66
+ function useContactWidget(widgetId, defaults) {
67
+ const [visible, setVisible] = React.useState(false);
68
+ const [lastResult, setLastResult] = React.useState(null);
69
+ const open = React.useCallback(() => setVisible(true), []);
70
+ const close = React.useCallback(() => setVisible(false), []);
71
+ const View2 = React.useCallback(
72
+ () => /* @__PURE__ */ jsx(
73
+ ContactWidget,
74
+ {
75
+ ...defaults,
76
+ widgetId,
77
+ visible,
78
+ onClose: (r) => {
79
+ setLastResult(r);
80
+ setVisible(false);
81
+ }
82
+ }
83
+ ),
84
+ [defaults, widgetId, visible]
85
+ );
86
+ return { open, close, View: View2, lastResult };
87
+ }
88
+ export {
89
+ ContactWidget,
90
+ useContactWidget
91
+ };
package/dist/index.cjs CHANGED
@@ -136,16 +136,36 @@ var Storage = class {
136
136
  };
137
137
 
138
138
  // src/http.ts
139
- async function post(opts, path, body, extraHeaders) {
140
- return request(opts, "POST", path, body, extraHeaders);
139
+ var NetworkTimeoutError = class extends Error {
140
+ constructor(method, path, timeoutMs) {
141
+ super(`Request timed out: ${method} ${path} (${timeoutMs}ms)`);
142
+ this.name = "NetworkTimeoutError";
143
+ this.method = method;
144
+ this.path = path;
145
+ this.timeoutMs = timeoutMs;
146
+ }
147
+ };
148
+ var DEFAULT_AUTH_TIMEOUT_MS = 1e4;
149
+ var DEFAULT_INGEST_TIMEOUT_MS = 3e4;
150
+ function defaultTimeoutFor(path) {
151
+ if (path.startsWith("/api/v1/auth-service/")) return DEFAULT_AUTH_TIMEOUT_MS;
152
+ return DEFAULT_INGEST_TIMEOUT_MS;
153
+ }
154
+ async function post(opts, path, body, extraHeaders, reqOpts) {
155
+ return request(opts, "POST", path, body, extraHeaders, reqOpts);
141
156
  }
142
- async function getJson(opts, path, extraHeaders) {
143
- return request(opts, "GET", path, void 0, extraHeaders);
157
+ async function getJson(opts, path, extraHeaders, reqOpts) {
158
+ return request(opts, "GET", path, void 0, extraHeaders, reqOpts);
144
159
  }
145
- async function del(opts, path, extraHeaders) {
146
- return request(opts, "DELETE", path, void 0, extraHeaders);
160
+ async function del(opts, path, extraHeaders, reqOpts) {
161
+ return request(opts, "DELETE", path, void 0, extraHeaders, reqOpts);
147
162
  }
148
- async function request(opts, method, path, body, extraHeaders) {
163
+ async function request(opts, method, path, body, extraHeaders, reqOpts) {
164
+ const timeoutMs = reqOpts?.timeoutMs ?? defaultTimeoutFor(path);
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => {
167
+ controller.abort();
168
+ }, timeoutMs);
149
169
  let res;
150
170
  try {
151
171
  res = await fetch(`${opts.apiUrl}${path}`, {
@@ -160,15 +180,20 @@ async function request(opts, method, path, body, extraHeaders) {
160
180
  "x-api-key": opts.publicKey,
161
181
  ...extraHeaders ?? {}
162
182
  },
163
- body: body !== void 0 ? JSON.stringify(body) : void 0
183
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
184
+ signal: controller.signal
164
185
  });
165
186
  } catch (err) {
187
+ clearTimeout(timer);
188
+ const aborted = err?.name === "AbortError";
189
+ if (aborted) throw new NetworkTimeoutError(method, path, timeoutMs);
166
190
  console.warn(
167
191
  `[sendoracloud] ${method} ${path} failed at the network layer:`,
168
192
  err instanceof Error ? err.message : String(err)
169
193
  );
170
194
  return null;
171
195
  }
196
+ clearTimeout(timer);
172
197
  if (!res.ok) {
173
198
  const isAuthPath = path.startsWith("/api/v1/auth-service/");
174
199
  if (isAuthPath && !opts.debug) {
@@ -237,6 +262,19 @@ var Auth = class {
237
262
  this.accessExpiresAt = 0;
238
263
  this.inflight = Promise.resolve();
239
264
  this.refreshInflight = null;
265
+ /**
266
+ * Wave 62 — refresh failure back-pressure.
267
+ *
268
+ * Consecutive network-timeout failures don't wipe identity (refresh
269
+ * may still be valid; iOS NSURLSession was just stalled) but do
270
+ * back off so the next caller doesn't immediately fire another
271
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
272
+ * the counter resets + retries are allowed. AppState foreground
273
+ * also clears it so a user returning to the app gets an immediate
274
+ * fresh attempt.
275
+ */
276
+ this.refreshFailureCount = 0;
277
+ this.refreshCooldownUntil = 0;
240
278
  this.takeoverListeners = /* @__PURE__ */ new Set();
241
279
  this.lastTakeover = null;
242
280
  /**
@@ -343,15 +381,38 @@ var Auth = class {
343
381
  }
344
382
  }
345
383
  /**
346
- * Returns a non-expired access token or null. If the cached token
347
- * is expired but a refresh token exists, transparently refreshes
348
- * (single-flight concurrent callers share one network call).
384
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
385
+ *
386
+ * 1. Non-expired cached return it. (Fast path.)
387
+ * 2. Expired AND refresh inflight → return stale cached anyway.
388
+ * Backend grace window (60s — see auth-service refresh-rotation
389
+ * middleware) accepts slightly-stale access tokens, so the
390
+ * caller's downstream RPC will succeed against grace while
391
+ * the in-flight refresh resolves out of band. Distinct from
392
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
393
+ * the same in-flight promise — when that promise hung (iOS
394
+ * NSURLSession cold-launch contention) every caller hung too.
395
+ * 3. Expired + no inflight + refresh available + not in cooldown →
396
+ * fire refresh, await it.
397
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
398
+ * either succeeds against grace or fails fast on 401, which is
399
+ * a better UX than blocking the whole bridge waiting on a
400
+ * network that we already know is stalled.
401
+ *
402
+ * Returns null only when there's NO usable token at all (no cached
403
+ * + no refresh available).
349
404
  */
350
405
  async getAccessToken() {
351
406
  if (!this.accessToken) return null;
352
407
  if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
353
408
  return this.accessToken;
354
409
  }
410
+ if (this.refreshInflight) {
411
+ return this.accessToken;
412
+ }
413
+ if (Date.now() < this.refreshCooldownUntil) {
414
+ return this.accessToken;
415
+ }
355
416
  return this.refreshAccessToken();
356
417
  }
357
418
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -890,6 +951,8 @@ var Auth = class {
890
951
  if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
891
952
  this.accessToken = freshAccess;
892
953
  this.accessExpiresAt = freshExpires;
954
+ this.refreshFailureCount = 0;
955
+ this.refreshCooldownUntil = 0;
893
956
  return freshAccess;
894
957
  }
895
958
  }
@@ -902,11 +965,19 @@ var Auth = class {
902
965
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
903
966
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
904
967
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
968
+ this.refreshFailureCount = 0;
969
+ this.refreshCooldownUntil = 0;
905
970
  return data.tokens.accessToken;
906
971
  } catch (err) {
907
972
  if (err instanceof AuthError && isDeadRefreshError(err.code)) {
908
973
  await this.wipeLocalIdentity().catch(() => void 0);
974
+ this.refreshFailureCount = 0;
975
+ this.refreshCooldownUntil = 0;
976
+ return null;
909
977
  }
978
+ this.refreshFailureCount += 1;
979
+ const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
980
+ this.refreshCooldownUntil = Date.now() + backoffMs;
910
981
  return null;
911
982
  } finally {
912
983
  this.refreshInflight = null;
@@ -914,6 +985,20 @@ var Auth = class {
914
985
  })();
915
986
  return this.refreshInflight;
916
987
  }
988
+ /**
989
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
990
+ * who backgrounded the app during a network blip + returns now
991
+ * gets an immediate fresh refresh attempt instead of waiting on
992
+ * the cooldown timer.
993
+ *
994
+ * Called from the AppState 'active' listener registered in
995
+ * startProactiveRefreshCron. Idempotent + cheap — three field
996
+ * writes; no I/O.
997
+ */
998
+ resetRefreshState() {
999
+ this.refreshFailureCount = 0;
1000
+ this.refreshCooldownUntil = 0;
1001
+ }
917
1002
  startProactiveRefreshCron() {
918
1003
  if (this.proactiveRefreshTimer) return;
919
1004
  const PROACTIVE_TARGET_PCT = 0.8;
@@ -935,7 +1020,10 @@ var Auth = class {
935
1020
  }, TICK_MS);
936
1021
  try {
937
1022
  const subscription = import_react_native.AppState?.addEventListener?.("change", (state) => {
938
- if (state === "active") void tick();
1023
+ if (state === "active") {
1024
+ this.resetRefreshState();
1025
+ void tick();
1026
+ }
939
1027
  });
940
1028
  if (subscription) {
941
1029
  this.proactiveRefreshAppStateUnsub = () => subscription.remove();
@@ -1370,11 +1458,13 @@ async function getPlayInstallReferrer() {
1370
1458
  }
1371
1459
  var PREWARM_TTL_MS = 5 * 6e4;
1372
1460
  var PREWARM_MAX = 50;
1461
+ var PREWARM_MAX_INFLIGHT = 5;
1373
1462
  var Links = class {
1374
1463
  constructor(deps) {
1375
1464
  this.deps = deps;
1376
1465
  this.listeners = [];
1377
1466
  this.prewarmCache = /* @__PURE__ */ new Map();
1467
+ this.prewarmInflight = 0;
1378
1468
  this.linkingSub = null;
1379
1469
  }
1380
1470
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -1422,14 +1512,25 @@ var Links = class {
1422
1512
  *
1423
1513
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
1424
1514
  * will surface them on demand instead.
1515
+ *
1516
+ * Wave 28 — silently drops the call when more than
1517
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
1518
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
1519
+ * skip because the next matching `create()` will simply do the mint
1520
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
1521
+ * in a FlatList renderItem).
1425
1522
  */
1426
1523
  prewarm(input, opts) {
1427
1524
  this.evictExpired();
1428
1525
  const key = this.cacheKey(input, opts?.key);
1429
1526
  if (this.prewarmCache.has(key)) return;
1527
+ if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
1528
+ this.prewarmInflight++;
1430
1529
  const promise = this.doCreate(input).catch((err) => {
1431
1530
  this.prewarmCache.delete(key);
1432
1531
  throw err;
1532
+ }).finally(() => {
1533
+ this.prewarmInflight--;
1433
1534
  });
1434
1535
  this.prewarmCache.set(key, { promise, ts: Date.now() });
1435
1536
  }
package/dist/index.d.cts CHANGED
@@ -119,6 +119,19 @@ declare class Auth {
119
119
  private accessExpiresAt;
120
120
  private inflight;
121
121
  private refreshInflight;
122
+ /**
123
+ * Wave 62 — refresh failure back-pressure.
124
+ *
125
+ * Consecutive network-timeout failures don't wipe identity (refresh
126
+ * may still be valid; iOS NSURLSession was just stalled) but do
127
+ * back off so the next caller doesn't immediately fire another
128
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
129
+ * the counter resets + retries are allowed. AppState foreground
130
+ * also clears it so a user returning to the app gets an immediate
131
+ * fresh attempt.
132
+ */
133
+ private refreshFailureCount;
134
+ private refreshCooldownUntil;
122
135
  private takeoverListeners;
123
136
  private lastTakeover;
124
137
  /**
@@ -182,9 +195,26 @@ declare class Auth {
182
195
  /** Internal — fan out to subscribers + cache the latest. */
183
196
  private fireDeviceTakeover;
184
197
  /**
185
- * Returns a non-expired access token or null. If the cached token
186
- * is expired but a refresh token exists, transparently refreshes
187
- * (single-flight concurrent callers share one network call).
198
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
199
+ *
200
+ * 1. Non-expired cached return it. (Fast path.)
201
+ * 2. Expired AND refresh inflight → return stale cached anyway.
202
+ * Backend grace window (60s — see auth-service refresh-rotation
203
+ * middleware) accepts slightly-stale access tokens, so the
204
+ * caller's downstream RPC will succeed against grace while
205
+ * the in-flight refresh resolves out of band. Distinct from
206
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
207
+ * the same in-flight promise — when that promise hung (iOS
208
+ * NSURLSession cold-launch contention) every caller hung too.
209
+ * 3. Expired + no inflight + refresh available + not in cooldown →
210
+ * fire refresh, await it.
211
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
212
+ * either succeeds against grace or fails fast on 401, which is
213
+ * a better UX than blocking the whole bridge waiting on a
214
+ * network that we already know is stalled.
215
+ *
216
+ * Returns null only when there's NO usable token at all (no cached
217
+ * + no refresh available).
188
218
  */
189
219
  getAccessToken(): Promise<string | null>;
190
220
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -359,6 +389,17 @@ declare class Auth {
359
389
  * surface NOT_SIGNED_IN to the host app instead of looping.
360
390
  */
361
391
  private refreshAccessToken;
392
+ /**
393
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
394
+ * who backgrounded the app during a network blip + returns now
395
+ * gets an immediate fresh refresh attempt instead of waiting on
396
+ * the cooldown timer.
397
+ *
398
+ * Called from the AppState 'active' listener registered in
399
+ * startProactiveRefreshCron. Idempotent + cheap — three field
400
+ * writes; no I/O.
401
+ */
402
+ private resetRefreshState;
362
403
  /**
363
404
  * Proactive refresh (s58.72). Fires when access-token age crosses
364
405
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -574,6 +615,7 @@ declare class Links {
574
615
  private deps;
575
616
  private listeners;
576
617
  private prewarmCache;
618
+ private prewarmInflight;
577
619
  private linkingSub;
578
620
  constructor(deps: LinksDeps);
579
621
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -589,6 +631,13 @@ declare class Links {
589
631
  *
590
632
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
591
633
  * will surface them on demand instead.
634
+ *
635
+ * Wave 28 — silently drops the call when more than
636
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
637
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
638
+ * skip because the next matching `create()` will simply do the mint
639
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
640
+ * in a FlatList renderItem).
592
641
  */
593
642
  prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
594
643
  key?: string;
package/dist/index.d.ts CHANGED
@@ -119,6 +119,19 @@ declare class Auth {
119
119
  private accessExpiresAt;
120
120
  private inflight;
121
121
  private refreshInflight;
122
+ /**
123
+ * Wave 62 — refresh failure back-pressure.
124
+ *
125
+ * Consecutive network-timeout failures don't wipe identity (refresh
126
+ * may still be valid; iOS NSURLSession was just stalled) but do
127
+ * back off so the next caller doesn't immediately fire another
128
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
129
+ * the counter resets + retries are allowed. AppState foreground
130
+ * also clears it so a user returning to the app gets an immediate
131
+ * fresh attempt.
132
+ */
133
+ private refreshFailureCount;
134
+ private refreshCooldownUntil;
122
135
  private takeoverListeners;
123
136
  private lastTakeover;
124
137
  /**
@@ -182,9 +195,26 @@ declare class Auth {
182
195
  /** Internal — fan out to subscribers + cache the latest. */
183
196
  private fireDeviceTakeover;
184
197
  /**
185
- * Returns a non-expired access token or null. If the cached token
186
- * is expired but a refresh token exists, transparently refreshes
187
- * (single-flight concurrent callers share one network call).
198
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
199
+ *
200
+ * 1. Non-expired cached return it. (Fast path.)
201
+ * 2. Expired AND refresh inflight → return stale cached anyway.
202
+ * Backend grace window (60s — see auth-service refresh-rotation
203
+ * middleware) accepts slightly-stale access tokens, so the
204
+ * caller's downstream RPC will succeed against grace while
205
+ * the in-flight refresh resolves out of band. Distinct from
206
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
207
+ * the same in-flight promise — when that promise hung (iOS
208
+ * NSURLSession cold-launch contention) every caller hung too.
209
+ * 3. Expired + no inflight + refresh available + not in cooldown →
210
+ * fire refresh, await it.
211
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
212
+ * either succeeds against grace or fails fast on 401, which is
213
+ * a better UX than blocking the whole bridge waiting on a
214
+ * network that we already know is stalled.
215
+ *
216
+ * Returns null only when there's NO usable token at all (no cached
217
+ * + no refresh available).
188
218
  */
189
219
  getAccessToken(): Promise<string | null>;
190
220
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -359,6 +389,17 @@ declare class Auth {
359
389
  * surface NOT_SIGNED_IN to the host app instead of looping.
360
390
  */
361
391
  private refreshAccessToken;
392
+ /**
393
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
394
+ * who backgrounded the app during a network blip + returns now
395
+ * gets an immediate fresh refresh attempt instead of waiting on
396
+ * the cooldown timer.
397
+ *
398
+ * Called from the AppState 'active' listener registered in
399
+ * startProactiveRefreshCron. Idempotent + cheap — three field
400
+ * writes; no I/O.
401
+ */
402
+ private resetRefreshState;
362
403
  /**
363
404
  * Proactive refresh (s58.72). Fires when access-token age crosses
364
405
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -574,6 +615,7 @@ declare class Links {
574
615
  private deps;
575
616
  private listeners;
576
617
  private prewarmCache;
618
+ private prewarmInflight;
577
619
  private linkingSub;
578
620
  constructor(deps: LinksDeps);
579
621
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -589,6 +631,13 @@ declare class Links {
589
631
  *
590
632
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
591
633
  * will surface them on demand instead.
634
+ *
635
+ * Wave 28 — silently drops the call when more than
636
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
637
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
638
+ * skip because the next matching `create()` will simply do the mint
639
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
640
+ * in a FlatList renderItem).
592
641
  */
593
642
  prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
594
643
  key?: string;
package/dist/index.js CHANGED
@@ -93,16 +93,36 @@ var Storage = class {
93
93
  };
94
94
 
95
95
  // src/http.ts
96
- async function post(opts, path, body, extraHeaders) {
97
- return request(opts, "POST", path, body, extraHeaders);
96
+ var NetworkTimeoutError = class extends Error {
97
+ constructor(method, path, timeoutMs) {
98
+ super(`Request timed out: ${method} ${path} (${timeoutMs}ms)`);
99
+ this.name = "NetworkTimeoutError";
100
+ this.method = method;
101
+ this.path = path;
102
+ this.timeoutMs = timeoutMs;
103
+ }
104
+ };
105
+ var DEFAULT_AUTH_TIMEOUT_MS = 1e4;
106
+ var DEFAULT_INGEST_TIMEOUT_MS = 3e4;
107
+ function defaultTimeoutFor(path) {
108
+ if (path.startsWith("/api/v1/auth-service/")) return DEFAULT_AUTH_TIMEOUT_MS;
109
+ return DEFAULT_INGEST_TIMEOUT_MS;
110
+ }
111
+ async function post(opts, path, body, extraHeaders, reqOpts) {
112
+ return request(opts, "POST", path, body, extraHeaders, reqOpts);
98
113
  }
99
- async function getJson(opts, path, extraHeaders) {
100
- return request(opts, "GET", path, void 0, extraHeaders);
114
+ async function getJson(opts, path, extraHeaders, reqOpts) {
115
+ return request(opts, "GET", path, void 0, extraHeaders, reqOpts);
101
116
  }
102
- async function del(opts, path, extraHeaders) {
103
- return request(opts, "DELETE", path, void 0, extraHeaders);
117
+ async function del(opts, path, extraHeaders, reqOpts) {
118
+ return request(opts, "DELETE", path, void 0, extraHeaders, reqOpts);
104
119
  }
105
- async function request(opts, method, path, body, extraHeaders) {
120
+ async function request(opts, method, path, body, extraHeaders, reqOpts) {
121
+ const timeoutMs = reqOpts?.timeoutMs ?? defaultTimeoutFor(path);
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => {
124
+ controller.abort();
125
+ }, timeoutMs);
106
126
  let res;
107
127
  try {
108
128
  res = await fetch(`${opts.apiUrl}${path}`, {
@@ -117,15 +137,20 @@ async function request(opts, method, path, body, extraHeaders) {
117
137
  "x-api-key": opts.publicKey,
118
138
  ...extraHeaders ?? {}
119
139
  },
120
- body: body !== void 0 ? JSON.stringify(body) : void 0
140
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
141
+ signal: controller.signal
121
142
  });
122
143
  } catch (err) {
144
+ clearTimeout(timer);
145
+ const aborted = err?.name === "AbortError";
146
+ if (aborted) throw new NetworkTimeoutError(method, path, timeoutMs);
123
147
  console.warn(
124
148
  `[sendoracloud] ${method} ${path} failed at the network layer:`,
125
149
  err instanceof Error ? err.message : String(err)
126
150
  );
127
151
  return null;
128
152
  }
153
+ clearTimeout(timer);
129
154
  if (!res.ok) {
130
155
  const isAuthPath = path.startsWith("/api/v1/auth-service/");
131
156
  if (isAuthPath && !opts.debug) {
@@ -194,6 +219,19 @@ var Auth = class {
194
219
  this.accessExpiresAt = 0;
195
220
  this.inflight = Promise.resolve();
196
221
  this.refreshInflight = null;
222
+ /**
223
+ * Wave 62 — refresh failure back-pressure.
224
+ *
225
+ * Consecutive network-timeout failures don't wipe identity (refresh
226
+ * may still be valid; iOS NSURLSession was just stalled) but do
227
+ * back off so the next caller doesn't immediately fire another
228
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
229
+ * the counter resets + retries are allowed. AppState foreground
230
+ * also clears it so a user returning to the app gets an immediate
231
+ * fresh attempt.
232
+ */
233
+ this.refreshFailureCount = 0;
234
+ this.refreshCooldownUntil = 0;
197
235
  this.takeoverListeners = /* @__PURE__ */ new Set();
198
236
  this.lastTakeover = null;
199
237
  /**
@@ -300,15 +338,38 @@ var Auth = class {
300
338
  }
301
339
  }
302
340
  /**
303
- * Returns a non-expired access token or null. If the cached token
304
- * is expired but a refresh token exists, transparently refreshes
305
- * (single-flight concurrent callers share one network call).
341
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
342
+ *
343
+ * 1. Non-expired cached return it. (Fast path.)
344
+ * 2. Expired AND refresh inflight → return stale cached anyway.
345
+ * Backend grace window (60s — see auth-service refresh-rotation
346
+ * middleware) accepts slightly-stale access tokens, so the
347
+ * caller's downstream RPC will succeed against grace while
348
+ * the in-flight refresh resolves out of band. Distinct from
349
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
350
+ * the same in-flight promise — when that promise hung (iOS
351
+ * NSURLSession cold-launch contention) every caller hung too.
352
+ * 3. Expired + no inflight + refresh available + not in cooldown →
353
+ * fire refresh, await it.
354
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
355
+ * either succeeds against grace or fails fast on 401, which is
356
+ * a better UX than blocking the whole bridge waiting on a
357
+ * network that we already know is stalled.
358
+ *
359
+ * Returns null only when there's NO usable token at all (no cached
360
+ * + no refresh available).
306
361
  */
307
362
  async getAccessToken() {
308
363
  if (!this.accessToken) return null;
309
364
  if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
310
365
  return this.accessToken;
311
366
  }
367
+ if (this.refreshInflight) {
368
+ return this.accessToken;
369
+ }
370
+ if (Date.now() < this.refreshCooldownUntil) {
371
+ return this.accessToken;
372
+ }
312
373
  return this.refreshAccessToken();
313
374
  }
314
375
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -847,6 +908,8 @@ var Auth = class {
847
908
  if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
848
909
  this.accessToken = freshAccess;
849
910
  this.accessExpiresAt = freshExpires;
911
+ this.refreshFailureCount = 0;
912
+ this.refreshCooldownUntil = 0;
850
913
  return freshAccess;
851
914
  }
852
915
  }
@@ -859,11 +922,19 @@ var Auth = class {
859
922
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
860
923
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
861
924
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
925
+ this.refreshFailureCount = 0;
926
+ this.refreshCooldownUntil = 0;
862
927
  return data.tokens.accessToken;
863
928
  } catch (err) {
864
929
  if (err instanceof AuthError && isDeadRefreshError(err.code)) {
865
930
  await this.wipeLocalIdentity().catch(() => void 0);
931
+ this.refreshFailureCount = 0;
932
+ this.refreshCooldownUntil = 0;
933
+ return null;
866
934
  }
935
+ this.refreshFailureCount += 1;
936
+ const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
937
+ this.refreshCooldownUntil = Date.now() + backoffMs;
867
938
  return null;
868
939
  } finally {
869
940
  this.refreshInflight = null;
@@ -871,6 +942,20 @@ var Auth = class {
871
942
  })();
872
943
  return this.refreshInflight;
873
944
  }
945
+ /**
946
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
947
+ * who backgrounded the app during a network blip + returns now
948
+ * gets an immediate fresh refresh attempt instead of waiting on
949
+ * the cooldown timer.
950
+ *
951
+ * Called from the AppState 'active' listener registered in
952
+ * startProactiveRefreshCron. Idempotent + cheap — three field
953
+ * writes; no I/O.
954
+ */
955
+ resetRefreshState() {
956
+ this.refreshFailureCount = 0;
957
+ this.refreshCooldownUntil = 0;
958
+ }
874
959
  startProactiveRefreshCron() {
875
960
  if (this.proactiveRefreshTimer) return;
876
961
  const PROACTIVE_TARGET_PCT = 0.8;
@@ -892,7 +977,10 @@ var Auth = class {
892
977
  }, TICK_MS);
893
978
  try {
894
979
  const subscription = RnAppState?.addEventListener?.("change", (state) => {
895
- if (state === "active") void tick();
980
+ if (state === "active") {
981
+ this.resetRefreshState();
982
+ void tick();
983
+ }
896
984
  });
897
985
  if (subscription) {
898
986
  this.proactiveRefreshAppStateUnsub = () => subscription.remove();
@@ -1332,11 +1420,13 @@ async function getPlayInstallReferrer() {
1332
1420
  }
1333
1421
  var PREWARM_TTL_MS = 5 * 6e4;
1334
1422
  var PREWARM_MAX = 50;
1423
+ var PREWARM_MAX_INFLIGHT = 5;
1335
1424
  var Links = class {
1336
1425
  constructor(deps) {
1337
1426
  this.deps = deps;
1338
1427
  this.listeners = [];
1339
1428
  this.prewarmCache = /* @__PURE__ */ new Map();
1429
+ this.prewarmInflight = 0;
1340
1430
  this.linkingSub = null;
1341
1431
  }
1342
1432
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -1384,14 +1474,25 @@ var Links = class {
1384
1474
  *
1385
1475
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
1386
1476
  * will surface them on demand instead.
1477
+ *
1478
+ * Wave 28 — silently drops the call when more than
1479
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
1480
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
1481
+ * skip because the next matching `create()` will simply do the mint
1482
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
1483
+ * in a FlatList renderItem).
1387
1484
  */
1388
1485
  prewarm(input, opts) {
1389
1486
  this.evictExpired();
1390
1487
  const key = this.cacheKey(input, opts?.key);
1391
1488
  if (this.prewarmCache.has(key)) return;
1489
+ if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
1490
+ this.prewarmInflight++;
1392
1491
  const promise = this.doCreate(input).catch((err) => {
1393
1492
  this.prewarmCache.delete(key);
1394
1493
  throw err;
1494
+ }).finally(() => {
1495
+ this.prewarmInflight--;
1395
1496
  });
1396
1497
  this.prewarmCache.set(key, { promise, ts: Date.now() });
1397
1498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.0.5",
3
+ "version": "1.2.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -11,6 +11,11 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.cjs"
14
+ },
15
+ "./contact-widget": {
16
+ "types": "./dist/contact-widget.d.ts",
17
+ "import": "./dist/contact-widget.js",
18
+ "require": "./dist/contact-widget.cjs"
14
19
  }
15
20
  },
16
21
  "files": [
@@ -32,7 +37,8 @@
32
37
  },
33
38
  "peerDependencies": {
34
39
  "@react-native-async-storage/async-storage": ">=1.17.0",
35
- "react-native": ">=0.70.0"
40
+ "react-native": ">=0.70.0",
41
+ "react-native-webview": ">=13.0.0"
36
42
  },
37
43
  "peerDependenciesMeta": {
38
44
  "@react-native-async-storage/async-storage": {
@@ -40,13 +46,19 @@
40
46
  },
41
47
  "react-native": {
42
48
  "optional": false
49
+ },
50
+ "react-native-webview": {
51
+ "optional": true
43
52
  }
44
53
  },
45
54
  "scripts": {
46
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
55
+ "build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
47
56
  "typecheck": "tsc --noEmit"
48
57
  },
49
58
  "devDependencies": {
59
+ "@types/react": "^19.2.16",
60
+ "react-native": "^0.85.3",
61
+ "react-native-webview": "^13.16.1",
50
62
  "tsup": "^8.0.0",
51
63
  "typescript": "^5.0.0"
52
64
  },