@sendoracloud/sdk-react-native 0.17.1 → 0.18.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 CHANGED
@@ -88,7 +88,7 @@ await SendoraCloud.reset();
88
88
  | `track(event, props?)` | Fire a custom product event. |
89
89
  | `screen(name, props?)` | Track a screen view. |
90
90
  | `registerPushToken(reg)` | Register an APNs or FCM push token. |
91
- | `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO. See `auth.ts`. |
91
+ | `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO + `auth.getRefreshToken()` + `auth.merge(anonRefresh, targetRefresh?)`. See `auth.ts`. |
92
92
  | `links.create<T>(input, opts?)` | Mint a Sendora deep link. Generic `T` for typed `linkData`. |
93
93
  | `links.prewarm<T>(input, opts?)` | Background-mint + cache so share-tap is instant. |
94
94
  | `links.handleUniversalLink(url)` | Resolve a warm-path Universal Link / App Link delivery. |
@@ -183,6 +183,39 @@ Full paste-ready recipes live in [`examples/links-share.tsx`](./examples/links-s
183
183
 
184
184
  `SendoraCloud.auth` covers anonymous sign-in, email + password, magic link, email OTP, TOTP MFA, recovery codes, OIDC / SAML SSO, Sign in with Apple, Google, Microsoft, LinkedIn, Facebook, Discord. See [auth quickstart](https://sendoracloud.com/docs/quickstart-react-native#auth).
185
185
 
186
+ ### Anonymous → identified lifecycle
187
+
188
+ The SDK creates an anonymous user on first `init()`. Every `track()` / `identify()` attaches to that `user_id` until the user signs up or signs in. Two transitions exist; both preserve analytics continuity, but the second one requires one extra line of code from your app:
189
+
190
+ **Case 1 — fresh signup.** `auth.signUp(email, password)` detects the anonymous refresh token and calls `/auth-service/upgrade` first, which flips `is_anonymous=false` + sets the password on the SAME row. The `user_id` is preserved — no funnel break. Falls back to a fresh `/signup` if the bound user is already identified (rare race).
191
+
192
+ ```ts
193
+ const { user } = await SendoraCloud.auth.signUp(email, password);
194
+ // user.id === the same id the anon user had. Done.
195
+ ```
196
+
197
+ **Case 2 — email already exists.** `signUp()` throws `EmailAlreadyTakenError` without consuming the anon refresh. You fall back to `signIn()` — but `signIn()` wipes the local anon identity, so anon events stay orphaned on the backend unless you transfer them with `auth.merge(anonRefresh)`. Capture the anon refresh BEFORE the signIn:
198
+
199
+ ```ts
200
+ try {
201
+ const { user } = await SendoraCloud.auth.signUp(email, password);
202
+ } catch (err) {
203
+ if (!(err instanceof EmailAlreadyTakenError)) throw err;
204
+
205
+ const anonRefresh = await SendoraCloud.auth.getRefreshToken();
206
+ const { user } = await SendoraCloud.auth.signIn(email, password);
207
+
208
+ // Best-effort, silent-fail-tolerant — anon row may be already drained.
209
+ if (anonRefresh) {
210
+ SendoraCloud.auth.merge(anonRefresh).catch(() => undefined);
211
+ }
212
+ }
213
+ ```
214
+
215
+ **Why merge isn't automatic on signIn.** Two people sharing a device (browser kiosk, family iPad) would silently get their anon-session events combined with a third party's real account. Forcing the merge call into your app keeps the data-mixing decision explicit. Wrap `signIn()` with the snippet above if you want auto behaviour.
216
+
217
+ Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing-email](https://sendoracloud.com/docs/quickstart-react-native#anon-existing-email).
218
+
186
219
  ## Config
187
220
 
188
221
  ```ts
package/dist/index.cjs CHANGED
@@ -785,6 +785,75 @@ var Auth = class {
785
785
  })();
786
786
  return this.refreshInflight;
787
787
  }
788
+ /**
789
+ * Returns the currently-stored refresh token (or null when signed out).
790
+ * Primary use: capture the anonymous refresh token BEFORE calling
791
+ * `signIn()` so it can be transferred via `merge()` after the existing
792
+ * account is authenticated. See the "Anonymous → identified" section
793
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
794
+ */
795
+ getRefreshToken() {
796
+ return this.hooks.storage.get(REFRESH_KEY) ?? null;
797
+ }
798
+ /**
799
+ * Transfer every event + trait from an anonymous user to the
800
+ * currently-signed-in (target) user, then delete the anonymous row.
801
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
802
+ * token — call this AFTER a successful `signIn()` so the storage
803
+ * already holds the target's refresh.
804
+ *
805
+ * Throws AuthError when:
806
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
807
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
808
+ * - the target is anonymous (CONFLICT — sign in first),
809
+ * - both tokens resolve to the same user (CONFLICT — no-op).
810
+ *
811
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
812
+ */
813
+ async merge(anonymousRefreshToken, targetRefreshToken) {
814
+ const target = targetRefreshToken ?? this.getRefreshToken();
815
+ if (!target) {
816
+ throw new AuthError(
817
+ "NO_TARGET_REFRESH",
818
+ "merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
819
+ );
820
+ }
821
+ const res = await post(
822
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
823
+ "/api/v1/auth-service/merge",
824
+ { anonymousRefreshToken, targetRefreshToken: target }
825
+ );
826
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
827
+ let parsed;
828
+ try {
829
+ parsed = await res.json();
830
+ } catch {
831
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
832
+ }
833
+ if (!res.ok || !parsed.success || !parsed.data) {
834
+ throw new AuthError(
835
+ parsed.error?.code ?? `HTTP_${res.status}`,
836
+ parsed.error?.message ?? `HTTP ${res.status}`
837
+ );
838
+ }
839
+ const t = parsed.data.tokens;
840
+ if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
841
+ this.accessToken = t.accessToken;
842
+ this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
843
+ this.hooks.storage.set(TOKEN_KEY, t.accessToken);
844
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
845
+ this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
846
+ }
847
+ return {
848
+ mergedUserId: parsed.data.mergedUserId ?? "",
849
+ deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
850
+ eventsReassigned: parsed.data.eventsReassigned ?? 0,
851
+ profilesReassigned: parsed.data.profilesReassigned ?? 0,
852
+ profilesDropped: parsed.data.profilesDropped ?? 0,
853
+ entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
854
+ entitiesDropped: parsed.data.entitiesDropped ?? 0
855
+ };
856
+ }
788
857
  async callAuth(path, body) {
789
858
  const res = await post(
790
859
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1771,6 +1840,55 @@ var SendoraSDK = class {
1771
1840
  }
1772
1841
  };
1773
1842
  }
1843
+ /**
1844
+ * Attribution namespace — mobile install + deferred deep-link reporting.
1845
+ * Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
1846
+ *
1847
+ * Backend resolves `orgId` + `projectId` from the API key, so the SDK
1848
+ * helper accepts only the device-side context (fingerprint, OS, app
1849
+ * version). Both methods are safe to call on every cold-start —
1850
+ * backend deduplicates installs by `deviceId` within a 24h window so
1851
+ * a paranoid app calling `reportInstall()` on every launch sees
1852
+ * `deduplicated: true` after the first match.
1853
+ *
1854
+ * Typical pairing:
1855
+ * await SendoraCloud.attribution.reportInstall({
1856
+ * deviceId,
1857
+ * fingerprintHash: await computeDeviceFingerprint(),
1858
+ * appVersion,
1859
+ * });
1860
+ * const deferred = await SendoraCloud.attribution.checkDeferred();
1861
+ * if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
1862
+ */
1863
+ get attribution() {
1864
+ const self = this;
1865
+ return {
1866
+ reportInstall: async (input) => {
1867
+ if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
1868
+ const res = await post(
1869
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
1870
+ "/attribution/install",
1871
+ input ?? {}
1872
+ );
1873
+ if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
1874
+ const parsed = await res.json();
1875
+ if (!parsed.success || !parsed.data) throw new Error("[sendoracloud] attribution.reportInstall: bad response");
1876
+ return parsed.data;
1877
+ },
1878
+ checkDeferred: async (input) => {
1879
+ if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
1880
+ const res = await post(
1881
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
1882
+ "/attribution/deferred",
1883
+ input ?? {}
1884
+ );
1885
+ if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
1886
+ const parsed = await res.json();
1887
+ if (!parsed.success) throw new Error("[sendoracloud] attribution.checkDeferred: bad response");
1888
+ return parsed.data ?? null;
1889
+ }
1890
+ };
1891
+ }
1774
1892
  /**
1775
1893
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
1776
1894
  * the AsyncStorage writes so a caller who kills the app immediately
package/dist/index.d.cts CHANGED
@@ -299,6 +299,38 @@ declare class Auth {
299
299
  * surface NOT_SIGNED_IN to the host app instead of looping.
300
300
  */
301
301
  private refreshAccessToken;
302
+ /**
303
+ * Returns the currently-stored refresh token (or null when signed out).
304
+ * Primary use: capture the anonymous refresh token BEFORE calling
305
+ * `signIn()` so it can be transferred via `merge()` after the existing
306
+ * account is authenticated. See the "Anonymous → identified" section
307
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
308
+ */
309
+ getRefreshToken(): string | null;
310
+ /**
311
+ * Transfer every event + trait from an anonymous user to the
312
+ * currently-signed-in (target) user, then delete the anonymous row.
313
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
314
+ * token — call this AFTER a successful `signIn()` so the storage
315
+ * already holds the target's refresh.
316
+ *
317
+ * Throws AuthError when:
318
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
319
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
320
+ * - the target is anonymous (CONFLICT — sign in first),
321
+ * - both tokens resolve to the same user (CONFLICT — no-op).
322
+ *
323
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
324
+ */
325
+ merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
326
+ mergedUserId: string;
327
+ deletedAnonymousId: string;
328
+ eventsReassigned: number;
329
+ profilesReassigned: number;
330
+ profilesDropped: number;
331
+ entitiesReassigned: number;
332
+ entitiesDropped: number;
333
+ }>;
302
334
  private callAuth;
303
335
  private persist;
304
336
  private wipeLocalIdentity;
@@ -719,6 +751,63 @@ declare class SendoraSDK {
719
751
  registerToken: (reg: PushTokenRegistration) => Promise<PushTokenReceipt>;
720
752
  trackOpen: (sendId: string, clickAction?: string) => Promise<void>;
721
753
  };
754
+ /**
755
+ * Attribution namespace — mobile install + deferred deep-link reporting.
756
+ * Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
757
+ *
758
+ * Backend resolves `orgId` + `projectId` from the API key, so the SDK
759
+ * helper accepts only the device-side context (fingerprint, OS, app
760
+ * version). Both methods are safe to call on every cold-start —
761
+ * backend deduplicates installs by `deviceId` within a 24h window so
762
+ * a paranoid app calling `reportInstall()` on every launch sees
763
+ * `deduplicated: true` after the first match.
764
+ *
765
+ * Typical pairing:
766
+ * await SendoraCloud.attribution.reportInstall({
767
+ * deviceId,
768
+ * fingerprintHash: await computeDeviceFingerprint(),
769
+ * appVersion,
770
+ * });
771
+ * const deferred = await SendoraCloud.attribution.checkDeferred();
772
+ * if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
773
+ */
774
+ get attribution(): {
775
+ reportInstall: (input?: {
776
+ deviceId?: string;
777
+ fingerprintHash?: string;
778
+ appVersion?: string;
779
+ os?: string;
780
+ osVersion?: string;
781
+ country?: string;
782
+ }) => Promise<{
783
+ installId: string;
784
+ deduplicated: boolean;
785
+ attributed: boolean;
786
+ result?: {
787
+ matchType: string;
788
+ confidence?: number;
789
+ clickId?: string;
790
+ linkId?: string;
791
+ campaign?: string;
792
+ source?: string;
793
+ medium?: string;
794
+ deepLinkData?: Record<string, unknown>;
795
+ deepLinkPath?: string;
796
+ };
797
+ }>;
798
+ checkDeferred: (input?: {
799
+ fingerprintHash?: string;
800
+ deviceId?: string;
801
+ }) => Promise<{
802
+ matchType: string;
803
+ confidence?: number;
804
+ campaign?: string;
805
+ source?: string;
806
+ medium?: string;
807
+ deepLinkData?: Record<string, unknown>;
808
+ deepLinkPath?: string;
809
+ } | null>;
810
+ };
722
811
  /**
723
812
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
724
813
  * the AsyncStorage writes so a caller who kills the app immediately
package/dist/index.d.ts CHANGED
@@ -299,6 +299,38 @@ declare class Auth {
299
299
  * surface NOT_SIGNED_IN to the host app instead of looping.
300
300
  */
301
301
  private refreshAccessToken;
302
+ /**
303
+ * Returns the currently-stored refresh token (or null when signed out).
304
+ * Primary use: capture the anonymous refresh token BEFORE calling
305
+ * `signIn()` so it can be transferred via `merge()` after the existing
306
+ * account is authenticated. See the "Anonymous → identified" section
307
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
308
+ */
309
+ getRefreshToken(): string | null;
310
+ /**
311
+ * Transfer every event + trait from an anonymous user to the
312
+ * currently-signed-in (target) user, then delete the anonymous row.
313
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
314
+ * token — call this AFTER a successful `signIn()` so the storage
315
+ * already holds the target's refresh.
316
+ *
317
+ * Throws AuthError when:
318
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
319
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
320
+ * - the target is anonymous (CONFLICT — sign in first),
321
+ * - both tokens resolve to the same user (CONFLICT — no-op).
322
+ *
323
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
324
+ */
325
+ merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
326
+ mergedUserId: string;
327
+ deletedAnonymousId: string;
328
+ eventsReassigned: number;
329
+ profilesReassigned: number;
330
+ profilesDropped: number;
331
+ entitiesReassigned: number;
332
+ entitiesDropped: number;
333
+ }>;
302
334
  private callAuth;
303
335
  private persist;
304
336
  private wipeLocalIdentity;
@@ -719,6 +751,63 @@ declare class SendoraSDK {
719
751
  registerToken: (reg: PushTokenRegistration) => Promise<PushTokenReceipt>;
720
752
  trackOpen: (sendId: string, clickAction?: string) => Promise<void>;
721
753
  };
754
+ /**
755
+ * Attribution namespace — mobile install + deferred deep-link reporting.
756
+ * Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
757
+ *
758
+ * Backend resolves `orgId` + `projectId` from the API key, so the SDK
759
+ * helper accepts only the device-side context (fingerprint, OS, app
760
+ * version). Both methods are safe to call on every cold-start —
761
+ * backend deduplicates installs by `deviceId` within a 24h window so
762
+ * a paranoid app calling `reportInstall()` on every launch sees
763
+ * `deduplicated: true` after the first match.
764
+ *
765
+ * Typical pairing:
766
+ * await SendoraCloud.attribution.reportInstall({
767
+ * deviceId,
768
+ * fingerprintHash: await computeDeviceFingerprint(),
769
+ * appVersion,
770
+ * });
771
+ * const deferred = await SendoraCloud.attribution.checkDeferred();
772
+ * if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
773
+ */
774
+ get attribution(): {
775
+ reportInstall: (input?: {
776
+ deviceId?: string;
777
+ fingerprintHash?: string;
778
+ appVersion?: string;
779
+ os?: string;
780
+ osVersion?: string;
781
+ country?: string;
782
+ }) => Promise<{
783
+ installId: string;
784
+ deduplicated: boolean;
785
+ attributed: boolean;
786
+ result?: {
787
+ matchType: string;
788
+ confidence?: number;
789
+ clickId?: string;
790
+ linkId?: string;
791
+ campaign?: string;
792
+ source?: string;
793
+ medium?: string;
794
+ deepLinkData?: Record<string, unknown>;
795
+ deepLinkPath?: string;
796
+ };
797
+ }>;
798
+ checkDeferred: (input?: {
799
+ fingerprintHash?: string;
800
+ deviceId?: string;
801
+ }) => Promise<{
802
+ matchType: string;
803
+ confidence?: number;
804
+ campaign?: string;
805
+ source?: string;
806
+ medium?: string;
807
+ deepLinkData?: Record<string, unknown>;
808
+ deepLinkPath?: string;
809
+ } | null>;
810
+ };
722
811
  /**
723
812
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
724
813
  * the AsyncStorage writes so a caller who kills the app immediately
package/dist/index.js CHANGED
@@ -742,6 +742,75 @@ var Auth = class {
742
742
  })();
743
743
  return this.refreshInflight;
744
744
  }
745
+ /**
746
+ * Returns the currently-stored refresh token (or null when signed out).
747
+ * Primary use: capture the anonymous refresh token BEFORE calling
748
+ * `signIn()` so it can be transferred via `merge()` after the existing
749
+ * account is authenticated. See the "Anonymous → identified" section
750
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
751
+ */
752
+ getRefreshToken() {
753
+ return this.hooks.storage.get(REFRESH_KEY) ?? null;
754
+ }
755
+ /**
756
+ * Transfer every event + trait from an anonymous user to the
757
+ * currently-signed-in (target) user, then delete the anonymous row.
758
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
759
+ * token — call this AFTER a successful `signIn()` so the storage
760
+ * already holds the target's refresh.
761
+ *
762
+ * Throws AuthError when:
763
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
764
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
765
+ * - the target is anonymous (CONFLICT — sign in first),
766
+ * - both tokens resolve to the same user (CONFLICT — no-op).
767
+ *
768
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
769
+ */
770
+ async merge(anonymousRefreshToken, targetRefreshToken) {
771
+ const target = targetRefreshToken ?? this.getRefreshToken();
772
+ if (!target) {
773
+ throw new AuthError(
774
+ "NO_TARGET_REFRESH",
775
+ "merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
776
+ );
777
+ }
778
+ const res = await post(
779
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
780
+ "/api/v1/auth-service/merge",
781
+ { anonymousRefreshToken, targetRefreshToken: target }
782
+ );
783
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
784
+ let parsed;
785
+ try {
786
+ parsed = await res.json();
787
+ } catch {
788
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
789
+ }
790
+ if (!res.ok || !parsed.success || !parsed.data) {
791
+ throw new AuthError(
792
+ parsed.error?.code ?? `HTTP_${res.status}`,
793
+ parsed.error?.message ?? `HTTP ${res.status}`
794
+ );
795
+ }
796
+ const t = parsed.data.tokens;
797
+ if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
798
+ this.accessToken = t.accessToken;
799
+ this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
800
+ this.hooks.storage.set(TOKEN_KEY, t.accessToken);
801
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
802
+ this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
803
+ }
804
+ return {
805
+ mergedUserId: parsed.data.mergedUserId ?? "",
806
+ deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
807
+ eventsReassigned: parsed.data.eventsReassigned ?? 0,
808
+ profilesReassigned: parsed.data.profilesReassigned ?? 0,
809
+ profilesDropped: parsed.data.profilesDropped ?? 0,
810
+ entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
811
+ entitiesDropped: parsed.data.entitiesDropped ?? 0
812
+ };
813
+ }
745
814
  async callAuth(path, body) {
746
815
  const res = await post(
747
816
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1733,6 +1802,55 @@ var SendoraSDK = class {
1733
1802
  }
1734
1803
  };
1735
1804
  }
1805
+ /**
1806
+ * Attribution namespace — mobile install + deferred deep-link reporting.
1807
+ * Mirrors the backend's `/attribution/{install,deferred}` SDK routes.
1808
+ *
1809
+ * Backend resolves `orgId` + `projectId` from the API key, so the SDK
1810
+ * helper accepts only the device-side context (fingerprint, OS, app
1811
+ * version). Both methods are safe to call on every cold-start —
1812
+ * backend deduplicates installs by `deviceId` within a 24h window so
1813
+ * a paranoid app calling `reportInstall()` on every launch sees
1814
+ * `deduplicated: true` after the first match.
1815
+ *
1816
+ * Typical pairing:
1817
+ * await SendoraCloud.attribution.reportInstall({
1818
+ * deviceId,
1819
+ * fingerprintHash: await computeDeviceFingerprint(),
1820
+ * appVersion,
1821
+ * });
1822
+ * const deferred = await SendoraCloud.attribution.checkDeferred();
1823
+ * if (deferred?.deepLinkPath) router.replace(deferred.deepLinkPath);
1824
+ */
1825
+ get attribution() {
1826
+ const self = this;
1827
+ return {
1828
+ reportInstall: async (input) => {
1829
+ if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
1830
+ const res = await post(
1831
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
1832
+ "/attribution/install",
1833
+ input ?? {}
1834
+ );
1835
+ if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
1836
+ const parsed = await res.json();
1837
+ if (!parsed.success || !parsed.data) throw new Error("[sendoracloud] attribution.reportInstall: bad response");
1838
+ return parsed.data;
1839
+ },
1840
+ checkDeferred: async (input) => {
1841
+ if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
1842
+ const res = await post(
1843
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
1844
+ "/attribution/deferred",
1845
+ input ?? {}
1846
+ );
1847
+ if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
1848
+ const parsed = await res.json();
1849
+ if (!parsed.success) throw new Error("[sendoracloud] attribution.checkDeferred: bad response");
1850
+ return parsed.data ?? null;
1851
+ }
1852
+ };
1853
+ }
1736
1854
  /**
1737
1855
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
1738
1856
  * the AsyncStorage writes so a caller who kills the app immediately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.17.1",
3
+ "version": "0.18.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",