@explorins/pers-sdk-react-native 2.3.6 → 2.3.8

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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1266 -1244
  3. package/dist/__tests__/PersSDKProvider.test.js +33 -0
  4. package/dist/hooks/useBusiness.d.ts +2 -2
  5. package/dist/hooks/useBusiness.d.ts.map +1 -1
  6. package/dist/hooks/useBusiness.js +2 -2
  7. package/dist/hooks/useIPFSUrl.d.ts +3 -3
  8. package/dist/hooks/useIPFSUrl.d.ts.map +1 -1
  9. package/dist/hooks/useIPFSUrl.js +7 -7
  10. package/dist/hooks/useTransactions.d.ts.map +1 -1
  11. package/dist/hooks/useTransactions.js +13 -5
  12. package/dist/hooks/useWeb3.js +4 -4
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2412 -492
  16. package/dist/index.js.map +1 -1
  17. package/dist/providers/PersSDKProvider.d.ts +4 -0
  18. package/dist/providers/PersSDKProvider.d.ts.map +1 -1
  19. package/dist/providers/PersSDKProvider.js +14 -0
  20. package/dist/providers/react-native-http-client.d.ts +2 -0
  21. package/dist/providers/react-native-http-client.d.ts.map +1 -1
  22. package/dist/providers/react-native-http-client.js +5 -2
  23. package/dist/storage/rn-secure-storage.d.ts.map +1 -1
  24. package/dist/storage/rn-secure-storage.js +21 -8
  25. package/package.json +3 -3
  26. package/src/__tests__/PersSDKProvider.test.tsx +484 -420
  27. package/src/__tests__/setup.ts +40 -40
  28. package/src/hooks/index.ts +60 -60
  29. package/src/hooks/useAnalytics.ts +327 -327
  30. package/src/hooks/useAuth.ts +265 -265
  31. package/src/hooks/useBookings.ts +191 -191
  32. package/src/hooks/useBusiness.ts +251 -251
  33. package/src/hooks/useCampaigns.ts +500 -500
  34. package/src/hooks/useDonations.ts +66 -66
  35. package/src/hooks/useEvents.ts +372 -372
  36. package/src/hooks/useFiles.ts +155 -155
  37. package/src/hooks/useIPFSUrl.ts +192 -192
  38. package/src/hooks/usePurchases.ts +65 -65
  39. package/src/hooks/useRedemptions.ts +467 -467
  40. package/src/hooks/useTenants.ts +72 -72
  41. package/src/hooks/useTokenBalances.ts +385 -385
  42. package/src/hooks/useTokens.ts +313 -313
  43. package/src/hooks/useTransactionSigner.ts +611 -611
  44. package/src/hooks/useTransactions.ts +299 -290
  45. package/src/hooks/useTriggerSources.ts +301 -301
  46. package/src/hooks/useUserStatus.ts +61 -61
  47. package/src/hooks/useUsers.ts +167 -167
  48. package/src/hooks/useWeb3.ts +355 -355
  49. package/src/index.ts +777 -774
  50. package/src/polyfills/index.ts +160 -160
  51. package/src/polyfills/web3-polyfills.ts +97 -97
  52. package/src/providers/PersSDKProvider.tsx +393 -374
  53. package/src/providers/react-native-auth-provider.ts +110 -110
  54. package/src/providers/react-native-http-client.ts +150 -145
  55. package/src/providers/rn-dpop-provider.ts +190 -190
  56. package/src/storage/async-storage-token-storage.ts +185 -185
  57. package/src/storage/rn-secure-storage.ts +182 -170
  58. package/src/types/react-native-globals.d.ts +45 -45
  59. package/src/utils/index.ts +8 -8
  60. package/src/utils/platform-detection.ts +86 -86
  61. package/dist/index-CzLxqePZ.js +0 -7717
  62. package/dist/index-CzLxqePZ.js.map +0 -1
package/README.md CHANGED
@@ -1,1245 +1,1267 @@
1
- # PERS SDK React Native
2
-
3
- A comprehensive React Native SDK for the PERS (Phygital Experience Rewards System) platform, designed specifically for tourism loyalty applications with blockchain transaction signing capabilities.
4
-
5
- ## Features
6
-
7
- - **Secure Authentication**: WebAuthn-based authentication with device biometrics
8
- - **Blockchain Integration**: Complete transaction signing with PERS Signer SDK supporting EVM-compatible chains (Ethereum, Polygon, Camino, etc.)
9
- - **Token Operations**: Balance checking, earning, redeeming, and transfers
10
- - **Business Management**: Business profiles, campaigns, and operations
11
- - **Campaign System**: Marketing campaigns, participation, and rewards
12
- - **Redemption Engine**: Comprehensive reward redemption and fulfillment
13
- - **Analytics**: Real-time analytics and transaction monitoring
14
- - **Donations**: Charitable giving and donation management
15
- - **User Management**: Profile management and user operations
16
- - **Web3 Support**: EVM-compatible chain wallet operations and blockchain interactions
17
- - **Secure Storage**: Hybrid storage strategy using **Keychain** (High Security) for secrets and **AsyncStorage** for public data.
18
- - **DPoP Integration**: Native C++ bridge for high-performance ECDSA signing via `react-native-quick-crypto`.
19
- - **React Native Optimized**: Full platform support with automatic polyfills.
20
-
21
- ## Installation
22
-
23
- ```bash
24
- npm install @explorins/pers-sdk-react-native
25
- ```
26
-
27
- ### Required Peer Dependencies
28
-
29
- You must install `async-storage` manually as it is a peer dependency:
30
-
31
- ```bash
32
- npm install @react-native-async-storage/async-storage
33
- ```
34
-
35
- ### Included Native Dependencies
36
-
37
- The SDK automatically installs the following native libraries. **You must rebuild your native app** (e.g., `npx expo run:android` or `cd ios && pod install`) after installing the SDK to link these native modules:
38
-
39
- - `react-native-quick-crypto` (High-performance Crypto)
40
- - `react-native-keychain` (Secure Storage)
41
- - `react-native-passkey` (WebAuthn/Passkeys)
42
- - `react-native-get-random-values` (Crypto Primitives)
43
-
44
- ### Optional Dependencies
45
-
46
- ```bash
47
- # For URL Polyfills (Recommended if not already included in your app)
48
- npm install react-native-url-polyfill
49
- ```
50
-
51
- ### Metro Configuration (Required)
52
-
53
- The PERS SDK uses subpath exports (`@explorins/pers-sdk/core`, etc.) which require Metro to be configured. Add to your `metro.config.js`:
54
-
55
- ```javascript
56
- const { getDefaultConfig } = require('expo/metro-config');
57
- // or for bare RN: const { getDefaultConfig } = require('@react-native/metro-config');
58
-
59
- const config = getDefaultConfig(__dirname);
60
-
61
- // REQUIRED: Enable package exports for PERS SDK subpath imports
62
- config.resolver.unstable_enablePackageExports = true;
63
-
64
- module.exports = config;
65
- ```
66
-
67
- ---
68
-
69
- ## ⚠️ Android Architecture Requirements
70
-
71
- **The SDK requires React Native New Architecture** starting from v2.1.x.
72
-
73
- ### Why?
74
-
75
- `react-native-quick-crypto` v1.0+ (used for high-performance cryptographic operations) uses [Nitro Modules](https://github.com/margelo/nitro), which require New Architecture.
76
-
77
- ### Quick Check
78
-
79
- If you see errors like:
80
- ```
81
- NitroModulesProxy.createHybridObject(...) threw an unknown std::runtime_error
82
- ```
83
-
84
- Your app is running with **Old Architecture** (`newArchEnabled=false`).
85
-
86
- ### Solution 1: Enable New Architecture (Recommended)
87
-
88
- React Native 0.76+ has stable New Architecture support. Enable it in `android/gradle.properties`:
89
-
90
- ```properties
91
- # android/gradle.properties
92
-
93
- # Use this property to enable support to the new architecture.
94
- newArchEnabled=true
95
-
96
- # Use this property to enable or disable the Hermes JS engine.
97
- hermesEnabled=true
98
- ```
99
-
100
- Then clean and rebuild:
101
-
102
- ```bash
103
- cd android && ./gradlew clean && cd ..
104
- npx react-native start --reset-cache
105
- npx react-native run-android
106
- ```
107
-
108
- > **Reference**: See the [VQ Demo App's gradle.properties](https://github.com/explorins/vq-pers-integration-demo/blob/main/android/gradle.properties) for a working configuration with Expo SDK 54 and React Native 0.81.5.
109
-
110
- ### Solution 2: Downgrade quick-crypto (For Old Architecture)
111
-
112
- If you must stay on Old Architecture, pin to v0.7.x:
113
-
114
- ```json
115
- {
116
- "resolutions": {
117
- "react-native-quick-crypto": "0.7.17"
118
- }
119
- }
120
- ```
121
-
122
- Or for npm (in `package.json`):
123
-
124
- ```json
125
- {
126
- "overrides": {
127
- "react-native-quick-crypto": "0.7.17"
128
- }
129
- }
130
- ```
131
-
132
- ### Compatibility Matrix
133
-
134
- | RN Version | Architecture | quick-crypto | Status |
135
- |------------|--------------|--------------|--------|
136
- | 0.76+ | New (`newArchEnabled=true`) | 1.0.6+ | ✅ Recommended |
137
- | 0.76+ | Old (`newArchEnabled=false`) | 0.7.17 | ⚠️ Requires downgrade |
138
- | < 0.76 | Old | 0.7.x | ⚠️ Use older quick-crypto |
139
-
140
- > **See [Android Compatibility FAQ](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/ANDROID_COMPATIBILITY_FAQ.md)** for detailed troubleshooting.
141
-
142
- ---
143
-
144
- ## Critical Setup Requirement: Passkeys
145
-
146
- To enable Passkey authentication (WebAuthn) on iOS and Android, you **must**:
147
-
148
- 1. **Register your app with PERS backend** - Contact **support@explorins.com** with:
149
- - iOS: Bundle ID + Apple Team ID
150
- - Android: Package Name + SHA-256 fingerprint
151
-
152
- 2. **Configure native files** - See [Passkey Setup Guide](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/REACT_NATIVE_PASSKEY_SETUP.md)
153
-
154
- 3. **For Expo**: Use development builds (`npx expo run:android`), not Expo Go
155
-
156
- ## Quick Start
157
-
158
- ### 1. Setup the Provider
159
-
160
- ```typescript
161
- import React from 'react';
162
- import { PersSDKProvider } from '@explorins/pers-sdk-react-native';
163
-
164
- export default function App() {
165
- return (
166
- <PersSDKProvider config={{
167
- apiUrl: 'https://api.pers.ninja',
168
- tenantId: 'your-tenant-id' // Optional
169
- }}>
170
- <NavigationContainer>
171
- <YourAppContent />
172
- </NavigationContainer>
173
- </PersSDKProvider>
174
- );
175
- }
176
- ```
177
-
178
- ### 2. Authentication & Token Operations
179
-
180
- ```typescript
181
- import {
182
- useAuth,
183
- useTokens,
184
- useRedemptions,
185
- useTransactionSigner
186
- } from '@explorins/pers-sdk-react-native';
187
-
188
- function RewardScreen() {
189
- const { user, isAuthenticated, login } = useAuth();
190
- const { getTokens } = useTokens();
191
- const { redeem } = useRedemptions();
192
- const { signAndSubmitTransactionWithJWT, isSignerAvailable } = useTransactionSigner();
193
-
194
- const handleLogin = async () => {
195
- try {
196
- await login('your-jwt-token');
197
- console.log('Authenticated:', user?.identifier);
198
- } catch (error) {
199
- console.error('Login failed:', error);
200
- }
201
- };
202
-
203
- const handleLoadTokens = async () => {
204
- try {
205
- const tokens = await getTokens();
206
- console.log('User tokens:', tokens);
207
- } catch (error) {
208
- console.error('Failed to load tokens:', error);
209
- }
210
- };
211
-
212
- const handleRedemption = async () => {
213
- try {
214
- // Step 1: Create redemption
215
- const redemptionId = 'redemption-id-from-ui';
216
- const result = await redeem(redemptionId);
217
-
218
- // Note: The redeem method automatically handles blockchain signing if required
219
- // and if the signer is available.
220
-
221
- if (result.isSigned) {
222
- console.log('Redemption completed with blockchain signature!');
223
- console.log('Transaction Hash:', result.transactionHash);
224
- }
225
- } catch (error) {
226
- console.error('Redemption failed:', error);
227
- }
228
- };
229
-
230
- return (
231
- <ScrollView style={styles.container}>
232
- {!isAuthenticated ? (
233
- <TouchableOpacity onPress={handleLogin} style={styles.loginButton}>
234
- <Text style={styles.buttonText}>Login with PERS</Text>
235
- </TouchableOpacity>
236
- ) : (
237
- <View>
238
- <Text style={styles.welcome}>Welcome, {user?.identifier}!</Text>
239
-
240
- <TouchableOpacity onPress={handleLoadTokens} style={styles.button}>
241
- <Text style={styles.buttonText}>Load Tokens</Text>
242
- </TouchableOpacity>
243
-
244
- <TouchableOpacity
245
- onPress={handleRedemption}
246
- disabled={!isSignerAvailable}
247
- style={[styles.button, !isSignerAvailable && styles.disabled]}
248
- >
249
- <Text style={styles.buttonText}>
250
- {isSignerAvailable ? 'Redeem Rewards' : 'Signer Loading...'}
251
- </Text>
252
- </TouchableOpacity>
253
- </View>
254
- )}
255
- </ScrollView>
256
- );
257
- }
258
- ```
259
-
260
- ## Core Hooks
261
-
262
- ### Authentication & Users
263
-
264
- ```typescript
265
- // Authentication management
266
- const {
267
- isInitialized,
268
- isAuthenticated,
269
- user,
270
- login,
271
- loginWithRawData,
272
- logout,
273
- refreshUserData,
274
- getCurrentUser,
275
- checkIsAuthenticated,
276
- refreshTokens,
277
- clearAuth,
278
- hasValidAuth
279
- } = useAuth();
280
-
281
- // User profile operations
282
- const {
283
- getCurrentUser,
284
- updateCurrentUser,
285
- getUserById,
286
- getAllUsersPublic,
287
- getAllUsers, // Admin
288
- updateUser, // Admin
289
- toggleUserStatus // Admin
290
- } = useUsers();
291
- ```
292
-
293
- ### Token & Financial Operations
294
-
295
- ```typescript
296
- // Token management
297
- const {
298
- getTokens,
299
- getActiveCreditToken,
300
- getRewardTokens,
301
- getTokenTypes,
302
- getStatusTokens,
303
- getTokenByContract
304
- } = useTokens();
305
-
306
- // Token balance loading (NEW in v2.1.1)
307
- const {
308
- tokenBalances, // Array of balances with token metadata
309
- isLoading, // Loading state
310
- error, // Error state
311
- refresh // Manual refresh function
312
- } = useTokenBalances({
313
- availableTokens, // From useTokens()
314
- autoLoad: true, // Auto-load on mount
315
- refreshInterval: 30000 // Optional: refresh every 30s
316
- });
317
-
318
- // Transaction history
319
- const {
320
- createTransaction,
321
- getTransactionById,
322
- getUserTransactionHistory, // With TransactionQueryOptions (includes pagination + filters + include)
323
- getPaginatedTransactions, // Admin - single options object with include
324
- exportTransactionsCSV, // Admin
325
- signingStatus, // UI feedback during blockchain signing
326
- signingStatusMessage
327
- } = useTransactions();
328
-
329
- // Blockchain transaction signing
330
- const {
331
- signAndSubmitTransactionWithJWT,
332
- isSignerAvailable,
333
- isSignerInitialized,
334
- currentStatus,
335
- statusMessage
336
- } = useTransactionSigner();
337
- ```
338
-
339
- ### Business & Campaign Management
340
-
341
- ```typescript
342
- // Business operations
343
- const {
344
- getActiveBusinesses,
345
- getBusinessTypes,
346
- getBusinesses,
347
- getBusinessById,
348
- getBusinessByAccount,
349
- getBusinessesByType,
350
- createBusiness, // Admin
351
- updateBusiness, // Admin
352
- toggleBusinessStatus // Admin
353
- } = useBusiness();
354
-
355
- // Campaign management
356
- const {
357
- getActiveCampaigns,
358
- getCampaigns, // With options: { active?, businessId?, sortBy?, include?, page?, limit? }
359
- getCampaignById,
360
- claimCampaign,
361
- getUserClaims, // With options: { page?, limit?, include? }
362
- getCampaignTriggers,
363
- getAllCampaigns, // Admin
364
- getCampaignClaims, // Admin - with options object
365
- getCampaignClaimsByUserId, // Admin
366
- getCampaignClaimsByBusinessId // Admin
367
- } = useCampaigns();
368
-
369
- // Redemption system
370
- const {
371
- getActiveRedemptions,
372
- getUserRedemptions,
373
- redeem,
374
- getRedemptionTypes,
375
- signingStatus, // UI feedback during blockchain signing
376
- signingStatusMessage,
377
- createRedemption, // Admin
378
- getAllRedemptions, // Admin
379
- updateRedemption, // Admin
380
- toggleRedemptionStatus // Admin
381
- } = useRedemptions();
382
- ```
383
-
384
- ### Platform & Analytics
385
-
386
- ```typescript
387
- // Purchase processing
388
- const {
389
- createPaymentIntent,
390
- getActivePurchaseTokens,
391
- getAllUserPurchases
392
- } = usePurchases();
393
-
394
- // Multi-tenant support
395
- const {
396
- getTenantInfo,
397
- getClientConfig,
398
- getLoginToken,
399
- getAdmins
400
- } = useTenants();
401
-
402
- // Analytics & reporting
403
- const {
404
- getTransactionAnalytics
405
- } = useAnalytics();
406
-
407
- // Web3 & blockchain (wallet addresses from user.wallets)
408
- const {
409
- getTokenBalance,
410
- getTokenMetadata,
411
- getTokenCollection,
412
- resolveIPFSUrl,
413
- fetchAndProcessMetadata,
414
- getChainDataById,
415
- getExplorerUrl,
416
- // Helper methods for token collections
417
- extractTokenIds, // Extract tokenIds from TokenDTO metadata
418
- getAccountOwnedTokensFromContract, // Recommended: Get owned tokens automatically
419
- buildCollectionRequest // Build request for getTokenCollection
420
- } = useWeb3();
421
-
422
- // User status & achievements
423
- const {
424
- getUserStatusTypes,
425
- getEarnedUserStatus,
426
- createUserStatusType // Admin
427
- } = useUserStatus();
428
-
429
- // File management
430
- const {
431
- getSignedPutUrl,
432
- getSignedGetUrl,
433
- getSignedUrl,
434
- optimizeMedia
435
- } = useFiles();
436
-
437
- // Donations
438
- const {
439
- getDonationTypes
440
- } = useDonations();
441
-
442
- // Event subscriptions (notifications, logging)
443
- const {
444
- subscribe, // Subscribe to SDK events
445
- once, // One-time event listener
446
- clear, // Clear all subscriptions
447
- isAvailable, // Event system available
448
- subscriberCount // Active subscriber count
449
- } = useEvents();
450
- ```
451
-
452
- ## Event System
453
-
454
- The `useEvents` hook provides access to SDK-wide events for showing notifications, logging, and reacting to SDK operations. All events include a `userMessage` field ready for UI display.
455
-
456
- ### Basic Usage
457
-
458
- ```typescript
459
- import { useEvents } from '@explorins/pers-sdk-react-native';
460
- import { useEffect } from 'react';
461
-
462
- function NotificationHandler() {
463
- const { subscribe, isAvailable } = useEvents();
464
-
465
- useEffect(() => {
466
- if (!isAvailable) return;
467
-
468
- // Subscribe to all events
469
- const unsubscribe = subscribe((event) => {
470
- showNotification(event.userMessage, event.level);
471
- });
472
-
473
- return () => unsubscribe(); // Cleanup on unmount
474
- }, [subscribe, isAvailable]);
475
- }
476
- ```
477
-
478
- ### Filtering Events
479
-
480
- ```typescript
481
- // Only transaction successes
482
- subscribe(
483
- (event) => {
484
- playSuccessSound();
485
- showConfetti();
486
- },
487
- { domain: 'transaction', level: 'success' }
488
- );
489
-
490
- // Only errors (for logging)
491
- subscribe(
492
- (event) => {
493
- logToSentry(event);
494
- },
495
- { level: 'error' }
496
- );
497
-
498
- // One-time event (auto-unsubscribes)
499
- once(
500
- (event) => {
501
- console.log('First transaction completed!');
502
- },
503
- { domain: 'transaction', level: 'success' }
504
- );
505
- ```
506
-
507
- ### Event Domains
508
-
509
- | Domain | Events |
510
- |--------|--------|
511
- | `auth` | Login, logout, token refresh |
512
- | `user` | Profile updates |
513
- | `transaction` | Created, completed, failed |
514
- | `campaign` | Claimed, activated |
515
- | `redemption` | Redeemed, expired |
516
- | `business` | Created, updated, membership |
517
- | `api` | Network errors, validation errors |
518
-
519
- ### Event Structure
520
-
521
- ```typescript
522
- interface PersEvent {
523
- id: string; // Unique event ID
524
- timestamp: number; // Unix timestamp (ms)
525
- domain: string; // Event domain (transaction, auth, etc.)
526
- type: string; // Event type within domain
527
- level: 'success' | 'error';
528
- userMessage: string; // Ready for UI display
529
- action?: string; // Suggested action
530
- details?: object; // Additional data
531
- }
532
- ```
533
-
534
- ---
535
-
536
- ## POS Transaction Flow
537
-
538
- For Point-of-Sale scenarios where a **business submits a transaction on behalf of a user**, use the `buildPOSTransferRequest` helper:
539
-
540
- ```typescript
541
- import {
542
- useTransactions,
543
- buildPOSTransferRequest,
544
- useEvents
545
- } from '@explorins/pers-sdk-react-native';
546
-
547
- function POSScreen() {
548
- const { createTransaction, signingStatus } = useTransactions();
549
- const { subscribe, isAvailable } = useEvents();
550
-
551
- // Listen for transaction events
552
- useEffect(() => {
553
- if (!isAvailable) return;
554
-
555
- const unsubscribe = subscribe(
556
- (event) => {
557
- if (event.level === 'success') {
558
- Alert.alert('Success', event.userMessage);
559
- }
560
- },
561
- { domain: 'transaction' }
562
- );
563
-
564
- return () => unsubscribe();
565
- }, [subscribe, isAvailable]);
566
-
567
- const handlePOSTransaction = async (
568
- userId: string,
569
- businessId: string,
570
- amount: number,
571
- token: TokenDTO
572
- ) => {
573
- // Build POS transfer request
574
- const request = buildPOSTransferRequest({
575
- amount,
576
- contractAddress: token.contractAddress,
577
- chainId: token.chainId,
578
- userId, // User sending tokens
579
- businessId // Business receiving & authorized to submit
580
- });
581
-
582
- // Create and sign transaction
583
- const result = await createTransaction(request, (status, message) => {
584
- console.log(`Signing: ${status} - ${message}`);
585
- });
586
-
587
- console.log('Transaction created:', result.transaction?.id);
588
- };
589
- }
590
- ```
591
-
592
- ### POS Authorization Fields
593
-
594
- The `buildPOSTransferRequest` helper automatically sets:
595
-
596
- | Field | Value | Purpose |
597
- |-------|-------|---------|
598
- | `engagedBusinessId` | Business ID | Business commercially involved (for reporting) |
599
- | `authorizedSubmitterId` | Business ID | Entity authorized to submit the signed tx |
600
- | `authorizedSubmitterType` | `BUSINESS` | Type of authorized submitter |
601
-
602
- For custom scenarios, use `buildTransferRequest` with manual POS fields:
603
-
604
- ```typescript
605
- import { buildTransferRequest, AccountOwnerType } from '@explorins/pers-sdk-react-native';
606
-
607
- const request = buildTransferRequest({
608
- amount: 100,
609
- contractAddress: '0x...',
610
- chainId: 137,
611
- senderAccountId: 'user-123',
612
- senderAccountType: AccountOwnerType.USER,
613
- recipientAccountId: 'business-456',
614
- recipientAccountType: AccountOwnerType.BUSINESS,
615
- // POS authorization
616
- engagedBusinessId: 'business-456',
617
- authorizedSubmitterId: 'business-456',
618
- authorizedSubmitterType: AccountOwnerType.BUSINESS
619
- });
620
- ```
621
-
622
- ---
623
-
624
- ## Token Collection Helper Methods
625
-
626
- The `useWeb3` hook includes helper methods for querying token balances from any blockchain address. These work with **all token standards** (ERC-20, ERC-721, ERC-1155).
627
-
628
- ### Recommended: getAccountOwnedTokensFromContract
629
-
630
- A unified API to get all tokens owned by any blockchain address:
631
-
632
- ```typescript
633
- import { useWeb3, useTokens, usePersSDK } from '@explorins/pers-sdk-react-native';
634
-
635
- function RewardTokensScreen() {
636
- const { user } = usePersSDK();
637
- const { getRewardTokens } = useTokens();
638
- const { getAccountOwnedTokensFromContract } = useWeb3();
639
-
640
- const loadUserRewards = async () => {
641
- const walletAddress = user?.wallets?.[0]?.address;
642
- if (!walletAddress) return;
643
-
644
- const rewardTokens = await getRewardTokens();
645
-
646
- for (const token of rewardTokens) {
647
- // Works with ERC-20, ERC-721, and ERC-1155 automatically
648
- const result = await getAccountOwnedTokensFromContract(walletAddress, token);
649
-
650
- console.log(`Token: ${token.symbol}`);
651
- console.log(`Owned: ${result.totalOwned}`);
652
-
653
- result.ownedTokens.forEach(owned => {
654
- console.log(` - ${owned.metadata?.name}: ${owned.balance}`);
655
- });
656
- }
657
- };
658
-
659
- return (
660
- <TouchableOpacity onPress={loadUserRewards}>
661
- <Text>Load My Rewards</Text>
662
- </TouchableOpacity>
663
- );
664
- }
665
- ```
666
-
667
- ### How It Handles Each Token Standard
668
-
669
- | Token Type | What It Does |
670
- |------------|--------------|
671
- | **ERC-20** | Returns balance for fungible tokens |
672
- | **ERC-721** | Enumerates all owned NFTs |
673
- | **ERC-1155** | Extracts tokenIds from metadata, queries balances |
674
-
675
- The helper abstracts away the complexity - especially for ERC-1155 which requires specific `tokenIds` that the helper extracts from `token.metadata[].tokenMetadataIncrementalId`.
676
-
677
- ### Return Type
678
-
679
- ```typescript
680
- interface AccountOwnedTokensResult {
681
- token: TokenDTO; // The token definition
682
- ownedTokens: TokenBalance[]; // Tokens with balance > 0
683
- totalOwned: number; // Count of owned tokens
684
- }
685
- ```
686
-
687
- ## EVM Blockchain Transaction Signing
688
-
689
- The `useTransactionSigner` hook provides secure EVM blockchain transaction signing:
690
-
691
- ```typescript
692
- import { useTransactionSigner } from '@explorins/pers-sdk-react-native';
693
-
694
- function BlockchainRedemption() {
695
- const {
696
- signAndSubmitTransactionWithJWT,
697
- isSignerAvailable,
698
- isSignerInitialized
699
- } = useTransactionSigner();
700
-
701
- const handleBlockchainRedemption = async (jwtToken: string) => {
702
- if (!isSignerAvailable) {
703
- console.error('EVM blockchain signer not available');
704
- return;
705
- }
706
-
707
- try {
708
- // This handles the complete flow:
709
- // 1. User authentication via WebAuthn
710
- // 2. Transaction data fetching from PERS backend
711
- // 3. Secure EVM transaction signing using device biometrics
712
- // 4. EVM blockchain submission
713
- const result = await signAndSubmitTransactionWithJWT(jwtToken);
714
-
715
- if (result.success) {
716
- console.log('Transaction completed!');
717
- console.log('Hash:', result.transactionHash);
718
- console.log('View on blockchain explorer: [Chain-specific URL]');
719
-
720
- // Handle post-transaction flow
721
- if (result.shouldRedirect && result.redirectUrl) {
722
- // Navigate to success page or external URL
723
- Linking.openURL(result.redirectUrl);
724
- }
725
- }
726
- } catch (error) {
727
- if (error.message.includes('expired')) {
728
- // JWT token expired - redirect to login
729
- navigation.navigate('Login');
730
- } else if (error.message.includes('cancelled')) {
731
- // User cancelled WebAuthn authentication
732
- console.log('User cancelled transaction');
733
- } else {
734
- // Other errors
735
- Alert.alert('Transaction Failed', error.message);
736
- }
737
- }
738
- };
739
-
740
- // Show loading state while signer initializes
741
- if (!isSignerInitialized) {
742
- return (
743
- <View style={styles.loadingContainer}>
744
- <ActivityIndicator size="large" />
745
- <Text>Initializing EVM blockchain signer...</Text>
746
- </View>
747
- );
748
- }
749
-
750
- return (
751
- <TouchableOpacity
752
- onPress={() => handleBlockchainRedemption(redemptionJWT)}
753
- disabled={!isSignerAvailable}
754
- style={[styles.button, !isSignerAvailable && styles.disabled]}
755
- >
756
- <Text style={styles.buttonText}>
757
- Sign & Submit Transaction
758
- </Text>
759
- </TouchableOpacity>
760
- );
761
- }
762
- ```
763
- ### Error Handling
764
-
765
- The SDK provides comprehensive structured error handling with utilities for consistent error management:
766
-
767
- ```typescript
768
- import {
769
- PersApiError,
770
- ErrorUtils
771
- } from '@explorins/pers-sdk-react-native';
772
- import { Alert } from 'react-native';
773
-
774
- // Structured error handling
775
- try {
776
- await sdk.campaigns.claimCampaign({ campaignId });
777
- } catch (error) {
778
- if (error instanceof PersApiError) {
779
- // Structured error with backend details
780
- console.log('Error code:', error.code); // 'CAMPAIGN_BUSINESS_REQUIRED'
781
- console.log('Status:', error.status); // 400
782
- console.log('User message:', error.userMessage); // User-friendly message
783
-
784
- // Show user-friendly error
785
- Alert.alert('Error', error.userMessage || error.message);
786
-
787
- // Check if retryable
788
- if (error.retryable) {
789
- Alert.alert('Error', error.message, [
790
- { text: 'Cancel' },
791
- { text: 'Retry', onPress: () => retry() }
792
- ]);
793
- }
794
- } else {
795
- // Generic error fallback with ErrorUtils
796
- const message = ErrorUtils.getMessage(error);
797
- Alert.alert('Error', message);
798
- }
799
- }
800
-
801
- // Error utilities for any error type
802
- const status = ErrorUtils.getStatus(error); // Extract HTTP status
803
- const message = ErrorUtils.getMessage(error); // Extract user message
804
- const retryable = ErrorUtils.isRetryable(error); // Check if retryable
805
- const tokenExpired = ErrorUtils.isTokenExpiredError(error); // Detect token expiration
806
- ```
807
-
808
- ### Transaction Helpers
809
-
810
- **NEW in v2.1.1:** Factory functions for client-side transaction workflows:
811
-
812
- ```typescript
813
- import {
814
- ClientTransactionType,
815
- buildPendingTransactionData,
816
- extractDeadlineFromSigningData,
817
- type PendingTransactionParams
818
- } from '@explorins/pers-sdk-react-native';
819
-
820
- // Build pending transaction data for QR codes, NFC, deep links, etc.
821
- const qrData = buildPendingTransactionData(
822
- signingResult.transactionId,
823
- signingResult.signature,
824
- 'EIP-712' // Default format
825
- );
826
-
827
- console.log(qrData);
828
- // {
829
- // transactionId: '...',
830
- // signature: '0x...',
831
- // transactionFormat: 'EIP-712',
832
- // txType: 'PENDING_SUBMISSION'
833
- // }
834
-
835
- // Serialize for transfer (QR code, NFC, etc.)
836
- const qrCodeValue = JSON.stringify(qrData);
837
-
838
- // Extract deadline from EIP-712 signing data
839
- const deadline = extractDeadlineFromSigningData(signingResult.signingData);
840
- if (deadline) {
841
- const expiryTime = new Date(deadline * 1000);
842
- console.log('Transaction expires at:', expiryTime);
843
- }
844
-
845
- // Client transaction types
846
- if (transaction.txType === ClientTransactionType.PENDING_SUBMISSION) {
847
- // Handle pending submission flow
848
- }
849
- ```
850
- ## Security Features
851
-
852
- ### WebAuthn Authentication
853
- - Device biometric authentication (fingerprint, face ID, PIN)
854
- - No passwords or private keys stored locally
855
- - Secure hardware-backed authentication
856
-
857
- ### Token Storage
858
- - React Native Keychain integration for sensitive data
859
- - Automatic token refresh and expiration handling
860
- - Multiple storage strategies (AsyncStorage, Keychain, Memory)
861
-
862
- ### Transaction Security
863
- - JWT token validation and expiration checking
864
- - Secure transaction signing without exposing private keys
865
- - Automatic session management with secure caching
866
-
867
- ## Platform Support
868
-
869
- - **iOS**: Full support with Keychain integration
870
- - **Android**: Full support with Keystore integration
871
- - **Expo**: Compatible with Expo managed workflow
872
- - **Web**: React Native Web compatibility
873
-
874
- ---
875
-
876
- ## Analytics & Attribution Tracking
877
-
878
- The SDK provides built-in analytics tracking for user acquisition and engagement. The `PersSDKProvider` **automatically detects the platform** (iOS/Android) and sends tracking headers with all API requests.
879
-
880
- ### Automatic Platform Detection
881
-
882
- When you wrap your app with `PersSDKProvider`, platform info is auto-detected:
883
-
884
- ```typescript
885
- // Platform is automatically detected and set:
886
- // { os: 'iOS', osVersion: '17.4', deviceType: 'phone', app: 'MyApp' }
887
- ```
888
-
889
- ### Manual Data Source Tracking
890
-
891
- For campaign attribution, use `usePersSDK()` to set custom tracking data:
892
-
893
- ```typescript
894
- import { usePersSDK } from '@explorins/pers-sdk-react-native';
895
- import { Linking } from 'react-native';
896
-
897
- function DeepLinkHandler() {
898
- const { sdk } = usePersSDK();
899
-
900
- useEffect(() => {
901
- // Handle deep links with UTM params
902
- Linking.getInitialURL().then(url => {
903
- if (url) {
904
- const params = new URL(url).searchParams;
905
-
906
- sdk.setDataSource({
907
- channel: 'mobile',
908
- medium: params.get('utm_medium') || undefined,
909
- campaign: params.get('utm_campaign') || undefined,
910
- source: params.get('utm_source') || undefined,
911
- });
912
- }
913
- });
914
- }, [sdk]);
915
- }
916
- ```
917
-
918
- ### HTTP Headers Sent
919
-
920
- The SDK automatically sends tracking data as HTTP headers:
921
-
922
- | Header | Source | Description |
923
- |--------|--------|-------------|
924
- | `x-source-channel` | `dataSource.channel` | `'mobile'` for native apps |
925
- | `x-source-medium` | `dataSource.medium` | Traffic medium (push, email, etc.) |
926
- | `x-source-campaign` | `dataSource.campaign` | Campaign identifier |
927
- | `x-source` | `dataSource.source` | Traffic source |
928
- | `x-platform` | Auto-detected | JSON: `{ os, osVersion, deviceType, app }` |
929
-
930
- ### Push Notification Attribution
931
-
932
- Track users coming from push notifications:
933
-
934
- ```typescript
935
- import { usePersSDK } from '@explorins/pers-sdk-react-native';
936
-
937
- function usePushNotificationTracking() {
938
- const { sdk } = usePersSDK();
939
-
940
- const handlePushNotification = (notification: any) => {
941
- // Extract campaign from notification data
942
- const campaignId = notification.data?.campaignId;
943
-
944
- if (campaignId) {
945
- sdk.updateDataSource({
946
- medium: 'push',
947
- campaign: campaignId,
948
- });
949
- }
950
- };
951
-
952
- return { handlePushNotification };
953
- }
954
- ```
955
-
956
- ---
957
-
958
- ## Advanced Configuration
959
-
960
- ### SDK Initialization & DPoP
961
-
962
- The `PersSDKProvider` accepts a `config` object allowing you to customize behavior, including **DPoP (Distributed Proof of Possession)**.
963
-
964
- **DPoP Behavior:**
965
- - **Enabled by Default**: Automatically active on iOS/Android using a high-performance C++ crypto bridge (`react-native-quick-crypto`).
966
- - **Web Support**: Uses standard WebCrypto API.
967
- - **Security**: Binds access tokens to the device, preventing replay attacks.
968
-
969
- **Customizing Initialization:**
970
- ```typescript
971
- <PersSDKProvider config={{
972
- apiProjectKey: 'your-project-key',
973
- // DPoP is enabled by default. To disable (not recommended):
974
- dpop: {
975
- enabled: false
976
- }
977
- }}>
978
- <YourApp />
979
- </PersSDKProvider>
980
- ```
981
-
982
- ### Storage Fallback Strategy
983
-
984
- The SDK implements a robust **Hybrid Storage Strategy**:
985
- 1. **Primary**: Attempts to use **Android Keystore / iOS Keychain** (via `react-native-keychain`) for maximum security.
986
- 2. **Fallback**: If hardware storage is unavailable or fails, it automatically falls back to **AsyncStorage**.
987
- 3. **Web**: Uses `localStorage` automatically.
988
-
989
- This ensures your app works reliably across all devices while prioritizing security where available.
990
-
991
- ### Custom Authentication Provider
992
-
993
- The SDK automatically uses `ReactNativeSecureStorage` for React Native (iOS/Android) and `LocalStorageTokenStorage` for Web. You can customize this if needed:
994
-
995
- ```typescript
996
- import { createReactNativeAuthProvider, ReactNativeSecureStorage } from '@explorins/pers-sdk-react-native';
997
-
998
- const authProvider = createReactNativeAuthProvider('your-project-key', {
999
- keyPrefix: 'my_app_tokens_',
1000
- debug: true,
1001
- // Optional: Provide custom storage implementation
1002
- // customStorage: new ReactNativeSecureStorage('custom_prefix_')
1003
- });
1004
- ```
1005
-
1006
- ### Error Handling Patterns
1007
-
1008
- ```typescript
1009
- import { useTokens } from '@explorins/pers-sdk-react-native';
1010
-
1011
- function ErrorHandlingExample() {
1012
- const { getTokens } = useTokens();
1013
- const [error, setError] = useState<string | null>(null);
1014
-
1015
- const handleLoadTokensWithErrorHandling = async () => {
1016
- try {
1017
- setError(null);
1018
- await getTokens();
1019
- } catch (err) {
1020
- // Handle specific error types
1021
- if (err.code === 'NETWORK_ERROR') {
1022
- setError('Network connection required');
1023
- } else if (err.code === 'INVALID_TOKEN') {
1024
- setError('Please log in again');
1025
- } else {
1026
- setError(err.message || 'An unexpected error occurred');
1027
- }
1028
- }
1029
- };
1030
- }
1031
- ```
1032
-
1033
- ## Analytics Integration
1034
-
1035
- ```typescript
1036
- import { useAnalytics } from '@explorins/pers-sdk-react-native';
1037
-
1038
- function AnalyticsExample() {
1039
- const { getTransactionAnalytics } = useAnalytics();
1040
-
1041
- const loadAnalytics = async () => {
1042
- try {
1043
- const txAnalytics = await getTransactionAnalytics({
1044
- groupBy: ['day'],
1045
- metrics: ['count', 'sum'],
1046
- startDate: '2023-01-01',
1047
- endDate: '2023-12-31'
1048
- });
1049
-
1050
- console.log('Transaction analytics:', txAnalytics.results);
1051
- console.log('Execution time:', txAnalytics.metadata?.executionTime);
1052
- } catch (error) {
1053
- console.error('Failed to load analytics:', error);
1054
- }
1055
- };
1056
- }
1057
- ```
1058
-
1059
- ## Migration Guide
1060
-
1061
- ### v2.0.0 Breaking Changes - Pagination
1062
-
1063
- **Version 2.0.0 introduces standardized pagination** across all hooks that return lists. Previously, hooks returned raw arrays. Now they return `PaginatedResponseDTO<T>` with pagination metadata.
1064
-
1065
- #### What Changed
1066
-
1067
- All hooks returning lists now return paginated responses:
1068
-
1069
- ```typescript
1070
- // ❌ OLD (v1.x) - Direct array
1071
- const businesses: BusinessDTO[] = await getActiveBusinesses();
1072
-
1073
- // ✅ NEW (v2.x) - Paginated response
1074
- const response: PaginatedResponseDTO<BusinessDTO> = await getActiveBusinesses();
1075
- const businesses: BusinessDTO[] = response.data;
1076
- ```
1077
-
1078
- #### Affected Hooks
1079
-
1080
- | Hook | Method | Return Type |
1081
- |------|--------|-------------|
1082
- | `useBusiness` | `getActiveBusinesses()` | `PaginatedResponseDTO<BusinessDTO>` |
1083
- | `useBusiness` | `getBusinessTypes()` | `PaginatedResponseDTO<BusinessTypeDTO>` |
1084
- | `useCampaigns` | `getActiveCampaigns()` | `PaginatedResponseDTO<CampaignDTO>` |
1085
- | `useCampaigns` | `getUserClaims()` | `PaginatedResponseDTO<CampaignClaimDTO>` |
1086
- | `useTokens` | `getTokens()` | `PaginatedResponseDTO<TokenDTO>` |
1087
- | `useTokens` | `getRewardTokens()` | `PaginatedResponseDTO<TokenDTO>` |
1088
- | `useTokens` | `getStatusTokens()` | `PaginatedResponseDTO<TokenDTO>` |
1089
- | `useRedemptions` | `getActiveRedemptions()` | `PaginatedResponseDTO<RedemptionDTO>` |
1090
- | `useRedemptions` | `getUserRedemptions()` | `PaginatedResponseDTO<RedemptionDTO>` |
1091
- | `useTransactions` | `getUserTransactionHistory()` | `PaginatedResponseDTO<TransactionDTO>` |
1092
- | `usePurchases` | `getAllUserPurchases()` | `PaginatedResponseDTO<PurchaseDTO>` |
1093
- | `usePurchases` | `getActivePurchaseTokens()` | `PaginatedResponseDTO<PurchaseTokenDTO>` |
1094
- | `useDonations` | `getDonationTypes()` | `PaginatedResponseDTO<DonationTypeDTO>` |
1095
-
1096
- #### PaginatedResponseDTO Structure
1097
-
1098
- ```typescript
1099
- import type { PaginatedResponseDTO } from '@explorins/pers-shared';
1100
-
1101
- interface PaginatedResponseDTO<T> {
1102
- data: T[]; // Array of results
1103
- pagination: {
1104
- currentPage: number; // Current page number (1-indexed)
1105
- pageSize: number; // Items per page
1106
- totalItems: number; // Total number of items across all pages
1107
- totalPages: number; // Total number of pages
1108
- };
1109
- }
1110
- ```
1111
-
1112
- #### Migration Examples
1113
-
1114
- **Before (v1.x):**
1115
- ```typescript
1116
- const businesses = await getActiveBusinesses();
1117
- console.log('Business count:', businesses.length);
1118
- businesses.forEach(b => console.log(b.name));
1119
- ```
1120
-
1121
- **After (v2.x):**
1122
- ```typescript
1123
- const response = await getActiveBusinesses();
1124
- console.log('Business count:', response.data.length);
1125
- console.log('Total businesses:', response.pagination.totalItems);
1126
- response.data.forEach(b => console.log(b.name));
1127
- ```
1128
-
1129
- **With Pagination Parameters:**
1130
- ```typescript
1131
- // Fetch page 2 with 20 items per page
1132
- const response = await getActiveBusinesses({ page: 2, pageSize: 20 });
1133
-
1134
- console.log(`Page ${response.pagination.currentPage} of ${response.pagination.totalPages}`);
1135
- console.log(`Showing ${response.data.length} businesses`);
1136
- ```
1137
-
1138
- #### React Native Component Example
1139
-
1140
- ```typescript
1141
- import { useState, useEffect } from 'react';
1142
- import { useBusiness } from '@explorins/pers-sdk-react-native';
1143
- import { View, Text, FlatList, ActivityIndicator } from 'react-native';
1144
-
1145
- function BusinessListScreen() {
1146
- const { getActiveBusinesses } = useBusiness();
1147
- const [businesses, setBusinesses] = useState<BusinessDTO[]>([]);
1148
- const [pagination, setPagination] = useState(null);
1149
- const [loading, setLoading] = useState(true);
1150
-
1151
- useEffect(() => {
1152
- loadBusinesses();
1153
- }, []);
1154
-
1155
- const loadBusinesses = async (page = 1) => {
1156
- try {
1157
- setLoading(true);
1158
- const response = await getActiveBusinesses({ page, pageSize: 20 });
1159
- setBusinesses(response.data);
1160
- setPagination(response.pagination);
1161
- } catch (error) {
1162
- console.error('Failed to load businesses:', error);
1163
- } finally {
1164
- setLoading(false);
1165
- }
1166
- };
1167
-
1168
- if (loading) return <ActivityIndicator />;
1169
-
1170
- return (
1171
- <View>
1172
- <Text>
1173
- Showing {businesses.length} of {pagination?.totalItems} businesses
1174
- </Text>
1175
- <FlatList
1176
- data={businesses}
1177
- keyExtractor={(item) => item.id}
1178
- renderItem={({ item }) => <BusinessCard business={item} />}
1179
- />
1180
- <Text>Page {pagination?.currentPage} of {pagination?.totalPages}</Text>
1181
- </View>
1182
- );
1183
- }
1184
- ```
1185
-
1186
- #### Quick Fix for Existing Code
1187
-
1188
- If you want minimal code changes, extract `.data` immediately:
1189
-
1190
- ```typescript
1191
- // Quick adaptation in your hooks
1192
- const businesses = (await getActiveBusinesses()).data;
1193
- const campaigns = (await getActiveCampaigns()).data;
1194
- const tokens = (await getTokens()).data;
1195
- ```
1196
-
1197
- #### Benefits of Pagination
1198
-
1199
- - **Performance**: Load only what you need, not entire datasets
1200
- - **Consistency**: All list endpoints follow the same pattern
1201
- - **Metadata**: Access total counts without loading all items
1202
- - **Better UX**: Build proper pagination UI components
1203
- - **Memory Efficiency**: Reduced memory footprint for large datasets
1204
-
1205
- ---
1206
-
1207
- ## Documentation
1208
-
1209
- All documentation is available on GitHub:
1210
-
1211
- | Guide | Description |
1212
- |-------|-------------|
1213
- | [Quick Setup Guide](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/QUICK_SETUP_GUIDE.md) | Android + Passkey setup in one page |
1214
- | [**Troubleshooting**](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/TROUBLESHOOTING.md) | **Common issues & solutions** |
1215
- | [Passkey Setup](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/REACT_NATIVE_PASSKEY_SETUP.md) | WebAuthn/Passkey configuration |
1216
- | [Android Compatibility](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/ANDROID_COMPATIBILITY_FAQ.md) | New Architecture troubleshooting |
1217
- | [Auth State Handling](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/AUTH_STATE_HANDLING.md) | Session management patterns |
1218
- | [Changelog](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/CHANGELOG.md) | Version history |
1219
-
1220
- ### Quick Reference: Common Issues
1221
-
1222
- - **User is null after restart?** → Session restoration is async. [See solution](./docs/TROUBLESHOOTING.md#1-sdkuser-is-null-after-app-restart)
1223
- - **Stamp transaction fails?** → Use `tokenBalance.tokenId`, not metadata ID. [See solution](./docs/TROUBLESHOOTING.md#3-stamp-erc721-voucher-transaction-fails)
1224
- - **Which wallet to use?** → Match by `token.chainId`, never use `wallets[0]`. [See solution](./docs/TROUBLESHOOTING.md#6-which-wallet-to-use-multiple-wallets)
1225
- - **Passkey "NotConfigured"?** → Device needs Google Play Services. [See solution](./docs/TROUBLESHOOTING.md#2-passkey-notconfigured-error-on-android)
1226
-
1227
- ---
1228
-
1229
- ## Contributing
1230
-
1231
- We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
1232
-
1233
- ## License
1234
-
1235
- MIT License - see [LICENSE](LICENSE) file for details.
1236
-
1237
- ## Support
1238
-
1239
- - Email: support@explorins.com
1240
- - Documentation: [https://docs.pers.ninja](https://docs.pers.ninja)
1241
- - Issues: [GitHub Issues](https://github.com/eXplorins/pers-sdk/issues)
1242
-
1243
- ---
1244
-
1
+ # PERS SDK React Native
2
+
3
+ A comprehensive React Native SDK for the PERS (Phygital Experience Rewards System) platform, designed specifically for tourism loyalty applications with blockchain transaction signing capabilities.
4
+
5
+ ## Features
6
+
7
+ - **Secure Authentication**: WebAuthn-based authentication with device biometrics
8
+ - **Blockchain Integration**: Complete transaction signing with PERS Signer SDK supporting EVM-compatible chains (Ethereum, Polygon, Camino, etc.)
9
+ - **Token Operations**: Balance checking, earning, redeeming, and transfers
10
+ - **Business Management**: Business profiles, campaigns, and operations
11
+ - **Campaign System**: Marketing campaigns, participation, and rewards
12
+ - **Redemption Engine**: Comprehensive reward redemption and fulfillment
13
+ - **Analytics**: Real-time analytics and transaction monitoring
14
+ - **Donations**: Charitable giving and donation management
15
+ - **User Management**: Profile management and user operations
16
+ - **Web3 Support**: EVM-compatible chain wallet operations and blockchain interactions
17
+ - **Secure Storage**: Hybrid storage strategy using **Keychain** (High Security) for secrets and **AsyncStorage** for public data.
18
+ - **DPoP Integration**: Native C++ bridge for high-performance ECDSA signing via `react-native-quick-crypto`.
19
+ - **React Native Optimized**: Full platform support with automatic polyfills.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @explorins/pers-sdk-react-native
25
+ ```
26
+
27
+ ### Required Peer Dependencies
28
+
29
+ You must install `async-storage` manually as it is a peer dependency:
30
+
31
+ ```bash
32
+ npm install @react-native-async-storage/async-storage
33
+ ```
34
+
35
+ ### Included Native Dependencies
36
+
37
+ The SDK automatically installs the following native libraries. **You must rebuild your native app** (e.g., `npx expo run:android` or `cd ios && pod install`) after installing the SDK to link these native modules:
38
+
39
+ - `react-native-quick-crypto` (High-performance Crypto)
40
+ - `react-native-keychain` (Secure Storage)
41
+ - `react-native-passkey` (WebAuthn/Passkeys)
42
+ - `react-native-get-random-values` (Crypto Primitives)
43
+
44
+ ### Optional Dependencies
45
+
46
+ ```bash
47
+ # For URL Polyfills (Recommended if not already included in your app)
48
+ npm install react-native-url-polyfill
49
+ ```
50
+
51
+ ### Metro Configuration (Required)
52
+
53
+ The PERS SDK uses subpath exports (`@explorins/pers-sdk/core`, etc.) which require Metro to be configured. Add to your `metro.config.js`:
54
+
55
+ ```javascript
56
+ const { getDefaultConfig } = require('expo/metro-config');
57
+ // or for bare RN: const { getDefaultConfig } = require('@react-native/metro-config');
58
+
59
+ const config = getDefaultConfig(__dirname);
60
+
61
+ // REQUIRED: Enable package exports for PERS SDK subpath imports
62
+ config.resolver.unstable_enablePackageExports = true;
63
+
64
+ module.exports = config;
65
+ ```
66
+
67
+ ---
68
+
69
+ ## ⚠️ Android Architecture Requirements
70
+
71
+ **The SDK requires React Native New Architecture** starting from v2.1.x.
72
+
73
+ ### Why?
74
+
75
+ `react-native-quick-crypto` v1.0+ (used for high-performance cryptographic operations) uses [Nitro Modules](https://github.com/margelo/nitro), which require New Architecture.
76
+
77
+ ### Quick Check
78
+
79
+ If you see errors like:
80
+ ```
81
+ NitroModulesProxy.createHybridObject(...) threw an unknown std::runtime_error
82
+ ```
83
+
84
+ Your app is running with **Old Architecture** (`newArchEnabled=false`).
85
+
86
+ ### Solution 1: Enable New Architecture (Recommended)
87
+
88
+ React Native 0.76+ has stable New Architecture support. Enable it in `android/gradle.properties`:
89
+
90
+ ```properties
91
+ # android/gradle.properties
92
+
93
+ # Use this property to enable support to the new architecture.
94
+ newArchEnabled=true
95
+
96
+ # Use this property to enable or disable the Hermes JS engine.
97
+ hermesEnabled=true
98
+ ```
99
+
100
+ Then clean and rebuild:
101
+
102
+ ```bash
103
+ cd android && ./gradlew clean && cd ..
104
+ npx react-native start --reset-cache
105
+ npx react-native run-android
106
+ ```
107
+
108
+ > **Reference**: See the [VQ Demo App's gradle.properties](https://github.com/explorins/vq-pers-integration-demo/blob/main/android/gradle.properties) for a working configuration with Expo SDK 54 and React Native 0.81.5.
109
+
110
+ ### Solution 2: Downgrade quick-crypto (For Old Architecture)
111
+
112
+ If you must stay on Old Architecture, pin to v0.7.x:
113
+
114
+ ```json
115
+ {
116
+ "resolutions": {
117
+ "react-native-quick-crypto": "0.7.17"
118
+ }
119
+ }
120
+ ```
121
+
122
+ Or for npm (in `package.json`):
123
+
124
+ ```json
125
+ {
126
+ "overrides": {
127
+ "react-native-quick-crypto": "0.7.17"
128
+ }
129
+ }
130
+ ```
131
+
132
+ ### Compatibility Matrix
133
+
134
+ | RN Version | Architecture | quick-crypto | Status |
135
+ |------------|--------------|--------------|--------|
136
+ | 0.76+ | New (`newArchEnabled=true`) | 1.0.6+ | ✅ Recommended |
137
+ | 0.76+ | Old (`newArchEnabled=false`) | 0.7.17 | ⚠️ Requires downgrade |
138
+ | < 0.76 | Old | 0.7.x | ⚠️ Use older quick-crypto |
139
+
140
+ > **See [Android Compatibility FAQ](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/ANDROID_COMPATIBILITY_FAQ.md)** for detailed troubleshooting.
141
+
142
+ ---
143
+
144
+ ## Critical Setup Requirement: Passkeys
145
+
146
+ To enable Passkey authentication (WebAuthn) on iOS and Android, you **must**:
147
+
148
+ 1. **Register your app with PERS backend** - Contact **support@explorins.com** with:
149
+ - iOS: Bundle ID + Apple Team ID
150
+ - Android: Package Name + SHA-256 fingerprint
151
+
152
+ 2. **Configure native files** - See [Passkey Setup Guide](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/REACT_NATIVE_PASSKEY_SETUP.md)
153
+
154
+ 3. **For Expo**: Use development builds (`npx expo run:android`), not Expo Go
155
+
156
+ ## Quick Start
157
+
158
+ ### 1. Setup the Provider
159
+
160
+ ```typescript
161
+ import React from 'react';
162
+ import { PersSDKProvider } from '@explorins/pers-sdk-react-native';
163
+
164
+ export default function App() {
165
+ return (
166
+ <PersSDKProvider config={{
167
+ apiUrl: 'https://api.pers.ninja',
168
+ tenantId: 'your-tenant-id' // Optional
169
+ }}>
170
+ <NavigationContainer>
171
+ <YourAppContent />
172
+ </NavigationContainer>
173
+ </PersSDKProvider>
174
+ );
175
+ }
176
+ ```
177
+
178
+ ### 2. Authentication & Token Operations
179
+
180
+ ```typescript
181
+ import {
182
+ useAuth,
183
+ useTokens,
184
+ useRedemptions,
185
+ useTransactionSigner
186
+ } from '@explorins/pers-sdk-react-native';
187
+
188
+ function RewardScreen() {
189
+ const { user, isAuthenticated, login } = useAuth();
190
+ const { getTokens } = useTokens();
191
+ const { redeem } = useRedemptions();
192
+ const { signAndSubmitTransactionWithJWT, isSignerAvailable } = useTransactionSigner();
193
+
194
+ const handleLogin = async () => {
195
+ try {
196
+ await login('your-jwt-token');
197
+ console.log('Authenticated:', user?.identifier);
198
+ } catch (error) {
199
+ console.error('Login failed:', error);
200
+ }
201
+ };
202
+
203
+ const handleLoadTokens = async () => {
204
+ try {
205
+ const tokens = await getTokens();
206
+ console.log('User tokens:', tokens);
207
+ } catch (error) {
208
+ console.error('Failed to load tokens:', error);
209
+ }
210
+ };
211
+
212
+ const handleRedemption = async () => {
213
+ try {
214
+ // Step 1: Create redemption
215
+ const redemptionId = 'redemption-id-from-ui';
216
+ const result = await redeem(redemptionId);
217
+
218
+ // Note: The redeem method automatically handles blockchain signing if required
219
+ // and if the signer is available.
220
+
221
+ if (result.isSigned) {
222
+ console.log('Redemption completed with blockchain signature!');
223
+ console.log('Transaction Hash:', result.transactionHash);
224
+ }
225
+ } catch (error) {
226
+ console.error('Redemption failed:', error);
227
+ }
228
+ };
229
+
230
+ return (
231
+ <ScrollView style={styles.container}>
232
+ {!isAuthenticated ? (
233
+ <TouchableOpacity onPress={handleLogin} style={styles.loginButton}>
234
+ <Text style={styles.buttonText}>Login with PERS</Text>
235
+ </TouchableOpacity>
236
+ ) : (
237
+ <View>
238
+ <Text style={styles.welcome}>Welcome, {user?.identifier}!</Text>
239
+
240
+ <TouchableOpacity onPress={handleLoadTokens} style={styles.button}>
241
+ <Text style={styles.buttonText}>Load Tokens</Text>
242
+ </TouchableOpacity>
243
+
244
+ <TouchableOpacity
245
+ onPress={handleRedemption}
246
+ disabled={!isSignerAvailable}
247
+ style={[styles.button, !isSignerAvailable && styles.disabled]}
248
+ >
249
+ <Text style={styles.buttonText}>
250
+ {isSignerAvailable ? 'Redeem Rewards' : 'Signer Loading...'}
251
+ </Text>
252
+ </TouchableOpacity>
253
+ </View>
254
+ )}
255
+ </ScrollView>
256
+ );
257
+ }
258
+ ```
259
+
260
+ ## Core Hooks
261
+
262
+ ### Authentication & Users
263
+
264
+ ```typescript
265
+ // Authentication management
266
+ const {
267
+ isInitialized,
268
+ isAuthenticated,
269
+ user,
270
+ login,
271
+ loginWithRawData,
272
+ logout,
273
+ refreshUserData,
274
+ getCurrentUser,
275
+ checkIsAuthenticated,
276
+ refreshTokens,
277
+ clearAuth,
278
+ hasValidAuth
279
+ } = useAuth();
280
+
281
+ // User profile operations
282
+ const {
283
+ getCurrentUser,
284
+ updateCurrentUser,
285
+ getUserById,
286
+ getAllUsersPublic,
287
+ getAllUsers, // Admin
288
+ updateUser, // Admin
289
+ toggleUserStatus // Admin
290
+ } = useUsers();
291
+ ```
292
+
293
+ ### Token & Financial Operations
294
+
295
+ ```typescript
296
+ // Token management
297
+ const {
298
+ getTokens,
299
+ getActiveCreditToken,
300
+ getRewardTokens,
301
+ getTokenTypes,
302
+ getStatusTokens,
303
+ getTokenByContract
304
+ } = useTokens();
305
+
306
+ // Token balance loading (NEW in v2.1.1)
307
+ const {
308
+ tokenBalances, // Array of balances with token metadata
309
+ isLoading, // Loading state
310
+ error, // Error state
311
+ refresh // Manual refresh function
312
+ } = useTokenBalances({
313
+ availableTokens, // From useTokens()
314
+ autoLoad: true, // Auto-load on mount
315
+ refreshInterval: 30000 // Optional: refresh every 30s
316
+ });
317
+
318
+ // Transaction history
319
+ const {
320
+ createTransaction,
321
+ getTransactionById,
322
+ getUserTransactionHistory, // With TransactionQueryOptions (includes pagination + filters + include)
323
+ getPaginatedTransactions, // Admin - single options object with include
324
+ exportTransactionsCSV, // Admin
325
+ signingStatus, // UI feedback during blockchain signing
326
+ signingStatusMessage
327
+ } = useTransactions();
328
+
329
+ // Blockchain transaction signing
330
+ const {
331
+ signAndSubmitTransactionWithJWT,
332
+ isSignerAvailable,
333
+ isSignerInitialized,
334
+ currentStatus,
335
+ statusMessage
336
+ } = useTransactionSigner();
337
+ ```
338
+
339
+ ### Business & Campaign Management
340
+
341
+ ```typescript
342
+ // Business operations
343
+ const {
344
+ getActiveBusinesses,
345
+ getBusinessTypes,
346
+ getBusinesses,
347
+ getBusinessById,
348
+ getBusinessByAccount,
349
+ getBusinessesByType,
350
+ createBusiness, // Admin
351
+ updateBusiness, // Admin
352
+ toggleBusinessStatus // Admin
353
+ } = useBusiness();
354
+
355
+ // Campaign management
356
+ const {
357
+ getActiveCampaigns,
358
+ getCampaigns, // With options: { active?, businessId?, sortBy?, include?, page?, limit? }
359
+ getCampaignById,
360
+ claimCampaign,
361
+ getUserClaims, // With options: { page?, limit?, include? }
362
+ getCampaignTriggers,
363
+ getAllCampaigns, // Admin
364
+ getCampaignClaims, // Admin - with options object
365
+ getCampaignClaimsByUserId, // Admin
366
+ getCampaignClaimsByBusinessId // Admin
367
+ } = useCampaigns();
368
+
369
+ // Redemption system
370
+ const {
371
+ getActiveRedemptions,
372
+ getUserRedemptions,
373
+ redeem,
374
+ getRedemptionTypes,
375
+ signingStatus, // UI feedback during blockchain signing
376
+ signingStatusMessage,
377
+ createRedemption, // Admin
378
+ getAllRedemptions, // Admin
379
+ updateRedemption, // Admin
380
+ toggleRedemptionStatus // Admin
381
+ } = useRedemptions();
382
+ ```
383
+
384
+ ### Platform & Analytics
385
+
386
+ ```typescript
387
+ // Purchase processing
388
+ const {
389
+ createPaymentIntent,
390
+ getActivePurchaseTokens,
391
+ getAllUserPurchases
392
+ } = usePurchases();
393
+
394
+ // Multi-tenant support
395
+ const {
396
+ getTenantInfo,
397
+ getClientConfig,
398
+ getLoginToken,
399
+ getAdmins
400
+ } = useTenants();
401
+
402
+ // Analytics & reporting
403
+ const {
404
+ getTransactionAnalytics
405
+ } = useAnalytics();
406
+
407
+ // Web3 & blockchain (wallet addresses from user.wallets)
408
+ const {
409
+ getTokenBalance,
410
+ getTokenMetadata,
411
+ getTokenCollection,
412
+ resolveIPFSUrl,
413
+ fetchAndProcessMetadata,
414
+ getChainDataById,
415
+ getExplorerUrl,
416
+ // Helper methods for token collections
417
+ extractTokenIds, // Extract tokenIds from TokenDTO metadata
418
+ getAccountOwnedTokensFromContract, // Recommended: Get owned tokens automatically
419
+ buildCollectionRequest // Build request for getTokenCollection
420
+ } = useWeb3();
421
+
422
+ // User status & achievements
423
+ const {
424
+ getUserStatusTypes,
425
+ getEarnedUserStatus,
426
+ createUserStatusType // Admin
427
+ } = useUserStatus();
428
+
429
+ // File management
430
+ const {
431
+ getSignedPutUrl,
432
+ getSignedGetUrl,
433
+ getSignedUrl,
434
+ optimizeMedia
435
+ } = useFiles();
436
+
437
+ // Donations
438
+ const {
439
+ getDonationTypes
440
+ } = useDonations();
441
+
442
+ // Event subscriptions (notifications, logging)
443
+ const {
444
+ subscribe, // Subscribe to SDK events
445
+ once, // One-time event listener
446
+ clear, // Clear all subscriptions
447
+ isAvailable, // Event system available
448
+ subscriberCount // Active subscriber count
449
+ } = useEvents();
450
+ ```
451
+
452
+ ## Localization
453
+
454
+ Campaign, redemption, business, token metadata and other content can be returned in the user's language. It is
455
+ opt-in: without a locale the API returns the base text, exactly as before.
456
+
457
+ ```tsx
458
+ // Device language, or a BCP 47 tag such as 'de' or 'pt-BR'; omit or pass null to keep base text
459
+ <PersSDKProvider config={{ apiProjectKey: 'my-project', locale: 'auto' }}>
460
+ <App />
461
+ </PersSDKProvider>
462
+
463
+ const { locale, setLocale } = usePersSDK();
464
+ setLocale('de'); // then refetch: keep app caches (e.g. React Query keys) per locale
465
+ ```
466
+
467
+ - Each field falls back per field: exact tag (`ar-SA`), then language (`ar`), then the base text. Fields without a
468
+ translation stay in the base language.
469
+ - Token metadata read with `useMetadata` is resolved on the device and re-resolves when the locale changes.
470
+ - Tokens minted before localization was enabled, and AI-generated per-mint text, stay in the base language.
471
+
472
+ ---
473
+
474
+ ## Event System
475
+
476
+ The `useEvents` hook provides access to SDK-wide events for showing notifications, logging, and reacting to SDK operations. All events include a `userMessage` field ready for UI display.
477
+
478
+ ### Basic Usage
479
+
480
+ ```typescript
481
+ import { useEvents } from '@explorins/pers-sdk-react-native';
482
+ import { useEffect } from 'react';
483
+
484
+ function NotificationHandler() {
485
+ const { subscribe, isAvailable } = useEvents();
486
+
487
+ useEffect(() => {
488
+ if (!isAvailable) return;
489
+
490
+ // Subscribe to all events
491
+ const unsubscribe = subscribe((event) => {
492
+ showNotification(event.userMessage, event.level);
493
+ });
494
+
495
+ return () => unsubscribe(); // Cleanup on unmount
496
+ }, [subscribe, isAvailable]);
497
+ }
498
+ ```
499
+
500
+ ### Filtering Events
501
+
502
+ ```typescript
503
+ // Only transaction successes
504
+ subscribe(
505
+ (event) => {
506
+ playSuccessSound();
507
+ showConfetti();
508
+ },
509
+ { domain: 'transaction', level: 'success' }
510
+ );
511
+
512
+ // Only errors (for logging)
513
+ subscribe(
514
+ (event) => {
515
+ logToSentry(event);
516
+ },
517
+ { level: 'error' }
518
+ );
519
+
520
+ // One-time event (auto-unsubscribes)
521
+ once(
522
+ (event) => {
523
+ console.log('First transaction completed!');
524
+ },
525
+ { domain: 'transaction', level: 'success' }
526
+ );
527
+ ```
528
+
529
+ ### Event Domains
530
+
531
+ | Domain | Events |
532
+ |--------|--------|
533
+ | `auth` | Login, logout, token refresh |
534
+ | `user` | Profile updates |
535
+ | `transaction` | Created, completed, failed |
536
+ | `campaign` | Claimed, activated |
537
+ | `redemption` | Redeemed, expired |
538
+ | `business` | Created, updated, membership |
539
+ | `api` | Network errors, validation errors |
540
+
541
+ ### Event Structure
542
+
543
+ ```typescript
544
+ interface PersEvent {
545
+ id: string; // Unique event ID
546
+ timestamp: number; // Unix timestamp (ms)
547
+ domain: string; // Event domain (transaction, auth, etc.)
548
+ type: string; // Event type within domain
549
+ level: 'success' | 'error';
550
+ userMessage: string; // Ready for UI display
551
+ action?: string; // Suggested action
552
+ details?: object; // Additional data
553
+ }
554
+ ```
555
+
556
+ ---
557
+
558
+ ## POS Transaction Flow
559
+
560
+ For Point-of-Sale scenarios where a **business submits a transaction on behalf of a user**, use the `buildPOSTransferRequest` helper:
561
+
562
+ ```typescript
563
+ import {
564
+ useTransactions,
565
+ buildPOSTransferRequest,
566
+ useEvents
567
+ } from '@explorins/pers-sdk-react-native';
568
+
569
+ function POSScreen() {
570
+ const { createTransaction, signingStatus } = useTransactions();
571
+ const { subscribe, isAvailable } = useEvents();
572
+
573
+ // Listen for transaction events
574
+ useEffect(() => {
575
+ if (!isAvailable) return;
576
+
577
+ const unsubscribe = subscribe(
578
+ (event) => {
579
+ if (event.level === 'success') {
580
+ Alert.alert('Success', event.userMessage);
581
+ }
582
+ },
583
+ { domain: 'transaction' }
584
+ );
585
+
586
+ return () => unsubscribe();
587
+ }, [subscribe, isAvailable]);
588
+
589
+ const handlePOSTransaction = async (
590
+ userId: string,
591
+ businessId: string,
592
+ amount: number,
593
+ token: TokenDTO
594
+ ) => {
595
+ // Build POS transfer request
596
+ const request = buildPOSTransferRequest({
597
+ amount,
598
+ contractAddress: token.contractAddress,
599
+ chainId: token.chainId,
600
+ userId, // User sending tokens
601
+ businessId // Business receiving & authorized to submit
602
+ });
603
+
604
+ // Create and sign transaction
605
+ const result = await createTransaction(request, (status, message) => {
606
+ console.log(`Signing: ${status} - ${message}`);
607
+ });
608
+
609
+ console.log('Transaction created:', result.transaction?.id);
610
+ };
611
+ }
612
+ ```
613
+
614
+ ### POS Authorization Fields
615
+
616
+ The `buildPOSTransferRequest` helper automatically sets:
617
+
618
+ | Field | Value | Purpose |
619
+ |-------|-------|---------|
620
+ | `engagedBusinessId` | Business ID | Business commercially involved (for reporting) |
621
+ | `authorizedSubmitterId` | Business ID | Entity authorized to submit the signed tx |
622
+ | `authorizedSubmitterType` | `BUSINESS` | Type of authorized submitter |
623
+
624
+ For custom scenarios, use `buildTransferRequest` with manual POS fields:
625
+
626
+ ```typescript
627
+ import { buildTransferRequest, AccountOwnerType } from '@explorins/pers-sdk-react-native';
628
+
629
+ const request = buildTransferRequest({
630
+ amount: 100,
631
+ contractAddress: '0x...',
632
+ chainId: 137,
633
+ senderAccountId: 'user-123',
634
+ senderAccountType: AccountOwnerType.USER,
635
+ recipientAccountId: 'business-456',
636
+ recipientAccountType: AccountOwnerType.BUSINESS,
637
+ // POS authorization
638
+ engagedBusinessId: 'business-456',
639
+ authorizedSubmitterId: 'business-456',
640
+ authorizedSubmitterType: AccountOwnerType.BUSINESS
641
+ });
642
+ ```
643
+
644
+ ---
645
+
646
+ ## Token Collection Helper Methods
647
+
648
+ The `useWeb3` hook includes helper methods for querying token balances from any blockchain address. These work with **all token standards** (ERC-20, ERC-721, ERC-1155).
649
+
650
+ ### Recommended: getAccountOwnedTokensFromContract
651
+
652
+ A unified API to get all tokens owned by any blockchain address:
653
+
654
+ ```typescript
655
+ import { useWeb3, useTokens, usePersSDK } from '@explorins/pers-sdk-react-native';
656
+
657
+ function RewardTokensScreen() {
658
+ const { user } = usePersSDK();
659
+ const { getRewardTokens } = useTokens();
660
+ const { getAccountOwnedTokensFromContract } = useWeb3();
661
+
662
+ const loadUserRewards = async () => {
663
+ const walletAddress = user?.wallets?.[0]?.address;
664
+ if (!walletAddress) return;
665
+
666
+ const rewardTokens = await getRewardTokens();
667
+
668
+ for (const token of rewardTokens) {
669
+ // Works with ERC-20, ERC-721, and ERC-1155 automatically
670
+ const result = await getAccountOwnedTokensFromContract(walletAddress, token);
671
+
672
+ console.log(`Token: ${token.symbol}`);
673
+ console.log(`Owned: ${result.totalOwned}`);
674
+
675
+ result.ownedTokens.forEach(owned => {
676
+ console.log(` - ${owned.metadata?.name}: ${owned.balance}`);
677
+ });
678
+ }
679
+ };
680
+
681
+ return (
682
+ <TouchableOpacity onPress={loadUserRewards}>
683
+ <Text>Load My Rewards</Text>
684
+ </TouchableOpacity>
685
+ );
686
+ }
687
+ ```
688
+
689
+ ### How It Handles Each Token Standard
690
+
691
+ | Token Type | What It Does |
692
+ |------------|--------------|
693
+ | **ERC-20** | Returns balance for fungible tokens |
694
+ | **ERC-721** | Enumerates all owned NFTs |
695
+ | **ERC-1155** | Extracts tokenIds from metadata, queries balances |
696
+
697
+ The helper abstracts away the complexity - especially for ERC-1155 which requires specific `tokenIds` that the helper extracts from `token.metadata[].tokenMetadataIncrementalId`.
698
+
699
+ ### Return Type
700
+
701
+ ```typescript
702
+ interface AccountOwnedTokensResult {
703
+ token: TokenDTO; // The token definition
704
+ ownedTokens: TokenBalance[]; // Tokens with balance > 0
705
+ totalOwned: number; // Count of owned tokens
706
+ }
707
+ ```
708
+
709
+ ## EVM Blockchain Transaction Signing
710
+
711
+ The `useTransactionSigner` hook provides secure EVM blockchain transaction signing:
712
+
713
+ ```typescript
714
+ import { useTransactionSigner } from '@explorins/pers-sdk-react-native';
715
+
716
+ function BlockchainRedemption() {
717
+ const {
718
+ signAndSubmitTransactionWithJWT,
719
+ isSignerAvailable,
720
+ isSignerInitialized
721
+ } = useTransactionSigner();
722
+
723
+ const handleBlockchainRedemption = async (jwtToken: string) => {
724
+ if (!isSignerAvailable) {
725
+ console.error('EVM blockchain signer not available');
726
+ return;
727
+ }
728
+
729
+ try {
730
+ // This handles the complete flow:
731
+ // 1. User authentication via WebAuthn
732
+ // 2. Transaction data fetching from PERS backend
733
+ // 3. Secure EVM transaction signing using device biometrics
734
+ // 4. EVM blockchain submission
735
+ const result = await signAndSubmitTransactionWithJWT(jwtToken);
736
+
737
+ if (result.success) {
738
+ console.log('Transaction completed!');
739
+ console.log('Hash:', result.transactionHash);
740
+ console.log('View on blockchain explorer: [Chain-specific URL]');
741
+
742
+ // Handle post-transaction flow
743
+ if (result.shouldRedirect && result.redirectUrl) {
744
+ // Navigate to success page or external URL
745
+ Linking.openURL(result.redirectUrl);
746
+ }
747
+ }
748
+ } catch (error) {
749
+ if (error.message.includes('expired')) {
750
+ // JWT token expired - redirect to login
751
+ navigation.navigate('Login');
752
+ } else if (error.message.includes('cancelled')) {
753
+ // User cancelled WebAuthn authentication
754
+ console.log('User cancelled transaction');
755
+ } else {
756
+ // Other errors
757
+ Alert.alert('Transaction Failed', error.message);
758
+ }
759
+ }
760
+ };
761
+
762
+ // Show loading state while signer initializes
763
+ if (!isSignerInitialized) {
764
+ return (
765
+ <View style={styles.loadingContainer}>
766
+ <ActivityIndicator size="large" />
767
+ <Text>Initializing EVM blockchain signer...</Text>
768
+ </View>
769
+ );
770
+ }
771
+
772
+ return (
773
+ <TouchableOpacity
774
+ onPress={() => handleBlockchainRedemption(redemptionJWT)}
775
+ disabled={!isSignerAvailable}
776
+ style={[styles.button, !isSignerAvailable && styles.disabled]}
777
+ >
778
+ <Text style={styles.buttonText}>
779
+ Sign & Submit Transaction
780
+ </Text>
781
+ </TouchableOpacity>
782
+ );
783
+ }
784
+ ```
785
+ ### Error Handling
786
+
787
+ The SDK provides comprehensive structured error handling with utilities for consistent error management:
788
+
789
+ ```typescript
790
+ import {
791
+ PersApiError,
792
+ ErrorUtils
793
+ } from '@explorins/pers-sdk-react-native';
794
+ import { Alert } from 'react-native';
795
+
796
+ // Structured error handling
797
+ try {
798
+ await sdk.campaigns.claimCampaign({ campaignId });
799
+ } catch (error) {
800
+ if (error instanceof PersApiError) {
801
+ // Structured error with backend details
802
+ console.log('Error code:', error.code); // 'CAMPAIGN_BUSINESS_REQUIRED'
803
+ console.log('Status:', error.status); // 400
804
+ console.log('User message:', error.userMessage); // User-friendly message
805
+
806
+ // Show user-friendly error
807
+ Alert.alert('Error', error.userMessage || error.message);
808
+
809
+ // Check if retryable
810
+ if (error.retryable) {
811
+ Alert.alert('Error', error.message, [
812
+ { text: 'Cancel' },
813
+ { text: 'Retry', onPress: () => retry() }
814
+ ]);
815
+ }
816
+ } else {
817
+ // Generic error fallback with ErrorUtils
818
+ const message = ErrorUtils.getMessage(error);
819
+ Alert.alert('Error', message);
820
+ }
821
+ }
822
+
823
+ // Error utilities for any error type
824
+ const status = ErrorUtils.getStatus(error); // Extract HTTP status
825
+ const message = ErrorUtils.getMessage(error); // Extract user message
826
+ const retryable = ErrorUtils.isRetryable(error); // Check if retryable
827
+ const tokenExpired = ErrorUtils.isTokenExpiredError(error); // Detect token expiration
828
+ ```
829
+
830
+ ### Transaction Helpers
831
+
832
+ **NEW in v2.1.1:** Factory functions for client-side transaction workflows:
833
+
834
+ ```typescript
835
+ import {
836
+ ClientTransactionType,
837
+ buildPendingTransactionData,
838
+ extractDeadlineFromSigningData,
839
+ type PendingTransactionParams
840
+ } from '@explorins/pers-sdk-react-native';
841
+
842
+ // Build pending transaction data for QR codes, NFC, deep links, etc.
843
+ const qrData = buildPendingTransactionData(
844
+ signingResult.transactionId,
845
+ signingResult.signature,
846
+ 'EIP-712' // Default format
847
+ );
848
+
849
+ console.log(qrData);
850
+ // {
851
+ // transactionId: '...',
852
+ // signature: '0x...',
853
+ // transactionFormat: 'EIP-712',
854
+ // txType: 'PENDING_SUBMISSION'
855
+ // }
856
+
857
+ // Serialize for transfer (QR code, NFC, etc.)
858
+ const qrCodeValue = JSON.stringify(qrData);
859
+
860
+ // Extract deadline from EIP-712 signing data
861
+ const deadline = extractDeadlineFromSigningData(signingResult.signingData);
862
+ if (deadline) {
863
+ const expiryTime = new Date(deadline * 1000);
864
+ console.log('Transaction expires at:', expiryTime);
865
+ }
866
+
867
+ // Client transaction types
868
+ if (transaction.txType === ClientTransactionType.PENDING_SUBMISSION) {
869
+ // Handle pending submission flow
870
+ }
871
+ ```
872
+ ## Security Features
873
+
874
+ ### WebAuthn Authentication
875
+ - Device biometric authentication (fingerprint, face ID, PIN)
876
+ - No passwords or private keys stored locally
877
+ - Secure hardware-backed authentication
878
+
879
+ ### Token Storage
880
+ - React Native Keychain integration for sensitive data
881
+ - Automatic token refresh and expiration handling
882
+ - Multiple storage strategies (AsyncStorage, Keychain, Memory)
883
+
884
+ ### Transaction Security
885
+ - JWT token validation and expiration checking
886
+ - Secure transaction signing without exposing private keys
887
+ - Automatic session management with secure caching
888
+
889
+ ## Platform Support
890
+
891
+ - **iOS**: Full support with Keychain integration
892
+ - **Android**: Full support with Keystore integration
893
+ - **Expo**: Compatible with Expo managed workflow
894
+ - **Web**: React Native Web compatibility
895
+
896
+ ---
897
+
898
+ ## Analytics & Attribution Tracking
899
+
900
+ The SDK provides built-in analytics tracking for user acquisition and engagement. The `PersSDKProvider` **automatically detects the platform** (iOS/Android) and sends tracking headers with all API requests.
901
+
902
+ ### Automatic Platform Detection
903
+
904
+ When you wrap your app with `PersSDKProvider`, platform info is auto-detected:
905
+
906
+ ```typescript
907
+ // Platform is automatically detected and set:
908
+ // { os: 'iOS', osVersion: '17.4', deviceType: 'phone', app: 'MyApp' }
909
+ ```
910
+
911
+ ### Manual Data Source Tracking
912
+
913
+ For campaign attribution, use `usePersSDK()` to set custom tracking data:
914
+
915
+ ```typescript
916
+ import { usePersSDK } from '@explorins/pers-sdk-react-native';
917
+ import { Linking } from 'react-native';
918
+
919
+ function DeepLinkHandler() {
920
+ const { sdk } = usePersSDK();
921
+
922
+ useEffect(() => {
923
+ // Handle deep links with UTM params
924
+ Linking.getInitialURL().then(url => {
925
+ if (url) {
926
+ const params = new URL(url).searchParams;
927
+
928
+ sdk.setDataSource({
929
+ channel: 'mobile',
930
+ medium: params.get('utm_medium') || undefined,
931
+ campaign: params.get('utm_campaign') || undefined,
932
+ source: params.get('utm_source') || undefined,
933
+ });
934
+ }
935
+ });
936
+ }, [sdk]);
937
+ }
938
+ ```
939
+
940
+ ### HTTP Headers Sent
941
+
942
+ The SDK automatically sends tracking data as HTTP headers:
943
+
944
+ | Header | Source | Description |
945
+ |--------|--------|-------------|
946
+ | `x-source-channel` | `dataSource.channel` | `'mobile'` for native apps |
947
+ | `x-source-medium` | `dataSource.medium` | Traffic medium (push, email, etc.) |
948
+ | `x-source-campaign` | `dataSource.campaign` | Campaign identifier |
949
+ | `x-source` | `dataSource.source` | Traffic source |
950
+ | `x-platform` | Auto-detected | JSON: `{ os, osVersion, deviceType, app }` |
951
+
952
+ ### Push Notification Attribution
953
+
954
+ Track users coming from push notifications:
955
+
956
+ ```typescript
957
+ import { usePersSDK } from '@explorins/pers-sdk-react-native';
958
+
959
+ function usePushNotificationTracking() {
960
+ const { sdk } = usePersSDK();
961
+
962
+ const handlePushNotification = (notification: any) => {
963
+ // Extract campaign from notification data
964
+ const campaignId = notification.data?.campaignId;
965
+
966
+ if (campaignId) {
967
+ sdk.updateDataSource({
968
+ medium: 'push',
969
+ campaign: campaignId,
970
+ });
971
+ }
972
+ };
973
+
974
+ return { handlePushNotification };
975
+ }
976
+ ```
977
+
978
+ ---
979
+
980
+ ## Advanced Configuration
981
+
982
+ ### SDK Initialization & DPoP
983
+
984
+ The `PersSDKProvider` accepts a `config` object allowing you to customize behavior, including **DPoP (Distributed Proof of Possession)**.
985
+
986
+ **DPoP Behavior:**
987
+ - **Enabled by Default**: Automatically active on iOS/Android using a high-performance C++ crypto bridge (`react-native-quick-crypto`).
988
+ - **Web Support**: Uses standard WebCrypto API.
989
+ - **Security**: Binds access tokens to the device, preventing replay attacks.
990
+
991
+ **Customizing Initialization:**
992
+ ```typescript
993
+ <PersSDKProvider config={{
994
+ apiProjectKey: 'your-project-key',
995
+ // DPoP is enabled by default. To disable (not recommended):
996
+ dpop: {
997
+ enabled: false
998
+ }
999
+ }}>
1000
+ <YourApp />
1001
+ </PersSDKProvider>
1002
+ ```
1003
+
1004
+ ### Storage Fallback Strategy
1005
+
1006
+ The SDK implements a robust **Hybrid Storage Strategy**:
1007
+ 1. **Primary**: Attempts to use **Android Keystore / iOS Keychain** (via `react-native-keychain`) for maximum security.
1008
+ 2. **Fallback**: If hardware storage is unavailable or fails, it automatically falls back to **AsyncStorage**.
1009
+ 3. **Web**: Uses `localStorage` automatically.
1010
+
1011
+ This ensures your app works reliably across all devices while prioritizing security where available.
1012
+
1013
+ ### Custom Authentication Provider
1014
+
1015
+ The SDK automatically uses `ReactNativeSecureStorage` for React Native (iOS/Android) and `LocalStorageTokenStorage` for Web. You can customize this if needed:
1016
+
1017
+ ```typescript
1018
+ import { createReactNativeAuthProvider, ReactNativeSecureStorage } from '@explorins/pers-sdk-react-native';
1019
+
1020
+ const authProvider = createReactNativeAuthProvider('your-project-key', {
1021
+ keyPrefix: 'my_app_tokens_',
1022
+ debug: true,
1023
+ // Optional: Provide custom storage implementation
1024
+ // customStorage: new ReactNativeSecureStorage('custom_prefix_')
1025
+ });
1026
+ ```
1027
+
1028
+ ### Error Handling Patterns
1029
+
1030
+ ```typescript
1031
+ import { useTokens } from '@explorins/pers-sdk-react-native';
1032
+
1033
+ function ErrorHandlingExample() {
1034
+ const { getTokens } = useTokens();
1035
+ const [error, setError] = useState<string | null>(null);
1036
+
1037
+ const handleLoadTokensWithErrorHandling = async () => {
1038
+ try {
1039
+ setError(null);
1040
+ await getTokens();
1041
+ } catch (err) {
1042
+ // Handle specific error types
1043
+ if (err.code === 'NETWORK_ERROR') {
1044
+ setError('Network connection required');
1045
+ } else if (err.code === 'INVALID_TOKEN') {
1046
+ setError('Please log in again');
1047
+ } else {
1048
+ setError(err.message || 'An unexpected error occurred');
1049
+ }
1050
+ }
1051
+ };
1052
+ }
1053
+ ```
1054
+
1055
+ ## Analytics Integration
1056
+
1057
+ ```typescript
1058
+ import { useAnalytics } from '@explorins/pers-sdk-react-native';
1059
+
1060
+ function AnalyticsExample() {
1061
+ const { getTransactionAnalytics } = useAnalytics();
1062
+
1063
+ const loadAnalytics = async () => {
1064
+ try {
1065
+ const txAnalytics = await getTransactionAnalytics({
1066
+ groupBy: ['day'],
1067
+ metrics: ['count', 'sum'],
1068
+ startDate: '2023-01-01',
1069
+ endDate: '2023-12-31'
1070
+ });
1071
+
1072
+ console.log('Transaction analytics:', txAnalytics.results);
1073
+ console.log('Execution time:', txAnalytics.metadata?.executionTime);
1074
+ } catch (error) {
1075
+ console.error('Failed to load analytics:', error);
1076
+ }
1077
+ };
1078
+ }
1079
+ ```
1080
+
1081
+ ## Migration Guide
1082
+
1083
+ ### v2.0.0 Breaking Changes - Pagination
1084
+
1085
+ **Version 2.0.0 introduces standardized pagination** across all hooks that return lists. Previously, hooks returned raw arrays. Now they return `PaginatedResponseDTO<T>` with pagination metadata.
1086
+
1087
+ #### What Changed
1088
+
1089
+ All hooks returning lists now return paginated responses:
1090
+
1091
+ ```typescript
1092
+ // ❌ OLD (v1.x) - Direct array
1093
+ const businesses: BusinessDTO[] = await getActiveBusinesses();
1094
+
1095
+ // ✅ NEW (v2.x) - Paginated response
1096
+ const response: PaginatedResponseDTO<BusinessDTO> = await getActiveBusinesses();
1097
+ const businesses: BusinessDTO[] = response.data;
1098
+ ```
1099
+
1100
+ #### Affected Hooks
1101
+
1102
+ | Hook | Method | Return Type |
1103
+ |------|--------|-------------|
1104
+ | `useBusiness` | `getActiveBusinesses()` | `PaginatedResponseDTO<BusinessDTO>` |
1105
+ | `useBusiness` | `getBusinessTypes()` | `PaginatedResponseDTO<BusinessTypeDTO>` |
1106
+ | `useCampaigns` | `getActiveCampaigns()` | `PaginatedResponseDTO<CampaignDTO>` |
1107
+ | `useCampaigns` | `getUserClaims()` | `PaginatedResponseDTO<CampaignClaimDTO>` |
1108
+ | `useTokens` | `getTokens()` | `PaginatedResponseDTO<TokenDTO>` |
1109
+ | `useTokens` | `getRewardTokens()` | `PaginatedResponseDTO<TokenDTO>` |
1110
+ | `useTokens` | `getStatusTokens()` | `PaginatedResponseDTO<TokenDTO>` |
1111
+ | `useRedemptions` | `getActiveRedemptions()` | `PaginatedResponseDTO<RedemptionDTO>` |
1112
+ | `useRedemptions` | `getUserRedemptions()` | `PaginatedResponseDTO<RedemptionDTO>` |
1113
+ | `useTransactions` | `getUserTransactionHistory()` | `PaginatedResponseDTO<TransactionDTO>` |
1114
+ | `usePurchases` | `getAllUserPurchases()` | `PaginatedResponseDTO<PurchaseDTO>` |
1115
+ | `usePurchases` | `getActivePurchaseTokens()` | `PaginatedResponseDTO<PurchaseTokenDTO>` |
1116
+ | `useDonations` | `getDonationTypes()` | `PaginatedResponseDTO<DonationTypeDTO>` |
1117
+
1118
+ #### PaginatedResponseDTO Structure
1119
+
1120
+ ```typescript
1121
+ import type { PaginatedResponseDTO } from '@explorins/pers-shared';
1122
+
1123
+ interface PaginatedResponseDTO<T> {
1124
+ data: T[]; // Array of results
1125
+ pagination: {
1126
+ currentPage: number; // Current page number (1-indexed)
1127
+ pageSize: number; // Items per page
1128
+ totalItems: number; // Total number of items across all pages
1129
+ totalPages: number; // Total number of pages
1130
+ };
1131
+ }
1132
+ ```
1133
+
1134
+ #### Migration Examples
1135
+
1136
+ **Before (v1.x):**
1137
+ ```typescript
1138
+ const businesses = await getActiveBusinesses();
1139
+ console.log('Business count:', businesses.length);
1140
+ businesses.forEach(b => console.log(b.name));
1141
+ ```
1142
+
1143
+ **After (v2.x):**
1144
+ ```typescript
1145
+ const response = await getActiveBusinesses();
1146
+ console.log('Business count:', response.data.length);
1147
+ console.log('Total businesses:', response.pagination.totalItems);
1148
+ response.data.forEach(b => console.log(b.name));
1149
+ ```
1150
+
1151
+ **With Pagination Parameters:**
1152
+ ```typescript
1153
+ // Fetch page 2 with 20 items per page
1154
+ const response = await getActiveBusinesses({ page: 2, pageSize: 20 });
1155
+
1156
+ console.log(`Page ${response.pagination.currentPage} of ${response.pagination.totalPages}`);
1157
+ console.log(`Showing ${response.data.length} businesses`);
1158
+ ```
1159
+
1160
+ #### React Native Component Example
1161
+
1162
+ ```typescript
1163
+ import { useState, useEffect } from 'react';
1164
+ import { useBusiness } from '@explorins/pers-sdk-react-native';
1165
+ import { View, Text, FlatList, ActivityIndicator } from 'react-native';
1166
+
1167
+ function BusinessListScreen() {
1168
+ const { getActiveBusinesses } = useBusiness();
1169
+ const [businesses, setBusinesses] = useState<BusinessDTO[]>([]);
1170
+ const [pagination, setPagination] = useState(null);
1171
+ const [loading, setLoading] = useState(true);
1172
+
1173
+ useEffect(() => {
1174
+ loadBusinesses();
1175
+ }, []);
1176
+
1177
+ const loadBusinesses = async (page = 1) => {
1178
+ try {
1179
+ setLoading(true);
1180
+ const response = await getActiveBusinesses({ page, pageSize: 20 });
1181
+ setBusinesses(response.data);
1182
+ setPagination(response.pagination);
1183
+ } catch (error) {
1184
+ console.error('Failed to load businesses:', error);
1185
+ } finally {
1186
+ setLoading(false);
1187
+ }
1188
+ };
1189
+
1190
+ if (loading) return <ActivityIndicator />;
1191
+
1192
+ return (
1193
+ <View>
1194
+ <Text>
1195
+ Showing {businesses.length} of {pagination?.totalItems} businesses
1196
+ </Text>
1197
+ <FlatList
1198
+ data={businesses}
1199
+ keyExtractor={(item) => item.id}
1200
+ renderItem={({ item }) => <BusinessCard business={item} />}
1201
+ />
1202
+ <Text>Page {pagination?.currentPage} of {pagination?.totalPages}</Text>
1203
+ </View>
1204
+ );
1205
+ }
1206
+ ```
1207
+
1208
+ #### Quick Fix for Existing Code
1209
+
1210
+ If you want minimal code changes, extract `.data` immediately:
1211
+
1212
+ ```typescript
1213
+ // Quick adaptation in your hooks
1214
+ const businesses = (await getActiveBusinesses()).data;
1215
+ const campaigns = (await getActiveCampaigns()).data;
1216
+ const tokens = (await getTokens()).data;
1217
+ ```
1218
+
1219
+ #### Benefits of Pagination
1220
+
1221
+ - **Performance**: Load only what you need, not entire datasets
1222
+ - **Consistency**: All list endpoints follow the same pattern
1223
+ - **Metadata**: Access total counts without loading all items
1224
+ - **Better UX**: Build proper pagination UI components
1225
+ - **Memory Efficiency**: Reduced memory footprint for large datasets
1226
+
1227
+ ---
1228
+
1229
+ ## Documentation
1230
+
1231
+ All documentation is available on GitHub:
1232
+
1233
+ | Guide | Description |
1234
+ |-------|-------------|
1235
+ | [Quick Setup Guide](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/QUICK_SETUP_GUIDE.md) | Android + Passkey setup in one page |
1236
+ | [**Troubleshooting**](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/TROUBLESHOOTING.md) | **Common issues & solutions** |
1237
+ | [Passkey Setup](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/REACT_NATIVE_PASSKEY_SETUP.md) | WebAuthn/Passkey configuration |
1238
+ | [Android Compatibility](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/ANDROID_COMPATIBILITY_FAQ.md) | New Architecture troubleshooting |
1239
+ | [Auth State Handling](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/docs/AUTH_STATE_HANDLING.md) | Session management patterns |
1240
+ | [Changelog](https://github.com/eXplorins/pers-sdk/blob/main/packages/pers-sdk-react-native/CHANGELOG.md) | Version history |
1241
+
1242
+ ### Quick Reference: Common Issues
1243
+
1244
+ - **User is null after restart?** → Session restoration is async. [See solution](./docs/TROUBLESHOOTING.md#1-sdkuser-is-null-after-app-restart)
1245
+ - **Stamp transaction fails?** → Use `tokenBalance.tokenId`, not metadata ID. [See solution](./docs/TROUBLESHOOTING.md#3-stamp-erc721-voucher-transaction-fails)
1246
+ - **Which wallet to use?** → Match by `token.chainId`, never use `wallets[0]`. [See solution](./docs/TROUBLESHOOTING.md#6-which-wallet-to-use-multiple-wallets)
1247
+ - **Passkey "NotConfigured"?** → Device needs Google Play Services. [See solution](./docs/TROUBLESHOOTING.md#2-passkey-notconfigured-error-on-android)
1248
+
1249
+ ---
1250
+
1251
+ ## Contributing
1252
+
1253
+ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
1254
+
1255
+ ## License
1256
+
1257
+ MIT License - see [LICENSE](LICENSE) file for details.
1258
+
1259
+ ## Support
1260
+
1261
+ - Email: support@explorins.com
1262
+ - Documentation: [https://docs.pers.ninja](https://docs.pers.ninja)
1263
+ - Issues: [GitHub Issues](https://github.com/eXplorins/pers-sdk/issues)
1264
+
1265
+ ---
1266
+
1245
1267
  Built with care by [eXplorins](https://explorins.com) for the tourism and loyalty industry.