@explorins/pers-sdk-react-native 2.3.7 → 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 (58) 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/useWeb3.js +4 -4
  11. package/dist/index.js +2400 -487
  12. package/dist/index.js.map +1 -1
  13. package/dist/providers/PersSDKProvider.d.ts +4 -0
  14. package/dist/providers/PersSDKProvider.d.ts.map +1 -1
  15. package/dist/providers/PersSDKProvider.js +14 -0
  16. package/dist/providers/react-native-http-client.d.ts +2 -0
  17. package/dist/providers/react-native-http-client.d.ts.map +1 -1
  18. package/dist/providers/react-native-http-client.js +5 -2
  19. package/dist/storage/rn-secure-storage.d.ts.map +1 -1
  20. package/dist/storage/rn-secure-storage.js +21 -8
  21. package/package.json +3 -3
  22. package/src/__tests__/PersSDKProvider.test.tsx +484 -420
  23. package/src/__tests__/setup.ts +40 -40
  24. package/src/hooks/index.ts +60 -60
  25. package/src/hooks/useAnalytics.ts +327 -327
  26. package/src/hooks/useAuth.ts +265 -265
  27. package/src/hooks/useBookings.ts +191 -191
  28. package/src/hooks/useBusiness.ts +251 -251
  29. package/src/hooks/useCampaigns.ts +500 -500
  30. package/src/hooks/useDonations.ts +66 -66
  31. package/src/hooks/useEvents.ts +372 -372
  32. package/src/hooks/useFiles.ts +155 -155
  33. package/src/hooks/useIPFSUrl.ts +192 -192
  34. package/src/hooks/usePurchases.ts +65 -65
  35. package/src/hooks/useRedemptions.ts +467 -467
  36. package/src/hooks/useTenants.ts +72 -72
  37. package/src/hooks/useTokenBalances.ts +385 -385
  38. package/src/hooks/useTokens.ts +313 -313
  39. package/src/hooks/useTransactionSigner.ts +611 -611
  40. package/src/hooks/useTransactions.ts +299 -299
  41. package/src/hooks/useTriggerSources.ts +301 -301
  42. package/src/hooks/useUserStatus.ts +61 -61
  43. package/src/hooks/useUsers.ts +167 -167
  44. package/src/hooks/useWeb3.ts +355 -355
  45. package/src/index.ts +777 -777
  46. package/src/polyfills/index.ts +160 -160
  47. package/src/polyfills/web3-polyfills.ts +97 -97
  48. package/src/providers/PersSDKProvider.tsx +393 -374
  49. package/src/providers/react-native-auth-provider.ts +110 -110
  50. package/src/providers/react-native-http-client.ts +150 -145
  51. package/src/providers/rn-dpop-provider.ts +190 -190
  52. package/src/storage/async-storage-token-storage.ts +185 -185
  53. package/src/storage/rn-secure-storage.ts +182 -170
  54. package/src/types/react-native-globals.d.ts +45 -45
  55. package/src/utils/index.ts +8 -8
  56. package/src/utils/platform-detection.ts +86 -86
  57. package/dist/index-CzLxqePZ.js +0 -7717
  58. package/dist/index-CzLxqePZ.js.map +0 -1
package/dist/index.js CHANGED
@@ -2607,8 +2607,15 @@ exports.TransactionTriggerProcessType = void 0;
2607
2607
  TransactionTriggerProcessType["CAMPAIGN_BUSINESS_CLAIM"] = "CAMPAIGN_BUSINESS_CLAIM";
2608
2608
  TransactionTriggerProcessType["REDEMPTION_SPEND"] = "REDEMPTION_SPEND";
2609
2609
  TransactionTriggerProcessType["REDEMPTION_RECEIVE"] = "REDEMPTION_RECEIVE";
2610
+ /**
2611
+ * Price token transfer during redemption (ERC20/ERC721/ERC1155 payment).
2612
+ * Does NOT trigger reward minting — the redemption lifecycle is driven by
2613
+ * REDEMPTION_SPEND (credit) or direct minting for free redemptions.
2614
+ */
2615
+ TransactionTriggerProcessType["REDEMPTION_PRICE_TOKEN_TRANSFER"] = "REDEMPTION_PRICE_TOKEN_TRANSFER";
2610
2616
  TransactionTriggerProcessType["MIGRATION"] = "MIGRATION";
2611
2617
  TransactionTriggerProcessType["ADMIN_TRIGGERED"] = "ADMIN_TRIGGERED";
2618
+ TransactionTriggerProcessType["BUSINESS_TRIGGERED"] = "BUSINESS_TRIGGERED";
2612
2619
  })(exports.TransactionTriggerProcessType || (exports.TransactionTriggerProcessType = {}));
2613
2620
 
2614
2621
  exports.ApiKeyType = void 0;
@@ -2620,6 +2627,7 @@ exports.ApiKeyType = void 0;
2620
2627
  ApiKeyType["USER_JWT_REFRESH_TOKEN"] = "USER_JWT_REFRESH_TOKEN";
2621
2628
  ApiKeyType["BUSINESS_JWT_ACCESS_TOKEN"] = "BUSINESS_JWT_ACCESS_TOKEN";
2622
2629
  ApiKeyType["BUSINESS_JWT_REFRESH_TOKEN"] = "BUSINESS_JWT_REFRESH_TOKEN";
2630
+ ApiKeyType["BUSINESS_SYSTEM_JWT"] = "BUSINESS_SYSTEM_JWT";
2623
2631
  ApiKeyType["BLOCKCHAIN_WRITER_JWT"] = "BLOCKCHAIN_WRITER_JWT";
2624
2632
  ApiKeyType["BLOCKCHAIN_READER_JWT"] = "BLOCKCHAIN_READER_JWT";
2625
2633
  ApiKeyType["TRANSACTION_JWT_ACCESS_TOKEN"] = "TRANSACTION_JWT_ACCESS_TOKEN";
@@ -2701,6 +2709,11 @@ exports.Web3ContractTypes = void 0;
2701
2709
  Web3ContractTypes["COUNTERFACTUAL_WALLET_PROXY"] = "COUNTERFACTUAL_WALLET_PROXY";
2702
2710
  // Token Contracts
2703
2711
  Web3ContractTypes["TOKEN_CONTRACT"] = "TOKEN_CONTRACT";
2712
+ // Token Deployment Infrastructure
2713
+ Web3ContractTypes["TOKEN_IMPLEMENTATION"] = "TOKEN_IMPLEMENTATION";
2714
+ Web3ContractTypes["TOKEN_PROXY_FACTORY"] = "TOKEN_PROXY_FACTORY";
2715
+ // Notification Infrastructure
2716
+ Web3ContractTypes["NOTIFICATION_HUB"] = "NOTIFICATION_HUB";
2704
2717
  })(exports.Web3ContractTypes || (exports.Web3ContractTypes = {}));
2705
2718
 
2706
2719
  /**
@@ -2737,6 +2750,8 @@ exports.Web3MasterWalletTypes = void 0;
2737
2750
  Web3MasterWalletTypes["PROXY_FACTORY_OWNER"] = "PROXY_FACTORY_OWNER";
2738
2751
  Web3MasterWalletTypes["RELAYER"] = "RELAYER";
2739
2752
  Web3MasterWalletTypes["TOKEN_OWNER"] = "TOKEN_OWNER";
2753
+ Web3MasterWalletTypes["TENANT_WALLET"] = "TENANT_WALLET";
2754
+ Web3MasterWalletTypes["NOTIFIER"] = "NOTIFIER";
2740
2755
  })(exports.Web3MasterWalletTypes || (exports.Web3MasterWalletTypes = {}));
2741
2756
 
2742
2757
  /**
@@ -2751,6 +2766,7 @@ exports.SigningAccountProvider = void 0;
2751
2766
  WALLETCONNECT = 'walletconnect',
2752
2767
  COINBASE_WALLET = 'coinbase_wallet', */
2753
2768
  SigningAccountProvider["DFNS"] = "DFNS";
2769
+ SigningAccountProvider["TURNKEY"] = "TURNKEY";
2754
2770
  // CUSTODIAL = 'custodial'
2755
2771
  SigningAccountProvider["PERS_INTERNAL"] = "PERS_INTERNAL"; // Internal PERS managed signing accounts
2756
2772
  })(exports.SigningAccountProvider || (exports.SigningAccountProvider = {}));
@@ -3156,6 +3172,7 @@ exports.WebhookAuthType = void 0;
3156
3172
  WebhookAuthType["NONE"] = "NONE";
3157
3173
  WebhookAuthType["BEARER"] = "BEARER";
3158
3174
  WebhookAuthType["JWT"] = "JWT";
3175
+ WebhookAuthType["HMAC"] = "HMAC";
3159
3176
  })(exports.WebhookAuthType || (exports.WebhookAuthType = {}));
3160
3177
  /**
3161
3178
  * Sources that can invoke a webhook
@@ -3213,6 +3230,47 @@ const OttAuthMethod = {
3213
3230
  EMAIL_CODE: 'email-code',
3214
3231
  };
3215
3232
 
3233
+ /**
3234
+ * Describes the economic mechanism used to issue a reward token.
3235
+ * Persisted on the transaction record for audit, analytics, and AML reporting.
3236
+ *
3237
+ * IMPORTANT: This records economic intent/attribution, not the on-chain executor.
3238
+ * Per-business on-chain execution is a Phase 2 milestone:
3239
+ * - BUSINESS_BALANCE_TRANSFER: requires transferFrom + allowance or AA smart-wallet as msg.sender
3240
+ * - BUSINESS_OWNED_MINT: requires per-business MINTER_ROLE (granted eagerly at metadata ownership assignment)
3241
+ * In the interim, business custodial smart wallets are routed through EIP-712 when CUSTODIAL wallet
3242
+ * type is detected (Step 7 of server-transaction.workflow.ts).
3243
+ *
3244
+ * Decision matrix (resolved at claim time by the reward orchestrators):
3245
+ * ERC20, canMintToken=true → TENANT_DELEGATED_MINT (tenant master wallet mints)
3246
+ * ERC20, canMintToken=false → BUSINESS_BALANCE_TRANSFER (business smart wallet transfers)
3247
+ * NFT, metadata.ownerBusinessId matches → BUSINESS_OWNED_MINT (MINTER_ROLE granted at ownership assignment)
3248
+ * NFT, no business ownership match → TENANT_DELEGATED_MINT
3249
+ * Non-reward transactions (transfer, burn, legacy) → null (not set)
3250
+ */
3251
+ exports.TokenIssuanceMode = void 0;
3252
+ (function (TokenIssuanceMode) {
3253
+ /**
3254
+ * Tenant master wallet mints new supply (default path).
3255
+ * Applies to all NFTs without business-ownership attribution and ERC20 with canMintToken=true.
3256
+ * On-chain: tenant master wallet calls mint(); cost absorbed by platform.
3257
+ */
3258
+ TokenIssuanceMode["TENANT_DELEGATED_MINT"] = "TENANT_DELEGATED_MINT";
3259
+ /**
3260
+ * Business owns the NFT template (metadata.ownerBusinessId matches engaging business).
3261
+ * MINTER_ROLE is eagerly granted to the business custodial wallet at metadata ownership assignment.
3262
+ * On-chain: business custodial smart wallet is msg.sender via EIP-712 path (Step 7).
3263
+ */
3264
+ TokenIssuanceMode["BUSINESS_OWNED_MINT"] = "BUSINESS_OWNED_MINT";
3265
+ /**
3266
+ * Business transfers from its own on-chain ERC20 balance. No new supply created.
3267
+ * Cap = business wallet balance. Triggered when canMintToken=false on the engaging business.
3268
+ * On-chain: business custodial smart wallet is msg.sender via EIP-712 path (Step 7).
3269
+ * Balance pre-check is enforced by validateTokenTransfer (Step 5) before submission.
3270
+ */
3271
+ TokenIssuanceMode["BUSINESS_BALANCE_TRANSFER"] = "BUSINESS_BALANCE_TRANSFER";
3272
+ })(exports.TokenIssuanceMode || (exports.TokenIssuanceMode = {}));
3273
+
3216
3274
  /**
3217
3275
  * Base array of all string-valued user model property keys.
3218
3276
  * Single source of truth for user field names across the codebase.
@@ -3529,7 +3587,7 @@ const validateFieldValue = (value, rules, fieldType) => {
3529
3587
  break;
3530
3588
  case 'phone':
3531
3589
  // Basic phone validation - allows digits, spaces, dashes, parentheses, plus
3532
- const phoneRegex = /^[\d\s\-\(\)\+]+$/;
3590
+ const phoneRegex = /^[\d\s\-()+]+$/;
3533
3591
  if (!phoneRegex.test(stringValue)) {
3534
3592
  return { field: '', message: 'Invalid phone number format', code: 'phone' };
3535
3593
  }
@@ -3661,6 +3719,399 @@ const validateAllFields = (data, definitions) => {
3661
3719
  return errors;
3662
3720
  };
3663
3721
 
3722
+ /**
3723
+ * User status hierarchy resolution (pure, framework-agnostic).
3724
+ *
3725
+ * Centralizes the rule for "does a user's earned status satisfy a minimum
3726
+ * requirement?" so backend and frontend evaluate it identically.
3727
+ *
3728
+ * Hierarchy semantics:
3729
+ * - Exact match always qualifies.
3730
+ * - Otherwise a user qualifies if they hold a status in the SAME hierarchy with
3731
+ * an `order` greater than or equal to the required status `order`.
3732
+ * - Hierarchies (tracks) are separated by tags: statuses with no tags share the
3733
+ * global hierarchy; tagged statuses share a track only when their tags overlap.
3734
+ * - A required status without an `order` requires an exact match (backward compatible).
3735
+ */
3736
+ /**
3737
+ * Returns true if any two tag arrays share at least one tag (case-insensitive).
3738
+ * Statuses that overlap on tags belong to the same track.
3739
+ */
3740
+ function tagsOverlap(tags1, tags2) {
3741
+ if (!tags1.length || !tags2.length)
3742
+ return false;
3743
+ const set1 = new Set(tags1.map(t => t.toLowerCase()));
3744
+ return tags2.some(tag => set1.has(tag.toLowerCase()));
3745
+ }
3746
+ /**
3747
+ * Determines whether a user meets a minimum status requirement, honoring the
3748
+ * tag-scoped status hierarchy.
3749
+ *
3750
+ * @param requiredStatusId - The minimum status type ID required.
3751
+ * @param userStatusIds - Status type IDs the user has earned.
3752
+ * @param allStatusTypes - All status types (must include the required and user statuses).
3753
+ * @returns True if the user satisfies the requirement (exact match or higher in same track).
3754
+ */
3755
+ function meetsMinimumStatusRequirement(requiredStatusId, userStatusIds, allStatusTypes) {
3756
+ // Quick check: exact match
3757
+ if (userStatusIds.includes(requiredStatusId)) {
3758
+ return true;
3759
+ }
3760
+ const statusMap = new Map(allStatusTypes.map(s => [s.id, s]));
3761
+ const requiredStatus = statusMap.get(requiredStatusId);
3762
+ if (!requiredStatus) {
3763
+ return false; // Required status doesn't exist
3764
+ }
3765
+ const requiredOrder = requiredStatus.order;
3766
+ if (requiredOrder === null || requiredOrder === undefined) {
3767
+ // No order on required status = require exact match only (backward compatible)
3768
+ return false;
3769
+ }
3770
+ const requiredTags = requiredStatus.tags || [];
3771
+ // Check if user has any status with equal or higher order in the same hierarchy
3772
+ for (const userStatusId of userStatusIds) {
3773
+ const userStatus = statusMap.get(userStatusId);
3774
+ if (!userStatus)
3775
+ continue;
3776
+ const userOrder = userStatus.order;
3777
+ if (userOrder === null || userOrder === undefined)
3778
+ continue; // No order
3779
+ const userTags = userStatus.tags || [];
3780
+ // Same hierarchy when: both untagged (global), OR they share a track tag.
3781
+ const sameHierarchy = (requiredTags.length === 0 && userTags.length === 0) ||
3782
+ tagsOverlap(requiredTags, userTags);
3783
+ if (!sameHierarchy)
3784
+ continue; // Different hierarchy
3785
+ if (userOrder >= requiredOrder) {
3786
+ return true;
3787
+ }
3788
+ }
3789
+ return false;
3790
+ }
3791
+
3792
+ /**
3793
+ * Canonical list of redemption eligibility gates.
3794
+ *
3795
+ * ⚠️ SYNC CONTRACT: These gates must mirror the validation steps in
3796
+ * `RedemptionRedeemWorkflow` (backend). If you add or remove a gate here,
3797
+ * update the workflow too — and vice versa.
3798
+ *
3799
+ * Use this constant in tests to assert that `evaluateRedemptionEligibility`
3800
+ * returns exactly these keys (no more, no less):
3801
+ *
3802
+ * expect(Object.keys(result.checks)).toEqual([...REDEMPTION_ELIGIBILITY_GATES]);
3803
+ */
3804
+ const REDEMPTION_ELIGIBILITY_GATES = [
3805
+ 'userActive',
3806
+ 'requiredFields',
3807
+ 'booking',
3808
+ 'userStatus',
3809
+ 'maxPerUser',
3810
+ ];
3811
+ function makeCheck(passed, code, message, detail) {
3812
+ return passed
3813
+ ? { passed: true, reason: null }
3814
+ : { passed: false, reason: { code, message }, ...(detail ? { detail } : {}) };
3815
+ }
3816
+ /**
3817
+ * Derives an aggregate redemption eligibility verdict from the user, redemption and status list.
3818
+ *
3819
+ * Pure and side-effect free: pass the DTOs directly from your API responses; the function
3820
+ * computes all per-gate verdicts (including status hierarchy and maxPerUser math) internally.
3821
+ *
3822
+ * Requires `?include=requiredUserInfo,booking` on the redemption fetch and
3823
+ * `?include=status` on the user fetch for full accuracy.
3824
+ */
3825
+ function evaluateRedemptionEligibility(input) {
3826
+ const { user, redemption, allStatusTypes, userRedeemCount } = input;
3827
+ const userStatusTypeIds = (user.included?.statusTypes ?? []).map(s => s.id).filter((id) => id !== undefined);
3828
+ const minUserStatusTypeId = redemption.minUserStatusType?.id ?? null;
3829
+ const requiredUserInfo = redemption.included?.requiredUserInfo;
3830
+ const booking = redemption.included?.booking;
3831
+ // userStatus — delegate to shared pure hierarchy check
3832
+ const userStatusPassed = !minUserStatusTypeId ||
3833
+ meetsMinimumStatusRequirement(minUserStatusTypeId, userStatusTypeIds, allStatusTypes);
3834
+ // maxPerUser — pure numeric comparison
3835
+ const maxPerUserPassed = !redemption.maxPerUser || userRedeemCount < redemption.maxPerUser;
3836
+ // requiredFields — use isValid from the include; derive missing keys from field definitions
3837
+ const requiredFieldsPassed = !requiredUserInfo || requiredUserInfo.isValid;
3838
+ const missingFieldKeys = requiredUserInfo?.fieldDefinitions
3839
+ ?.filter(f => f.isMissing)
3840
+ .map(f => f.key ?? f.id ?? '')
3841
+ .filter(Boolean) ?? [];
3842
+ const checks = {
3843
+ userActive: makeCheck(user.isActive !== false, 'USER_INACTIVE', 'User account is inactive'),
3844
+ requiredFields: makeCheck(requiredFieldsPassed, 'MISSING_REQUIRED_FIELDS', 'User is missing one or more required fields', requiredFieldsPassed ? undefined : { missingFieldKeys }),
3845
+ booking: makeCheck(!booking || booking.isValid, 'BOOKING_REQUIRED', 'User does not have a valid booking meeting the requirements'),
3846
+ userStatus: makeCheck(userStatusPassed, 'INSUFFICIENT_USER_STATUS', 'User does not meet the minimum status requirement', userStatusPassed || !minUserStatusTypeId ? undefined : {
3847
+ minRequiredStatusTypeId: minUserStatusTypeId,
3848
+ userStatusTypeIds,
3849
+ }),
3850
+ maxPerUser: makeCheck(maxPerUserPassed, 'MAX_PER_USER_REACHED', 'User has reached the maximum number of redeems allowed', maxPerUserPassed || !redemption.maxPerUser ? undefined : {
3851
+ maxPerUser: redemption.maxPerUser,
3852
+ userRedeemCount,
3853
+ }),
3854
+ };
3855
+ return {
3856
+ isEligible: Object.values(checks).every(c => c.passed),
3857
+ checks,
3858
+ };
3859
+ }
3860
+
3861
+ /**
3862
+ * Campaign trigger-source access rule (pure, framework-agnostic).
3863
+ *
3864
+ * Single source of truth for "may this business assign/remove/create a trigger
3865
+ * source against this campaign?", shared by CampaignService (assign/remove) and
3866
+ * TriggerSourceService (optional campaignId at creation) — two different NestJS
3867
+ * modules that each fetch the campaign via their own query, but must agree on
3868
+ * the same rule instead of maintaining separate copies.
3869
+ *
3870
+ * Rules:
3871
+ * - No requestingBusinessId (tenant admin) — always allowed.
3872
+ * - Tenant-level campaign (no ownerBusinessId) — allowed for any business; the
3873
+ * specific trigger source's own businessId ownership is checked separately.
3874
+ * - Business-owned campaign — only the owning business is allowed.
3875
+ */
3876
+ function canManageCampaignTriggerSources(campaign, requestingBusinessId) {
3877
+ if (!requestingBusinessId)
3878
+ return true;
3879
+ return !campaign.ownerBusinessId || campaign.ownerBusinessId === requestingBusinessId;
3880
+ }
3881
+
3882
+ /**
3883
+ * Computed lifecycle status of a campaign/redemption (never stored).
3884
+ * Precedence mirrors assertAvailable: inactive, then ended, then scheduled, else active.
3885
+ */
3886
+ exports.AvailabilityStatus = void 0;
3887
+ (function (AvailabilityStatus) {
3888
+ AvailabilityStatus["INACTIVE"] = "inactive";
3889
+ AvailabilityStatus["SCHEDULED"] = "scheduled";
3890
+ AvailabilityStatus["ACTIVE"] = "active";
3891
+ AvailabilityStatus["ENDED"] = "ended";
3892
+ })(exports.AvailabilityStatus || (exports.AvailabilityStatus = {}));
3893
+
3894
+ /** Precedence: inactive, ended, scheduled, active (same order as the SQL status filter). */
3895
+ function getAvailabilityStatus(entity, now = new Date()) {
3896
+ if (!entity.isActive)
3897
+ return exports.AvailabilityStatus.INACTIVE;
3898
+ if (entity.endDate && new Date(entity.endDate) < now)
3899
+ return exports.AvailabilityStatus.ENDED;
3900
+ if (entity.startDate && new Date(entity.startDate) > now)
3901
+ return exports.AvailabilityStatus.SCHEDULED;
3902
+ return exports.AvailabilityStatus.ACTIVE;
3903
+ }
3904
+ function assertAvailable(entity, errors, now = new Date()) {
3905
+ switch (getAvailabilityStatus(entity, now)) {
3906
+ case exports.AvailabilityStatus.INACTIVE: throw errors.notActive();
3907
+ case exports.AvailabilityStatus.ENDED: throw errors.ended(new Date(entity.endDate));
3908
+ case exports.AvailabilityStatus.SCHEDULED: throw errors.notStarted(new Date(entity.startDate));
3909
+ }
3910
+ }
3911
+
3912
+ /** Opt-in request header; `Accept-Language` is deliberately not used (browsers send it implicitly). */
3913
+ const LOCALE_HEADER = 'x-locale';
3914
+ const LOCALE_MAX_LENGTH = 35;
3915
+ const TRANSLATIONS_MAX_LOCALES = 30;
3916
+ const TRANSLATION_VALUE_MAX_LENGTH = 4000;
3917
+ const TRANSLATION_FIELD_MAX_LENGTH = 100;
3918
+ const TRANSLATIONS_MAX_SIZE = 32 * 1024;
3919
+ const LOCALE_PATTERN = /^[a-z]{2,3}(-[a-z0-9]{2,8}){0,3}$/;
3920
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
3921
+ /** Returns a lowercase BCP 47 tag (`en_US` -> `en-us`), or null when absent or malformed. */
3922
+ function normalizeLocale(raw) {
3923
+ if (typeof raw !== 'string')
3924
+ return null;
3925
+ const value = raw.trim().replace(/_/g, '-').toLowerCase();
3926
+ if (value.length === 0 || value.length > LOCALE_MAX_LENGTH || !LOCALE_PATTERN.test(value))
3927
+ return null;
3928
+ return value;
3929
+ }
3930
+ /**
3931
+ * Per-field lookup: exact tag, then progressively shorter tags (`ar-sa` -> `ar`), then `base`.
3932
+ * Empty or missing translated values fall through.
3933
+ */
3934
+ function resolveTranslation(translations, locale, field, base) {
3935
+ if (!translations || !locale)
3936
+ return base;
3937
+ const parts = locale.split('-');
3938
+ for (let length = parts.length; length > 0; length--) {
3939
+ const key = parts.slice(0, length).join('-');
3940
+ if (!hasOwn(translations, key))
3941
+ continue;
3942
+ const entry = translations[key];
3943
+ if (!entry || !hasOwn(entry, field))
3944
+ continue;
3945
+ const value = entry[field];
3946
+ if (typeof value === 'string' && value.length > 0)
3947
+ return value;
3948
+ }
3949
+ return base;
3950
+ }
3951
+ /** Size/count limits for a whole stored map. Returns an error message, or null when valid. */
3952
+ function validateTranslationsLimits(map) {
3953
+ if (!map)
3954
+ return null;
3955
+ if (Object.keys(map).length > TRANSLATIONS_MAX_LOCALES) {
3956
+ return `at most ${TRANSLATIONS_MAX_LOCALES} locales are allowed`;
3957
+ }
3958
+ if (JSON.stringify(map).length > TRANSLATIONS_MAX_SIZE) {
3959
+ return `translations exceed the ${TRANSLATIONS_MAX_SIZE} character limit`;
3960
+ }
3961
+ return null;
3962
+ }
3963
+ /** An allowed entry ending in `*` is a prefix wildcard (e.g. `option.*` for keyed nested texts). */
3964
+ function isAllowedField(field, allowedFields) {
3965
+ if (field.length > TRANSLATION_FIELD_MAX_LENGTH)
3966
+ return false;
3967
+ return allowedFields.some(allowed => allowed === field || (allowed.endsWith('*') && field.length >= allowed.length && field.startsWith(allowed.slice(0, -1))));
3968
+ }
3969
+ /** Structural validation of a write payload. Returns an error message, or null when valid. */
3970
+ function validateTranslationsPatch(input, allowedFields) {
3971
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
3972
+ return 'translations must be an object keyed by locale';
3973
+ }
3974
+ const entries = Object.entries(input);
3975
+ if (entries.length > TRANSLATIONS_MAX_LOCALES) {
3976
+ return `at most ${TRANSLATIONS_MAX_LOCALES} locales are allowed`;
3977
+ }
3978
+ for (const [rawLocale, entry] of entries) {
3979
+ if (normalizeLocale(rawLocale) === null)
3980
+ return `invalid locale "${rawLocale.slice(0, LOCALE_MAX_LENGTH)}"`;
3981
+ if (entry === null)
3982
+ continue;
3983
+ if (typeof entry !== 'object' || Array.isArray(entry)) {
3984
+ return `translations for "${rawLocale}" must be an object or null`;
3985
+ }
3986
+ for (const [field, value] of Object.entries(entry)) {
3987
+ if (!isAllowedField(field, allowedFields))
3988
+ return `field "${field.slice(0, 50)}" is not translatable`;
3989
+ if (value === null)
3990
+ continue;
3991
+ if (typeof value !== 'string')
3992
+ return `"${rawLocale}.${field}" must be a string or null`;
3993
+ if (value.length > TRANSLATION_VALUE_MAX_LENGTH) {
3994
+ return `"${rawLocale}.${field}" exceeds ${TRANSLATION_VALUE_MAX_LENGTH} characters`;
3995
+ }
3996
+ }
3997
+ }
3998
+ return validateTranslationsLimits(normalizePatchKeys(input));
3999
+ }
4000
+ function normalizePatchKeys(patch) {
4001
+ return applyPatch({}, patch) ?? {};
4002
+ }
4003
+ function applyPatch(base, patch) {
4004
+ const result = { ...base };
4005
+ for (const [rawLocale, entry] of Object.entries(patch)) {
4006
+ const locale = normalizeLocale(rawLocale);
4007
+ if (locale === null)
4008
+ continue;
4009
+ if (entry === null) {
4010
+ delete result[locale];
4011
+ continue;
4012
+ }
4013
+ const fields = {};
4014
+ for (const [field, value] of Object.entries(entry)) {
4015
+ if (typeof value === 'string' && value.length > 0)
4016
+ fields[field] = value;
4017
+ }
4018
+ if (Object.keys(fields).length > 0)
4019
+ result[locale] = fields;
4020
+ else
4021
+ delete result[locale];
4022
+ }
4023
+ return Object.keys(result).length > 0 ? result : null;
4024
+ }
4025
+ /** Applies a validated patch to the stored map; returns null when nothing remains. */
4026
+ function mergeTranslations(existing, patch) {
4027
+ return applyPatch(existing ?? {}, patch);
4028
+ }
4029
+ /** Client-side clean-up of an edited map: trims values, drops blank ones and locales left without text, normalizes locale keys. */
4030
+ function cleanTranslations(map) {
4031
+ const result = {};
4032
+ for (const [rawLocale, fields] of Object.entries(map ?? {})) {
4033
+ const locale = normalizeLocale(rawLocale);
4034
+ if (locale === null)
4035
+ continue;
4036
+ const kept = {};
4037
+ for (const [field, value] of Object.entries(fields ?? {})) {
4038
+ const text = typeof value === 'string' ? value.trim() : '';
4039
+ if (text.length > 0)
4040
+ kept[field] = text;
4041
+ }
4042
+ if (Object.keys(kept).length > 0)
4043
+ result[locale] = kept;
4044
+ }
4045
+ return result;
4046
+ }
4047
+ const sameFields = (a, b) => {
4048
+ const keys = Object.keys(a);
4049
+ return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key]);
4050
+ };
4051
+ /**
4052
+ * Write payload for editing: a changed locale replaces that locale, a removed one is `null`.
4053
+ * Returns `undefined` when nothing changed, so the field can be omitted and stored translations stay untouched.
4054
+ * `mergeTranslations(original, patch)` yields `cleanTranslations(current)`.
4055
+ */
4056
+ function buildTranslationsPatch(original, current) {
4057
+ const before = cleanTranslations(original);
4058
+ const after = cleanTranslations(current);
4059
+ const patch = {};
4060
+ for (const [locale, fields] of Object.entries(after)) {
4061
+ if (!before[locale] || !sameFields(before[locale], fields))
4062
+ patch[locale] = fields;
4063
+ }
4064
+ for (const locale of Object.keys(before)) {
4065
+ if (!after[locale])
4066
+ patch[locale] = null;
4067
+ }
4068
+ return Object.keys(patch).length > 0 ? patch : undefined;
4069
+ }
4070
+ /** Resolves the given translatable fields of a model for `locale`; base text when none applies. */
4071
+ function localizeFields(model, locale, fields) {
4072
+ const result = {};
4073
+ for (const field of fields) {
4074
+ result[field] = (locale
4075
+ ? resolveTranslation(model.translations, locale, field, (model[field] ?? null))
4076
+ : model[field]);
4077
+ }
4078
+ return result;
4079
+ }
4080
+ /** Restricts a translations map to `fields`; locales left empty are dropped. Null when nothing remains. */
4081
+ function pickTranslationFields(map, fields) {
4082
+ if (!map)
4083
+ return null;
4084
+ const result = {};
4085
+ for (const [locale, entry] of Object.entries(map)) {
4086
+ const picked = {};
4087
+ for (const field of fields) {
4088
+ if (hasOwn(entry, field) && typeof entry[field] === 'string')
4089
+ picked[field] = entry[field];
4090
+ }
4091
+ if (Object.keys(picked).length > 0)
4092
+ result[locale] = picked;
4093
+ }
4094
+ return Object.keys(result).length > 0 ? result : null;
4095
+ }
4096
+ /**
4097
+ * Translations for a row that inherits unset fields from a template: each field's translations
4098
+ * come from the same source as its base value (the row when it sets the field, otherwise the template).
4099
+ */
4100
+ function inheritTranslations(row, template, fields) {
4101
+ const fromRow = fields.filter(field => !template || row[field] != null);
4102
+ const fromTemplate = fields.filter(field => !!template && row[field] == null);
4103
+ const merged = {};
4104
+ for (const [source, picked] of [
4105
+ [row.translations, fromRow],
4106
+ [template?.translations, fromTemplate],
4107
+ ]) {
4108
+ for (const [locale, entry] of Object.entries(pickTranslationFields(source, picked) ?? {})) {
4109
+ merged[locale] = { ...merged[locale], ...entry };
4110
+ }
4111
+ }
4112
+ return Object.keys(merged).length > 0 ? merged : null;
4113
+ }
4114
+
3664
4115
  /**
3665
4116
  * Built-in user profile fields that can be required for redemptions.
3666
4117
  * These are a subset of UserModelField - excludes 'id' and 'identifierEmail'
@@ -3835,6 +4286,7 @@ const EntityTypes = {
3835
4286
  FILE: 'file',
3836
4287
  JWKS: 'jwks',
3837
4288
  PROJECT: 'project',
4289
+ EVENTS: 'events',
3838
4290
  };
3839
4291
  /**
3840
4292
  * Error-specific domains (extends EntityTypes)
@@ -3859,6 +4311,68 @@ const AuditEntityTypes = {
3859
4311
  ...EntityTypes,
3860
4312
  };
3861
4313
 
4314
+ /**
4315
+ * Canonical external event-type strings — the `type` field consumers see in
4316
+ * delivered envelopes. Composed from {@link EntityTypes} so domain names stay
4317
+ * DRY. Add a new entry here first; the events module registry then decides
4318
+ * scope and whether to persist it.
4319
+ *
4320
+ * Convention: `<domain>.<verb>` in lowercase dot-notation.
4321
+ */
4322
+ const ExternalEventType = {
4323
+ // Users
4324
+ UserCreated: `${EntityTypes.USER}.created`,
4325
+ UserUpdated: `${EntityTypes.USER}.updated`,
4326
+ // Transactions
4327
+ TransactionCreated: `${EntityTypes.TRANSACTION}.created`,
4328
+ TransactionMintSucceeded: `${EntityTypes.TRANSACTION}.mint_succeeded`,
4329
+ TransactionBurnSucceeded: `${EntityTypes.TRANSACTION}.burn_succeeded`,
4330
+ TransactionTransferSucceeded: `${EntityTypes.TRANSACTION}.transfer_succeeded`,
4331
+ // Campaigns
4332
+ CampaignCreated: `${EntityTypes.CAMPAIGN}.created`,
4333
+ CampaignUpdated: `${EntityTypes.CAMPAIGN}.updated`,
4334
+ CampaignClaimed: `${EntityTypes.CAMPAIGN}.claimed`,
4335
+ CampaignClaimFailed: `${EntityTypes.CAMPAIGN}.claim_failed`,
4336
+ // Redemptions
4337
+ RedemptionCreated: `${EntityTypes.REDEMPTION}.created`,
4338
+ RedemptionUpdated: `${EntityTypes.REDEMPTION}.updated`,
4339
+ RedemptionRedeemed: `${EntityTypes.REDEMPTION}.redeemed`,
4340
+ RedemptionRedeemFailed: `${EntityTypes.REDEMPTION}.redeem_failed`,
4341
+ // Businesses
4342
+ BusinessCreated: `${EntityTypes.BUSINESS}.created`,
4343
+ BusinessUpdated: `${EntityTypes.BUSINESS}.updated`,
4344
+ BusinessApproved: `${EntityTypes.BUSINESS}.approved`,
4345
+ BusinessRejected: `${EntityTypes.BUSINESS}.rejected`,
4346
+ // Campaigns (approval)
4347
+ CampaignApproved: `${EntityTypes.CAMPAIGN}.approved`,
4348
+ CampaignRejected: `${EntityTypes.CAMPAIGN}.rejected`,
4349
+ // Token metadata (approval)
4350
+ TokenMetadataApproved: `${EntityTypes.TOKEN_METADATA}.approved`,
4351
+ TokenMetadataRejected: `${EntityTypes.TOKEN_METADATA}.rejected`,
4352
+ // Redemptions (approval)
4353
+ RedemptionApproved: `${EntityTypes.REDEMPTION}.approved`,
4354
+ RedemptionRejected: `${EntityTypes.REDEMPTION}.rejected`,
4355
+ // Bookings
4356
+ BookingCreated: `${EntityTypes.BOOKING}.created`,
4357
+ BookingUpdated: `${EntityTypes.BOOKING}.updated`,
4358
+ // Token metadata
4359
+ TokenMetadataCreated: `${EntityTypes.TOKEN_METADATA}.created`,
4360
+ TokenMetadataUpdated: `${EntityTypes.TOKEN_METADATA}.updated`,
4361
+ // Auth
4362
+ MagicLinkSent: `${EntityTypes.USER}.magic_link_sent`,
4363
+ };
4364
+
4365
+ /**
4366
+ * Approval status for entities that may require tenant admin approval before becoming active.
4367
+ * Controlled by tenant.approvalSettings per-entity-type flags.
4368
+ */
4369
+ exports.ApprovalStatus = void 0;
4370
+ (function (ApprovalStatus) {
4371
+ ApprovalStatus["PENDING_APPROVAL"] = "pending_approval";
4372
+ ApprovalStatus["APPROVED"] = "approved";
4373
+ ApprovalStatus["REJECTED"] = "rejected";
4374
+ })(exports.ApprovalStatus || (exports.ApprovalStatus = {}));
4375
+
3862
4376
  /**
3863
4377
  * Error categories for classification and handling
3864
4378
  *
@@ -3901,6 +4415,33 @@ exports.ErrorCategory = void 0;
3901
4415
  ErrorCategory["UNKNOWN"] = "UNKNOWN";
3902
4416
  })(exports.ErrorCategory || (exports.ErrorCategory = {}));
3903
4417
 
4418
+ /**
4419
+ * Describes the payment/redemption mechanism from the user's perspective.
4420
+ * Computed at read time in the mapper — never stored.
4421
+ *
4422
+ * Use this for frontend rendering decisions (card labels, CTAs, progress UI)
4423
+ * instead of inspecting creditTokenAmount + priceTokenUnits shape directly.
4424
+ */
4425
+ exports.RedemptionMechanism = void 0;
4426
+ (function (RedemptionMechanism) {
4427
+ /** No payment required — redemption is free. */
4428
+ RedemptionMechanism["FREE"] = "FREE";
4429
+ /** User pays with credit tokens only (creditTokenAmount > 0, no priceTokenUnits). */
4430
+ RedemptionMechanism["CREDIT"] = "CREDIT";
4431
+ /**
4432
+ * User transfers ERC20 tokens as payment (priceTokenUnits with ERC20 token type).
4433
+ * Distinct from CREDIT: uses an on-chain token transfer, not the platform credit balance.
4434
+ */
4435
+ RedemptionMechanism["TOKEN_PAY"] = "TOKEN_PAY";
4436
+ /**
4437
+ * User burns/transfers ERC1155 or ERC721 tokens as payment.
4438
+ * Typical use case: stamp card redemption (collect N stamps → redeem reward).
4439
+ */
4440
+ RedemptionMechanism["TOKEN_BURN"] = "TOKEN_BURN";
4441
+ /** User pays with both creditTokenAmount AND priceTokenUnits. */
4442
+ RedemptionMechanism["MIXED"] = "MIXED";
4443
+ })(exports.RedemptionMechanism || (exports.RedemptionMechanism = {}));
4444
+
3904
4445
  /**
3905
4446
  * PERS WS-Relay Shared Types
3906
4447
  *
@@ -3956,6 +4497,12 @@ function isErrorMessage(msg) {
3956
4497
  function isPingMessage(msg) {
3957
4498
  return msg.type === 'ping';
3958
4499
  }
4500
+ /**
4501
+ * Type guard for notification.pending messages
4502
+ */
4503
+ function isNotificationPendingMessage(msg) {
4504
+ return msg.type === 'notification.pending';
4505
+ }
3959
4506
 
3960
4507
  /**
3961
4508
  * Check if a string matches UUID format
@@ -3974,6 +4521,10 @@ const NativeTokenTypes = {
3974
4521
  ERC1155: 'ERC1155',
3975
4522
  ERC721: 'ERC721'
3976
4523
  };
4524
+ /** Returns true for ERC721 and ERC1155 token types, false for ERC20. */
4525
+ function isNftTokenType(type) {
4526
+ return type === NativeTokenTypes.ERC721 || type === NativeTokenTypes.ERC1155;
4527
+ }
3977
4528
 
3978
4529
  const ANALYTICS_METRIC_TYPES = ['count', 'sum', 'avg', 'min', 'max'];
3979
4530
  const ANALYTICS_GROUP_BY_TYPES = ['month', 'week', 'day', 'year', 'quarter'];
@@ -4021,6 +4572,7 @@ const Domains = {
4021
4572
  WEBHOOK: 'webhook',
4022
4573
  EMAIL: 'email',
4023
4574
  AUDIT: 'audit',
4575
+ NOTIFICATION: 'notification',
4024
4576
  };
4025
4577
 
4026
4578
  /**
@@ -4069,7 +4621,8 @@ const VALID_CAMPAIGN_RELATIONS = [
4069
4621
  'triggerSources',
4070
4622
  'businesses',
4071
4623
  'claimCount',
4072
- 'ownerBusiness'
4624
+ 'ownerBusiness',
4625
+ 'translations'
4073
4626
  ];
4074
4627
  /**
4075
4628
  * Type guard to validate campaign include relation at runtime
@@ -4110,7 +4663,8 @@ const VALID_REDEMPTION_RELATIONS = [
4110
4663
  'redeemCount',
4111
4664
  'ownerBusiness',
4112
4665
  'requiredUserInfo',
4113
- 'booking'
4666
+ 'booking',
4667
+ 'translations'
4114
4668
  ];
4115
4669
  /**
4116
4670
  * Type guard to validate redemption include relation at runtime
@@ -4156,6 +4710,13 @@ function isValidBusinessMembershipRelation(value) {
4156
4710
  return VALID_BUSINESS_MEMBERSHIP_RELATIONS.includes(value);
4157
4711
  }
4158
4712
 
4713
+ const VALID_BUSINESS_RELATIONS = [
4714
+ 'translations'
4715
+ ];
4716
+ function isValidBusinessRelation(value) {
4717
+ return VALID_BUSINESS_RELATIONS.includes(value);
4718
+ }
4719
+
4159
4720
  /**
4160
4721
  * Valid include relations for User endpoints.
4161
4722
  * These relations can be requested via ?include=status,balances query parameter.
@@ -4176,7 +4737,8 @@ const VALID_TOKEN_METADATA_RELATIONS = [
4176
4737
  'ownerBusiness',
4177
4738
  'mintCount',
4178
4739
  'burnCount',
4179
- 'token'
4740
+ 'token',
4741
+ 'translations'
4180
4742
  ];
4181
4743
  /**
4182
4744
  * Type guard to validate token metadata include relation at runtime
@@ -4280,7 +4842,7 @@ function computeBookingStatus(checkInDate, checkOutDate, referenceDate = new Dat
4280
4842
  checkOut.setHours(0, 0, 0, 0);
4281
4843
  // Defensive validation: checkOut must be after checkIn
4282
4844
  // This catches invalid data from frontend or data integrity issues
4283
- if (checkOut <= checkIn) {
4845
+ if (checkOut < checkIn) {
4284
4846
  throw new Error(`Invalid booking dates: checkOut (${checkOut.toISOString()}) must be after checkIn (${checkIn.toISOString()})`);
4285
4847
  }
4286
4848
  // Checkout day itself counts as still active (guest there until end of day)
@@ -4498,28 +5060,28 @@ function parseUserAgentForPlatform(userAgent) {
4498
5060
  // Detect OS and device type
4499
5061
  if (/iPhone|iPod/i.test(ua)) {
4500
5062
  platform.os = 'iOS';
4501
- const match = ua.match(/OS (\d+)[_\.](\d+)/);
5063
+ const match = ua.match(/OS (\d+)[_.](\d+)/);
4502
5064
  if (match)
4503
5065
  platform.osVersion = `${match[1]}.${match[2]}`;
4504
5066
  platform.deviceType = 'phone';
4505
5067
  }
4506
5068
  else if (/iPad/i.test(ua)) {
4507
5069
  platform.os = 'iOS';
4508
- const match = ua.match(/OS (\d+)[_\.](\d+)/);
5070
+ const match = ua.match(/OS (\d+)[_.](\d+)/);
4509
5071
  if (match)
4510
5072
  platform.osVersion = `${match[1]}.${match[2]}`;
4511
5073
  platform.deviceType = 'tablet';
4512
5074
  }
4513
5075
  else if (/Android/i.test(ua)) {
4514
5076
  platform.os = 'Android';
4515
- const match = ua.match(/Android ([\d\.]+)/);
5077
+ const match = ua.match(/Android ([\d.]+)/);
4516
5078
  if (match)
4517
5079
  platform.osVersion = match[1];
4518
5080
  platform.deviceType = /Mobile/i.test(ua) ? 'phone' : 'tablet';
4519
5081
  }
4520
5082
  else if (/Windows NT/i.test(ua)) {
4521
5083
  platform.os = 'Windows';
4522
- const match = ua.match(/Windows NT ([\d\.]+)/);
5084
+ const match = ua.match(/Windows NT ([\d.]+)/);
4523
5085
  if (match)
4524
5086
  platform.osVersion = match[1];
4525
5087
  platform.deviceType = 'desktop';
@@ -4538,31 +5100,31 @@ function parseUserAgentForPlatform(userAgent) {
4538
5100
  // Detect browser (web channel)
4539
5101
  if (/Edg\//i.test(ua)) {
4540
5102
  platform.browser = 'Edge';
4541
- const match = ua.match(/Edg\/([\d\.]+)/);
5103
+ const match = ua.match(/Edg\/([\d.]+)/);
4542
5104
  if (match)
4543
5105
  platform.browserVersion = match[1];
4544
5106
  }
4545
5107
  else if (/Chrome\//i.test(ua) && !/Edg/i.test(ua)) {
4546
5108
  platform.browser = 'Chrome';
4547
- const match = ua.match(/Chrome\/([\d\.]+)/);
5109
+ const match = ua.match(/Chrome\/([\d.]+)/);
4548
5110
  if (match)
4549
5111
  platform.browserVersion = match[1];
4550
5112
  }
4551
5113
  else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) {
4552
5114
  platform.browser = 'Safari';
4553
- const match = ua.match(/Version\/([\d\.]+)/);
5115
+ const match = ua.match(/Version\/([\d.]+)/);
4554
5116
  if (match)
4555
5117
  platform.browserVersion = match[1];
4556
5118
  }
4557
5119
  else if (/Firefox\//i.test(ua)) {
4558
5120
  platform.browser = 'Firefox';
4559
- const match = ua.match(/Firefox\/([\d\.]+)/);
5121
+ const match = ua.match(/Firefox\/([\d.]+)/);
4560
5122
  if (match)
4561
5123
  platform.browserVersion = match[1];
4562
5124
  }
4563
5125
  else if (/Opera|OPR\//i.test(ua)) {
4564
5126
  platform.browser = 'Opera';
4565
- const match = ua.match(/(?:Opera|OPR)\/([\d\.]+)/);
5127
+ const match = ua.match(/(?:Opera|OPR)\/([\d.]+)/);
4566
5128
  if (match)
4567
5129
  platform.browserVersion = match[1];
4568
5130
  }
@@ -4649,6 +5211,28 @@ function parseUtmParams(url) {
4649
5211
  return params;
4650
5212
  }
4651
5213
 
5214
+ /** Default page when none is specified. */
5215
+ const DEFAULT_PAGE = 1;
5216
+ /** Default page size when none is specified. */
5217
+ const DEFAULT_PAGE_SIZE = 50;
5218
+ /**
5219
+ * Build a URLSearchParams object from pagination options.
5220
+ *
5221
+ * Always includes page/limit so the server always receives explicit values.
5222
+ * Uses `??` (not `||`) so an explicit `page: 0` / `limit: 0` is preserved
5223
+ * rather than silently replaced by the default.
5224
+ */
5225
+ function buildPaginationParams(options) {
5226
+ const params = new URLSearchParams();
5227
+ params.set('page', String(options?.page ?? DEFAULT_PAGE));
5228
+ params.set('limit', String(options?.limit ?? DEFAULT_PAGE_SIZE));
5229
+ if (options?.sortBy)
5230
+ params.set('sortBy', options.sortBy);
5231
+ if (options?.sortOrder)
5232
+ params.set('sortOrder', options.sortOrder);
5233
+ return params;
5234
+ }
5235
+
4652
5236
  /**
4653
5237
  * Analytics Constants
4654
5238
  *
@@ -4777,6 +5361,53 @@ function isSupportedImageFormat(extension) {
4777
5361
  return IMAGE_CDN_SUPPORTED_FORMATS.includes(extension.toLowerCase());
4778
5362
  }
4779
5363
 
5364
+ /**
5365
+ * Chain RPC Permissions
5366
+ *
5367
+ * Canonical permission sets for BLOCKCHAIN_READER_JWT / BLOCKCHAIN_WRITER_JWT tokens
5368
+ * issued to authenticate against private chain RPC endpoints (e.g. PERS Besu).
5369
+ * Single source of truth shared between Web3ChainDomainService (NestJS app) and any
5370
+ * raw, no-NestJS Lambda that needs to self-issue the same token (e.g.
5371
+ * serverless-functions/infrastructure/chain-provider.ts) — keeps both in sync instead
5372
+ * of letting a hand-picked subset drift and silently 401 on RPC calls it never
5373
+ * anticipated needing.
5374
+ */
5375
+ const CHAIN_READ_PERMISSIONS = [
5376
+ 'eth:call',
5377
+ 'eth:chainId',
5378
+ 'eth:getTransactionCount',
5379
+ 'eth:estimateGas',
5380
+ 'eth:gasPrice',
5381
+ 'eth:getBalance',
5382
+ 'eth:getBlockByNumber',
5383
+ 'eth:getBlockByHash',
5384
+ 'eth:getBlockTransactionCountByHash',
5385
+ 'eth:getBlockTransactionCountByNumber',
5386
+ 'eth:getCode',
5387
+ 'eth:getTransactionByBlockHashAndIndex',
5388
+ 'eth:getTransactionByBlockNumberAndIndex',
5389
+ 'eth:getTransactionByHash',
5390
+ 'eth:getTransactionReceipt',
5391
+ 'eth:subscribe',
5392
+ 'eth:unsubscribe',
5393
+ ];
5394
+ const CHAIN_WRITE_PERMISSIONS = [
5395
+ 'eth:*',
5396
+ ];
5397
+
5398
+ const CUSTOM_FIELD_TRANSLATABLE_FIELDS = ['label', 'description'];
5399
+ const USER_STATUS_TYPE_TRANSLATABLE_FIELDS = ['name', 'description'];
5400
+ const BUSINESS_TYPE_TRANSLATABLE_FIELDS = ['name', 'description'];
5401
+ const BUSINESS_TRANSLATABLE_FIELDS = ['displayName', 'description', 'shortDescription'];
5402
+ const TOKEN_METADATA_TRANSLATABLE_FIELDS = ['name', 'description'];
5403
+ const CAMPAIGN_TRIGGER_TRANSLATABLE_FIELDS = ['name', 'description', 'terms'];
5404
+ const TRIGGER_SOURCE_TRANSLATABLE_FIELDS = ['name', 'description'];
5405
+ const CAMPAIGN_TRANSLATABLE_FIELDS = ['name', 'description'];
5406
+ const REDEMPTION_TYPE_TRANSLATABLE_FIELDS = ['name', 'description'];
5407
+ const REDEMPTION_TRANSLATABLE_FIELDS = ['name', 'description', 'terms'];
5408
+ const TENANT_TRANSLATABLE_FIELDS = ['projectDescription'];
5409
+ const PURCHASE_TOKEN_TRANSLATABLE_FIELDS = ['description'];
5410
+
4780
5411
  /**
4781
5412
  * Chain Data Types
4782
5413
  *
@@ -4850,6 +5481,8 @@ const CommonErrorCodes = {
4850
5481
  // Business domain
4851
5482
  BUSINESS_NOT_FOUND: 'BUSINESS_NOT_FOUND',
4852
5483
  BUSINESS_NOT_MINTER: 'BUSINESS_NOT_MINTER',
5484
+ BUSINESS_DISPLAY_NAME_ALREADY_EXISTS: 'BUSINESS_DISPLAY_NAME_ALREADY_EXISTS',
5485
+ BUSINESS_NOT_APPROVED: 'BUSINESS_NOT_APPROVED',
4853
5486
  // Membership domain
4854
5487
  MEMBERSHIP_NOT_FOUND: 'MEMBERSHIP_NOT_FOUND',
4855
5488
  MEMBERSHIP_ALREADY_EXISTS: 'MEMBERSHIP_ALREADY_EXISTS',
@@ -4867,6 +5500,7 @@ const CommonErrorCodes = {
4867
5500
  CAMPAIGN_BUSINESS_REQUIRED: 'CAMPAIGN_BUSINESS_REQUIRED',
4868
5501
  CAMPAIGN_CONDITION_NOT_MET: 'CAMPAIGN_CONDITION_NOT_MET',
4869
5502
  CAMPAIGN_ALREADY_CLAIMED: 'CAMPAIGN_ALREADY_CLAIMED',
5503
+ CAMPAIGN_CLAIM_ALREADY_PROCESSING: 'CAMPAIGN_CLAIM_ALREADY_PROCESSING',
4870
5504
  CAMPAIGN_NO_TRIGGER: 'CAMPAIGN_NO_TRIGGER',
4871
5505
  CAMPAIGN_LOCATION_REQUIRED: 'CAMPAIGN_LOCATION_REQUIRED',
4872
5506
  CAMPAIGN_DISTANCE_EXCEEDED: 'CAMPAIGN_DISTANCE_EXCEEDED',
@@ -4883,6 +5517,8 @@ const CommonErrorCodes = {
4883
5517
  LOCATION_VERIFICATION_REQUIRED: 'LOCATION_VERIFICATION_REQUIRED',
4884
5518
  // Trigger domain
4885
5519
  TRIGGER_NOT_FOUND: 'TRIGGER_NOT_FOUND',
5520
+ // Notification domain
5521
+ NOTIFICATION_NOT_FOUND: 'NOTIFICATION_NOT_FOUND',
4886
5522
  // Trigger Source domain
4887
5523
  TRIGGER_SOURCE_NOT_FOUND: 'TRIGGER_SOURCE_NOT_FOUND',
4888
5524
  // Generic domain errors
@@ -4896,10 +5532,15 @@ const CommonErrorCodes = {
4896
5532
  EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
4897
5533
  AUTHENTICATION_REQUIRED: 'AUTHENTICATION_REQUIRED',
4898
5534
  AUTHORIZATION_FAILED: 'AUTHORIZATION_FAILED',
5535
+ NO_BUSINESS_ACCESS: 'NO_BUSINESS_ACCESS',
5536
+ USER_INACTIVE: 'USER_INACTIVE',
4899
5537
  CONFIGURATION_MISSING: 'CONFIGURATION_MISSING',
4900
5538
  // Redemption domain
4901
5539
  USER_STATUS_RESTRICTED: 'USER_STATUS_RESTRICTED',
4902
5540
  REDEMPTION_ALREADY_PROCESSING: 'REDEMPTION_ALREADY_PROCESSING',
5541
+ REDEMPTION_NOT_ACTIVE: 'REDEMPTION_NOT_ACTIVE',
5542
+ REDEMPTION_ENDED: 'REDEMPTION_ENDED',
5543
+ REDEMPTION_NOT_STARTED: 'REDEMPTION_NOT_STARTED',
4903
5544
  MAX_REDEMPTION: 'MAX_REDEMPTION',
4904
5545
  USER_LIMIT: 'USER_LIMIT',
4905
5546
  REQUIRED_FIELDS_MISSING: 'REQUIRED_FIELDS_MISSING',
@@ -4931,6 +5572,8 @@ const CommonErrorCodes = {
4931
5572
  WEBHOOK_SIGNATURE_INVALID: 'WEBHOOK_SIGNATURE_INVALID',
4932
5573
  WEBHOOK_FORWARD_FAILED: 'WEBHOOK_FORWARD_FAILED',
4933
5574
  WEBHOOK_TIMEOUT: 'WEBHOOK_TIMEOUT',
5575
+ // Events domain
5576
+ EVENT_SUBSCRIPTION_NOT_FOUND: 'EVENT_SUBSCRIPTION_NOT_FOUND',
4934
5577
  // Conflict errors (409)
4935
5578
  RESOURCE_CONFLICT: 'RESOURCE_CONFLICT',
4936
5579
  CONCURRENT_MODIFICATION: 'CONCURRENT_MODIFICATION',
@@ -5525,39 +6168,11 @@ const CacheTTL = {
5525
6168
  PROVIDER: 60 * 60 * 1000, // 1 hour - Provider connections with auth
5526
6169
  };
5527
6170
 
5528
- // ==========================================
5529
- // UTILITY FUNCTIONS
5530
- // ==========================================
5531
- /**
5532
- * Helper to build pagination query params with defaults
5533
- * Ensures pagination params are always included in API requests
5534
- *
5535
- * @param options - Pagination options (optional)
5536
- * @returns URLSearchParams with page and limit always set
5537
- *
5538
- * @example
5539
- * ```typescript
5540
- * async getItems(options?: PaginationOptions): Promise<PaginatedResponseDTO<ItemDTO>> {
5541
- * const params = buildPaginationParams(options);
5542
- * // params always includes: page=1&limit=50 (or custom values)
5543
- *
5544
- * if (options?.sortBy) params.set('sortBy', options.sortBy);
5545
- * if (options?.sortOrder) params.set('sortOrder', options.sortOrder);
5546
- *
5547
- * return this.apiClient.get(`/items?${params.toString()}`);
5548
- * }
5549
- * ```
5550
- */
5551
- function buildPaginationParams(options) {
5552
- const params = new URLSearchParams();
5553
- // Always include pagination params (defaults)
5554
- params.set('page', String(options?.page ?? 1));
5555
- params.set('limit', String(options?.limit ?? 50));
5556
- if (options?.sortBy)
5557
- params.set('sortBy', options.sortBy);
5558
- if (options?.sortOrder)
5559
- params.set('sortOrder', options.sortOrder);
5560
- return params;
6171
+ // Re-export pagination primitives from @explorins/pers-shared (single source of truth)
6172
+ /** Sets the comma-separated `include` query parameter when there is anything to include. */
6173
+ function setIncludeParam(params, include) {
6174
+ if (include?.length)
6175
+ params.set('include', include.join(','));
5561
6176
  }
5562
6177
  /**
5563
6178
  * Type guard to check if response is paginated
@@ -5676,7 +6291,7 @@ function normalizeToPaginated(response) {
5676
6291
  * );
5677
6292
  * ```
5678
6293
  */
5679
- async function fetchAllPages(fetchFn, limit = 50, maxPages = 100) {
6294
+ async function fetchAllPages(fetchFn, limit = DEFAULT_PAGE_SIZE, maxPages = 100) {
5680
6295
  const allItems = [];
5681
6296
  let page = 1;
5682
6297
  let hasMore = true;
@@ -5728,6 +6343,13 @@ class UserApi {
5728
6343
  const response = await this.apiClient.get(url);
5729
6344
  return normalizeToPaginated(response);
5730
6345
  }
6346
+ /**
6347
+ * PUBLIC: Get a single user's public profile by ID
6348
+ * Uses RESTful /users/public/:id endpoint
6349
+ */
6350
+ async getPublicProfileById(id) {
6351
+ return this.apiClient.get(`${this.basePath}/public/${id}`);
6352
+ }
5731
6353
  // ==========================================
5732
6354
  // AUTHENTICATED OPERATIONS
5733
6355
  // ==========================================
@@ -5936,6 +6558,12 @@ class UserService {
5936
6558
  async getAllUsersPublicProfiles(filter = null, options) {
5937
6559
  return this.userApi.getAllUsersPublicProfiles(filter, options);
5938
6560
  }
6561
+ /**
6562
+ * PUBLIC: Get a single user's public profile by ID
6563
+ */
6564
+ async getPublicProfileById(id) {
6565
+ return this.userApi.getPublicProfileById(id);
6566
+ }
5939
6567
  // ==========================================
5940
6568
  // AUTHENTICATED OPERATIONS
5941
6569
  // ==========================================
@@ -6067,6 +6695,7 @@ class UserStatusApi {
6067
6695
  async getRemoteUserStatusTypes(options) {
6068
6696
  try {
6069
6697
  const params = buildPaginationParams(options);
6698
+ setIncludeParam(params, options?.include);
6070
6699
  const response = await this.apiClient.get(`${this.basePath}/status-types?${params.toString()}`);
6071
6700
  return normalizeToPaginated(response);
6072
6701
  }
@@ -6075,13 +6704,18 @@ class UserStatusApi {
6075
6704
  throw error;
6076
6705
  }
6077
6706
  }
6707
+ /** PUBLIC: Get one user status type; `translations` adds the raw per-locale map (admin editing). */
6708
+ async getRemoteUserStatusTypeById(id, include) {
6709
+ const params = include?.length ? `?include=${include.join(',')}` : '';
6710
+ return this.apiClient.get(`${this.basePath}/status-types/${id}${params}`);
6711
+ }
6078
6712
  // ==========================================
6079
6713
  // AUTHENTICATED OPERATIONS
6080
6714
  // ==========================================
6081
6715
  /**
6082
6716
  * AUTH: Get earned user status for authenticated user
6083
6717
  * ✅ UPDATED: /user/auth/status → /users/me/status
6084
- * ✅ FIXED: Returns PaginatedResponseDTO<UserStatusTypeDTO> to match v2.0.0
6718
+ * ✅ FIXED: Returns `PaginatedResponseDTO<UserStatusTypeDTO>` to match v2.0.0
6085
6719
  * Note: Uses silent fallback pattern from framework - returns empty paginated response on error
6086
6720
  */
6087
6721
  async getRemoteEarnedUserStatus(options) {
@@ -6163,6 +6797,9 @@ class UserStatusService {
6163
6797
  async getRemoteUserStatusTypes(options) {
6164
6798
  return this.userStatusApi.getRemoteUserStatusTypes(options);
6165
6799
  }
6800
+ async getRemoteUserStatusTypeById(id, include) {
6801
+ return this.userStatusApi.getRemoteUserStatusTypeById(id, include);
6802
+ }
6166
6803
  // ==========================================
6167
6804
  // AUTHENTICATED OPERATIONS
6168
6805
  // ==========================================
@@ -6216,6 +6853,7 @@ function createUserStatusSDK(apiClient) {
6216
6853
  // ✅ FRAMEWORK ALIGNED: Only methods actually used by framework
6217
6854
  // Public methods
6218
6855
  getRemoteUserStatusTypes: (options) => userStatusService.getRemoteUserStatusTypes(options),
6856
+ getRemoteUserStatusTypeById: (id, include) => userStatusService.getRemoteUserStatusTypeById(id, include),
6219
6857
  // Auth methods
6220
6858
  getRemoteEarnedUserStatus: (options) => userStatusService.getRemoteEarnedUserStatus(options),
6221
6859
  // Admin methods
@@ -6353,6 +6991,9 @@ class TokenApi {
6353
6991
  if (options?.search) {
6354
6992
  params.set('search', options.search);
6355
6993
  }
6994
+ if (options?.approvalStatus) {
6995
+ params.set('approvalStatus', options.approvalStatus);
6996
+ }
6356
6997
  // Handle tags as string or array
6357
6998
  if (options?.tags) {
6358
6999
  const tagsArray = Array.isArray(options.tags) ? options.tags : [options.tags];
@@ -6374,6 +7015,15 @@ class TokenApi {
6374
7015
  // ==========================================
6375
7016
  // ADMIN OPERATIONS
6376
7017
  // ==========================================
7018
+ /**
7019
+ * ADMIN/BUSINESS: Get one token metadata (a business can only read its own)
7020
+ *
7021
+ * @param include - Same relations as the list: `translations` adds the raw per-locale map (admin editing)
7022
+ */
7023
+ async getTokenMetadataById(metadataId, include) {
7024
+ const params = include?.length ? `?include=${include.join(',')}` : '';
7025
+ return this.apiClient.get(`${this.basePath}/metadata/${metadataId}${params}`);
7026
+ }
6377
7027
  /**
6378
7028
  * ADMIN: Create new token
6379
7029
  */
@@ -6452,7 +7102,7 @@ class TokenApi {
6452
7102
  return this.apiClient.delete(`${this.basePath}/metadata/${metadataId}`);
6453
7103
  }
6454
7104
  // ==========================================
6455
- // 📊 CSV EXPORT
7105
+ // 📊 CSV EXPORT
6456
7106
  // ==========================================
6457
7107
  /**
6458
7108
  * ADMIN: Export token metadata to CSV
@@ -6488,6 +7138,23 @@ class TokenApi {
6488
7138
  const url = `${this.basePath}/metadata/export/csv${queryString ? `?${queryString}` : ''}`;
6489
7139
  return this.apiClient.get(url, 'blob');
6490
7140
  }
7141
+ // ==========================================
7142
+ // ✅ APPROVAL WORKFLOW
7143
+ // ==========================================
7144
+ /**
7145
+ * ADMIN: Approve a token metadata
7146
+ *
7147
+ * Approves token metadata that is pending approval, changing its status to 'approved'.
7148
+ *
7149
+ * Endpoint: PATCH /admin/token-metadata/:id/approve
7150
+ * Auth: @TenantAdmin()
7151
+ *
7152
+ * @param tokenMetadataId - ID of the token metadata to approve
7153
+ * @returns Updated token metadata with approval metadata
7154
+ */
7155
+ async setTokenMetadataApproval(tokenMetadataId, status, reason) {
7156
+ return this.apiClient.patch(`/tokens/metadata/${tokenMetadataId}/approval`, { status, reason });
7157
+ }
6491
7158
  }
6492
7159
 
6493
7160
  /**
@@ -6561,6 +7228,12 @@ class TokenService {
6561
7228
  async getTokenMetadata(options) {
6562
7229
  return this.tokenApi.getTokenMetadata(options);
6563
7230
  }
7231
+ /**
7232
+ * Get one token metadata by ID (admin/business), with the same include relations as the list
7233
+ */
7234
+ async getTokenMetadataById(metadataId, include) {
7235
+ return this.tokenApi.getTokenMetadataById(metadataId, include);
7236
+ }
6564
7237
  /**
6565
7238
  * Get rewards (ERC1155 token metadata) with filtering, pagination, and include relations
6566
7239
  *
@@ -6692,7 +7365,7 @@ class BusinessApi {
6692
7365
  this.businessTypesPath = '/businesses/types';
6693
7366
  }
6694
7367
  // ==========================================
6695
- // 🌐 BUSINESS TYPES MANAGEMENT
7368
+ // 🌐 BUSINESS TYPES MANAGEMENT
6696
7369
  // ==========================================
6697
7370
  /**
6698
7371
  * Get all business types (project key required)
@@ -6705,9 +7378,15 @@ class BusinessApi {
6705
7378
  */
6706
7379
  async getBusinessTypes(options) {
6707
7380
  const params = buildPaginationParams(options);
7381
+ setIncludeParam(params, options?.include);
6708
7382
  const response = await this.apiClient.get(`${this.businessTypesPath}?${params.toString()}`);
6709
7383
  return normalizeToPaginated(response);
6710
7384
  }
7385
+ /** PUBLIC: Get one business type; `translations` adds the raw per-locale map (admin editing). */
7386
+ async getBusinessTypeById(id, include) {
7387
+ const params = include?.length ? `?include=${include.join(',')}` : '';
7388
+ return this.apiClient.get(`${this.businessTypesPath}/${id}${params}`);
7389
+ }
6711
7390
  /**
6712
7391
  * ADMIN: Create business type
6713
7392
  *
@@ -6736,7 +7415,7 @@ class BusinessApi {
6736
7415
  return this.apiClient.delete(`${this.businessTypesPath}/${id}`);
6737
7416
  }
6738
7417
  // ==========================================
6739
- // 🏢 BUSINESS MANAGEMENT
7418
+ // 🏢 BUSINESS MANAGEMENT
6740
7419
  // ==========================================
6741
7420
  /**
6742
7421
  * Get current business info (business authentication required)
@@ -6786,12 +7465,15 @@ class BusinessApi {
6786
7465
  */
6787
7466
  async getBusinesses(options) {
6788
7467
  const params = buildPaginationParams(options);
7468
+ setIncludeParam(params, options?.include);
6789
7469
  if (options?.active !== undefined) {
6790
7470
  params.set('active', String(options.active));
6791
7471
  }
6792
7472
  if (options?.search) {
6793
7473
  params.set('search', options.search);
6794
7474
  }
7475
+ if (options?.approvalStatus)
7476
+ params.set('approvalStatus', options.approvalStatus);
6795
7477
  // Support single tag or array of tags (multiple ?tags=a&tags=b)
6796
7478
  if (options?.tags) {
6797
7479
  const tagsArray = Array.isArray(options.tags) ? options.tags : [options.tags];
@@ -6801,6 +7483,12 @@ class BusinessApi {
6801
7483
  if (options?.sanitize) {
6802
7484
  params.set('sanitize', options.sanitize);
6803
7485
  }
7486
+ // Batch lookup by id: one comma-separated param keeps the URL short for large id lists
7487
+ if (options?.ids) {
7488
+ const ids = Array.isArray(options.ids) ? options.ids : [options.ids];
7489
+ if (ids.length > 0)
7490
+ params.set('ids', ids.join(','));
7491
+ }
6804
7492
  if (options?.businessTypeId !== undefined) {
6805
7493
  params.set('businessTypeId', String(options.businessTypeId));
6806
7494
  }
@@ -6820,17 +7508,74 @@ class BusinessApi {
6820
7508
  if (options?.district) {
6821
7509
  params.set('district', options.district);
6822
7510
  }
7511
+ // Geographic radius search parameters
7512
+ if (options?.latitude !== undefined) {
7513
+ params.set('latitude', String(options.latitude));
7514
+ }
7515
+ if (options?.longitude !== undefined) {
7516
+ params.set('longitude', String(options.longitude));
7517
+ }
7518
+ if (options?.radiusMeters !== undefined) {
7519
+ params.set('radiusMeters', String(options.radiusMeters));
7520
+ }
7521
+ // Capability flag filters (admin only - ignored by non-admin callers server-side)
7522
+ if (options?.canMintToken !== undefined) {
7523
+ params.set('canMintToken', String(options.canMintToken));
7524
+ }
7525
+ if (options?.canChargeToken !== undefined) {
7526
+ params.set('canChargeToken', String(options.canChargeToken));
7527
+ }
7528
+ if (options?.canManageUsers !== undefined) {
7529
+ params.set('canManageUsers', String(options.canManageUsers));
7530
+ }
7531
+ if (options?.canReceiveDonation !== undefined) {
7532
+ params.set('canReceiveDonation', String(options.canReceiveDonation));
7533
+ }
6823
7534
  const response = await this.apiClient.get(`${this.basePath}?${params.toString()}`);
6824
7535
  return normalizeToPaginated(response);
6825
7536
  }
7537
+ /**
7538
+ * Get the distinct list of cities that have at least one business, for populating
7539
+ * city filter options before calling `getBusinesses({ city })`. Each option includes
7540
+ * `country`/`countryCode` so same-named cities in different countries can be disambiguated.
7541
+ *
7542
+ * Endpoint: GET /businesses/cities
7543
+ * Auth: @ApiSecurity('projectKey') (enhanced with role-based filtering, same as getBusinesses)
7544
+ *
7545
+ * @example
7546
+ * ```typescript
7547
+ * const cities = await businessApi.getBusinessCities();
7548
+ * // [{ city: 'Doha', country: 'Qatar', countryCode: 'QA' }, ...]
7549
+ *
7550
+ * const citiesInQatar = await businessApi.getBusinessCities({ countryCode: 'QA' });
7551
+ * ```
7552
+ */
7553
+ async getBusinessCities(options) {
7554
+ const params = new URLSearchParams();
7555
+ if (options?.active !== undefined)
7556
+ params.set('active', String(options.active));
7557
+ if (options?.approvalStatus)
7558
+ params.set('approvalStatus', options.approvalStatus);
7559
+ if (options?.countryCode)
7560
+ params.set('countryCode', options.countryCode);
7561
+ if (options?.country)
7562
+ params.set('country', options.country);
7563
+ if (options?.businessTypeId !== undefined)
7564
+ params.set('businessTypeId', String(options.businessTypeId));
7565
+ const query = params.toString();
7566
+ return this.apiClient.get(`${this.basePath}/cities${query ? `?${query}` : ''}`);
7567
+ }
6826
7568
  /**
6827
7569
  * Get business by ID
6828
7570
  *
6829
7571
  * Endpoint: GET /businesses/{id}
6830
7572
  * Auth: @ApiSecurity('projectKey')
6831
7573
  */
6832
- async getBusinessById(businessId) {
6833
- return this.apiClient.get(`${this.basePath}/${businessId}`);
7574
+ async getBusinessById(businessId, include) {
7575
+ const params = new URLSearchParams();
7576
+ setIncludeParam(params, include);
7577
+ const query = params.toString();
7578
+ return this.apiClient.get(`${this.basePath}/${businessId}${query ? `?${query}` : ''}`);
6834
7579
  }
6835
7580
  /**
6836
7581
  * Get business by account address
@@ -6842,14 +7587,14 @@ class BusinessApi {
6842
7587
  return this.apiClient.get(`${this.basePath}/account/${accountAddress}`);
6843
7588
  }
6844
7589
  // ==========================================
6845
- // 🔧 ADMIN OPERATIONS
7590
+ // 🔧 ADMIN OPERATIONS
6846
7591
  // ==========================================
6847
7592
  /**
6848
7593
  * ADMIN: Create business
6849
7594
  *
6850
7595
  * Endpoint: POST /businesses
6851
7596
  * Auth: @TenantAdmin()
6852
- * Returns: BusinessApiKeyDTO | BusinessTokenBalancesDTO
7597
+ * Returns: BusinessApiKeyDTO
6853
7598
  */
6854
7599
  async createBusiness(dto) {
6855
7600
  return this.apiClient.post(this.basePath, dto);
@@ -6904,7 +7649,7 @@ class BusinessApi {
6904
7649
  // return this.apiClient.put<string>(`${this.basePath}/${id}/api-key`, {});
6905
7650
  // }
6906
7651
  // ==========================================
6907
- // 📊 CSV EXPORT
7652
+ // 📊 CSV EXPORT
6908
7653
  // ==========================================
6909
7654
  /**
6910
7655
  * ADMIN: Export businesses to CSV
@@ -6930,6 +7675,31 @@ class BusinessApi {
6930
7675
  * });
6931
7676
  * ```
6932
7677
  */
7678
+ // ==========================================
7679
+ // ✅ APPROVAL WORKFLOW
7680
+ // ==========================================
7681
+ /**
7682
+ * ADMIN: Approve a business
7683
+ *
7684
+ * Approves a business that is pending approval, changing its status to 'approved'.
7685
+ * Only businesses with approval status 'pending_approval' can be approved.
7686
+ *
7687
+ * Endpoint: PATCH /admin/businesses/:id/approve
7688
+ * Auth: @TenantAdmin()
7689
+ *
7690
+ * @param businessId - ID of the business to approve
7691
+ * @returns Updated business with approval metadata
7692
+ *
7693
+ * @example
7694
+ * ` ypescript
7695
+ * const approvedBusiness = await businessApi.approveBusiness('business-123');
7696
+ * console.log('Approved by:', approvedBusiness.approval?.approvedBy);
7697
+ * console.log('Approved at:', approvedBusiness.approval?.approvedAt);
7698
+ * `
7699
+ */
7700
+ async setBusinessApproval(businessId, status, reason) {
7701
+ return this.apiClient.patch(`/businesses/${businessId}/approval`, { status, reason });
7702
+ }
6933
7703
  async exportCSV(options) {
6934
7704
  const params = new URLSearchParams();
6935
7705
  if (options?.dateFrom)
@@ -7202,11 +7972,21 @@ class BusinessService {
7202
7972
  async getBusinessTypes(options) {
7203
7973
  return this.businessApi.getBusinessTypes(options);
7204
7974
  }
7975
+ async getBusinessTypeById(id, include) {
7976
+ return this.businessApi.getBusinessTypeById(id, include);
7977
+ }
7205
7978
  /**
7206
7979
  * Get business by ID
7207
7980
  */
7208
- async getBusinessById(businessId) {
7209
- return this.businessApi.getBusinessById(businessId);
7981
+ async getBusinessById(businessId, include) {
7982
+ return this.businessApi.getBusinessById(businessId, include);
7983
+ }
7984
+ /**
7985
+ * Get the distinct list of cities that have at least one business, for populating
7986
+ * city filter options before calling `getBusinesses({ city })`.
7987
+ */
7988
+ async getBusinessCities(options) {
7989
+ return this.businessApi.getBusinessCities(options);
7210
7990
  }
7211
7991
  /**
7212
7992
  * Get business by account address
@@ -7237,11 +8017,18 @@ class BusinessService {
7237
8017
  async getBusinesses(options) {
7238
8018
  return this.businessApi.getBusinesses(options);
7239
8019
  }
8020
+ /**
8021
+ * ADMIN: Create business with full DTO
8022
+ */
8023
+ async createBusiness(dto) {
8024
+ return this.businessApi.createBusiness(dto);
8025
+ }
7240
8026
  /**
7241
8027
  * ADMIN: Create business by display name
8028
+ * @deprecated Use createBusiness(dto) for full control
7242
8029
  */
7243
8030
  async createBusinessByDisplayName(displayName) {
7244
- return this.businessApi.createBusinessByDisplayName(displayName);
8031
+ return this.businessApi.createBusiness({ displayName });
7245
8032
  }
7246
8033
  /**
7247
8034
  * ADMIN: Update business
@@ -7573,36 +8360,32 @@ class CampaignApi {
7573
8360
  * ```
7574
8361
  */
7575
8362
  async getCampaigns(options) {
7576
- const params = [];
7577
- // Always include pagination params to ensure paginated response
7578
- params.push(`page=${options?.page || 1}`);
7579
- params.push(`limit=${options?.limit || 50}`);
8363
+ // Always include pagination params (page/limit, plus sortBy/sortOrder) to ensure a paginated response
8364
+ const params = buildPaginationParams(options);
7580
8365
  if (options?.active !== undefined)
7581
- params.push(`active=${options.active}`);
8366
+ params.set('active', String(options.active));
7582
8367
  // Support single tag or array of tags (multiple ?tags=a&tags=b)
7583
8368
  if (options?.tags) {
7584
8369
  const tagsArray = Array.isArray(options.tags) ? options.tags : [options.tags];
7585
- tagsArray.forEach(t => params.push(`tags=${encodeURIComponent(t)}`));
8370
+ tagsArray.forEach(t => params.append('tags', t));
7586
8371
  }
7587
8372
  if (options?.businessId)
7588
- params.push(`businessId=${encodeURIComponent(options.businessId)}`);
8373
+ params.set('businessId', options.businessId);
8374
+ if (options?.ownerBusinessId)
8375
+ params.set('ownerBusinessId', options.ownerBusinessId);
8376
+ if (options?.approvalStatus)
8377
+ params.set('approvalStatus', options.approvalStatus);
8378
+ if (options?.status)
8379
+ params.set('status', [options.status].flat().join(','));
7589
8380
  if (options?.search)
7590
- params.push(`search=${encodeURIComponent(options.search)}`);
7591
- if (options?.startDate) {
7592
- const dateStr = options.startDate instanceof Date ? options.startDate.toISOString() : options.startDate;
7593
- params.push(`startDate=${encodeURIComponent(dateStr)}`);
7594
- }
7595
- if (options?.endDate) {
7596
- const dateStr = options.endDate instanceof Date ? options.endDate.toISOString() : options.endDate;
7597
- params.push(`endDate=${encodeURIComponent(dateStr)}`);
7598
- }
7599
- if (options?.sortBy)
7600
- params.push(`sortBy=${options.sortBy}`);
7601
- if (options?.sortOrder)
7602
- params.push(`sortOrder=${options.sortOrder}`);
8381
+ params.set('search', options.search);
8382
+ if (options?.startDate)
8383
+ params.set('startDate', options.startDate);
8384
+ if (options?.endDate)
8385
+ params.set('endDate', options.endDate);
7603
8386
  if (options?.include?.length)
7604
- params.push(`include=${options.include.join(',')}`);
7605
- return this.apiClient.get(`/campaigns?${params.join('&')}`);
8387
+ params.set('include', options.include.join(','));
8388
+ return this.apiClient.get(`/campaigns?${params.toString()}`);
7606
8389
  }
7607
8390
  /**
7608
8391
  * PUBLIC: Get campaign by ID
@@ -7869,6 +8652,10 @@ class CampaignApi {
7869
8652
  params.set('userId', filters.userId);
7870
8653
  if (filters?.businessId)
7871
8654
  params.set('businessId', filters.businessId);
8655
+ if (filters?.triggerType)
8656
+ params.set('triggerType', filters.triggerType);
8657
+ if (filters?.status)
8658
+ params.set('status', filters.status);
7872
8659
  if (filters?.dateFrom)
7873
8660
  params.set('dateFrom', filters.dateFrom instanceof Date ? filters.dateFrom.toISOString() : filters.dateFrom);
7874
8661
  if (filters?.dateTo)
@@ -7963,6 +8750,23 @@ class CampaignApi {
7963
8750
  const url = `/campaigns/claims/export/csv${queryString ? `?${queryString}` : ''}`;
7964
8751
  return this.apiClient.get(url, 'blob');
7965
8752
  }
8753
+ // ==========================================
8754
+ // ✅ APPROVAL WORKFLOW
8755
+ // ==========================================
8756
+ /**
8757
+ * ADMIN: Approve a campaign
8758
+ *
8759
+ * Approves a campaign that is pending approval, changing its status to 'approved'.
8760
+ *
8761
+ * Endpoint: PATCH /admin/campaigns/:id/approve
8762
+ * Auth: @TenantAdmin()
8763
+ *
8764
+ * @param campaignId - ID of the campaign to approve
8765
+ * @returns Updated campaign with approval metadata
8766
+ */
8767
+ async setCampaignApproval(campaignId, status, reason) {
8768
+ return this.apiClient.patch(`/campaigns/${campaignId}/approval`, { status, reason });
8769
+ }
7966
8770
  }
7967
8771
 
7968
8772
  /**
@@ -8197,52 +9001,54 @@ class RedemptionApi {
8197
9001
  * ```
8198
9002
  */
8199
9003
  async getRedemptions(options) {
8200
- const params = [];
8201
- // Always include pagination params to ensure paginated response
8202
- params.push(`page=${options?.page || 1}`);
8203
- params.push(`limit=${options?.limit || 50}`);
9004
+ // Always include pagination params (page/limit, plus sortBy/sortOrder) to ensure a paginated response
9005
+ const params = buildPaginationParams(options);
8204
9006
  if (options?.active !== undefined)
8205
- params.push(`active=${options.active}`);
9007
+ params.set('active', String(options.active));
8206
9008
  if (options?.search)
8207
- params.push(`search=${encodeURIComponent(options.search)}`);
9009
+ params.set('search', options.search);
8208
9010
  // Support single tag or array of tags (multiple ?tags=a&tags=b)
8209
9011
  if (options?.tags) {
8210
9012
  const tagsArray = Array.isArray(options.tags) ? options.tags : [options.tags];
8211
- tagsArray.forEach(t => params.push(`tags=${encodeURIComponent(t)}`));
8212
- }
8213
- if (options?.startDate) {
8214
- const dateStr = options.startDate instanceof Date ? options.startDate.toISOString() : options.startDate;
8215
- params.push(`startDate=${encodeURIComponent(dateStr)}`);
8216
- }
8217
- if (options?.endDate) {
8218
- const dateStr = options.endDate instanceof Date ? options.endDate.toISOString() : options.endDate;
8219
- params.push(`endDate=${encodeURIComponent(dateStr)}`);
9013
+ tagsArray.forEach(t => params.append('tags', t));
8220
9014
  }
8221
- if (options?.sortBy)
8222
- params.push(`sortBy=${options.sortBy}`);
8223
- if (options?.sortOrder)
8224
- params.push(`sortOrder=${options.sortOrder}`);
9015
+ if (options?.ownerBusinessId)
9016
+ params.set('ownerBusinessId', options.ownerBusinessId);
9017
+ if (options?.approvalStatus)
9018
+ params.set('approvalStatus', options.approvalStatus);
9019
+ if (options?.status)
9020
+ params.set('status', [options.status].flat().join(','));
9021
+ if (options?.startDate)
9022
+ params.set('startDate', options.startDate);
9023
+ if (options?.endDate)
9024
+ params.set('endDate', options.endDate);
8225
9025
  if (options?.include?.length)
8226
- params.push(`include=${options.include.join(',')}`);
8227
- const response = await this.apiClient.get(`${this.basePath}?${params.join('&')}`);
9026
+ params.set('include', options.include.join(','));
9027
+ const response = await this.apiClient.get(`${this.basePath}?${params.toString()}`);
8228
9028
  return normalizeToPaginated(response);
8229
9029
  }
8230
9030
  /**
8231
9031
  * PUBLIC: Get redemption types
8232
9032
  *
8233
- * Updated: /redemption/type → /redemption-types
9033
+ * Updated: /redemption/type → /redemption-types
8234
9034
  * @param options Pagination options (page, limit, sortBy, sortOrder)
8235
9035
  * @returns Paginated response with redemption types
8236
9036
  */
8237
9037
  async getRedemptionTypes(options) {
8238
9038
  const params = buildPaginationParams(options);
9039
+ setIncludeParam(params, options?.include);
8239
9040
  const response = await this.apiClient.get(`${this.typesPath}?${params.toString()}`);
8240
9041
  return normalizeToPaginated(response);
8241
9042
  }
9043
+ /** PUBLIC: Get one redemption type; `translations` adds the raw per-locale map (admin editing). */
9044
+ async getRedemptionTypeById(id, include) {
9045
+ const params = include?.length ? `?include=${include.join(',')}` : '';
9046
+ return this.apiClient.get(`${this.typesPath}/${id}${params}`);
9047
+ }
8242
9048
  /**
8243
9049
  * PUBLIC: Get redemption by ID
8244
9050
  *
8245
- * Updated: /redemption/:id → /redemptions/:id
9051
+ * Updated: /redemption/:id → /redemptions/:id
8246
9052
  *
8247
9053
  * @param id - Redemption UUID
8248
9054
  * @param include - Relations to include: 'redeemCount'
@@ -8261,7 +9067,7 @@ class RedemptionApi {
8261
9067
  /**
8262
9068
  * PUBLIC: Get available supply for redemption
8263
9069
  *
8264
- * Updated: /redemption/:id/available-supply → /redemptions/:id/supply
9070
+ * Updated: /redemption/:id/available-supply → /redemptions/:id/supply
8265
9071
  */
8266
9072
  async getRedemptionAvailableSupply(id) {
8267
9073
  const result = await this.apiClient.get(`${this.basePath}/${id}/supply`);
@@ -8377,7 +9183,7 @@ class RedemptionApi {
8377
9183
  /**
8378
9184
  * ADMIN: Create redemption
8379
9185
  *
8380
- * Updated: /redemption/admin → /redemptions
9186
+ * Updated: /redemption/admin → /redemptions
8381
9187
  */
8382
9188
  async createRedemption(redemption) {
8383
9189
  return this.apiClient.post(`${this.basePath}`, redemption);
@@ -8385,7 +9191,7 @@ class RedemptionApi {
8385
9191
  /**
8386
9192
  * ADMIN: Update redemption
8387
9193
  *
8388
- * Updated: /redemption/admin/:id → /redemptions/:id
9194
+ * Updated: /redemption/admin/:id → /redemptions/:id
8389
9195
  */
8390
9196
  async updateRedemption(id, redemptionCreateRequest) {
8391
9197
  return this.apiClient.put(`${this.basePath}/${id}`, redemptionCreateRequest);
@@ -8393,7 +9199,7 @@ class RedemptionApi {
8393
9199
  /**
8394
9200
  * UNIFIED: Toggle redemption active status
8395
9201
  *
8396
- * Updated: /redemption/admin/:id/toggle-active → /redemptions/:id/status
9202
+ * Updated: /redemption/admin/:id/toggle-active → /redemptions/:id/status
8397
9203
  * Following standard /status pattern used across domains
8398
9204
  */
8399
9205
  async toggleRedemptionActive(redemptionId) {
@@ -8402,7 +9208,7 @@ class RedemptionApi {
8402
9208
  /**
8403
9209
  * ADMIN: Delete redemption
8404
9210
  *
8405
- * Updated: /redemption/admin/:id → /redemptions/:id
9211
+ * Updated: /redemption/admin/:id → /redemptions/:id
8406
9212
  */
8407
9213
  async deleteRedemption(id) {
8408
9214
  return this.apiClient.delete(`${this.basePath}/${id}`);
@@ -8410,7 +9216,7 @@ class RedemptionApi {
8410
9216
  /**
8411
9217
  * ADMIN: Create redemption type
8412
9218
  *
8413
- * Updated: /redemption/admin/type → /redemption-types
9219
+ * Updated: /redemption/admin/type → /redemption-types
8414
9220
  */
8415
9221
  async createRedemptionType(redemptionType) {
8416
9222
  return this.apiClient.post(this.typesPath, redemptionType);
@@ -8437,7 +9243,7 @@ class RedemptionApi {
8437
9243
  /**
8438
9244
  * ADMIN: Create redemption token unit
8439
9245
  *
8440
- * Updated: /redemption/admin/:id/token-units → /redemption-tokens/:id/
9246
+ * Updated: /redemption/admin/:id/token-units → /redemption-tokens/:id/
8441
9247
  */
8442
9248
  async createRedemptionTokenUnit(redemptionId, redemptionTokenUnit) {
8443
9249
  return this.apiClient.post(`${this.basePath}/${redemptionId}/tokens`, redemptionTokenUnit);
@@ -8445,7 +9251,7 @@ class RedemptionApi {
8445
9251
  /**
8446
9252
  * ADMIN: Update redemption token unit
8447
9253
  *
8448
- * Updated: /redemption/admin/:id/token-units/:tokenUnitId → /redemption-tokens/:id/:tokenUnitId
9254
+ * Updated: /redemption/admin/:id/token-units/:tokenUnitId → /redemption-tokens/:id/:tokenUnitId
8449
9255
  */
8450
9256
  async updateRedemptionTokenUnit(redemptionId, tokenUnitId, redemptionTokenUnit) {
8451
9257
  return this.apiClient.put(`${this.basePath}/${redemptionId}/tokens/${tokenUnitId}`, redemptionTokenUnit);
@@ -8453,7 +9259,7 @@ class RedemptionApi {
8453
9259
  /**
8454
9260
  * ADMIN: Delete redemption token unit
8455
9261
  *
8456
- * Updated: /redemption/admin/:id/token-units/:tokenUnitId → /redemption-tokens/:id/:tokenUnitId
9262
+ * Updated: /redemption/admin/:id/token-units/:tokenUnitId → /redemption-tokens/:id/:tokenUnitId
8457
9263
  */
8458
9264
  async deleteRedemptionTokenUnit(redemptionId, redemptionTokenUnitId) {
8459
9265
  return this.apiClient.delete(`${this.basePath}/${redemptionId}/tokens/${redemptionTokenUnitId}`);
@@ -8511,6 +9317,23 @@ class RedemptionApi {
8511
9317
  const url = `${this.basePath}/redeems/export/csv${queryString ? `?${queryString}` : ''}`;
8512
9318
  return this.apiClient.get(url, 'blob');
8513
9319
  }
9320
+ // ==========================================
9321
+ // ✅ APPROVAL WORKFLOW
9322
+ // ==========================================
9323
+ /**
9324
+ * ADMIN: Approve a redemption
9325
+ *
9326
+ * Approves a redemption that is pending approval, changing its status to 'approved'.
9327
+ *
9328
+ * Endpoint: PATCH /admin/redemptions/:id/approve
9329
+ * Auth: @TenantAdmin()
9330
+ *
9331
+ * @param redemptionId - ID of the redemption to approve
9332
+ * @returns Updated redemption with approval metadata
9333
+ */
9334
+ async setRedemptionApproval(redemptionId, status, reason) {
9335
+ return this.apiClient.patch(`/redemptions/${redemptionId}/approval`, { status, reason });
9336
+ }
8514
9337
  }
8515
9338
 
8516
9339
  /**
@@ -8565,6 +9388,9 @@ class RedemptionService {
8565
9388
  async getRedemptionTypes(options) {
8566
9389
  return this.redemptionApi.getRedemptionTypes(options);
8567
9390
  }
9391
+ async getRedemptionTypeById(id, include) {
9392
+ return this.redemptionApi.getRedemptionTypeById(id, include);
9393
+ }
8568
9394
  // ==========================================
8569
9395
  // AUTHENTICATED OPERATIONS
8570
9396
  // ==========================================
@@ -9581,11 +10407,12 @@ class PurchaseApi {
9581
10407
  * - ADMIN (Tenant Admin JWT): Returns filtered results based on active parameter
9582
10408
  *
9583
10409
  * @param active Filter by active status
9584
- * @param options Pagination options (page, limit, sortBy, sortOrder)
10410
+ * @param options Pagination options (page, limit, sortBy, sortOrder) and `include: ['translations']` for admin editing
9585
10411
  * @returns Paginated response with purchase tokens
9586
10412
  */
9587
10413
  async getPurchaseTokens(active, options) {
9588
10414
  const params = buildPaginationParams(options);
10415
+ setIncludeParam(params, options?.include);
9589
10416
  if (active !== undefined) {
9590
10417
  params.append('active', String(active));
9591
10418
  }
@@ -9690,7 +10517,7 @@ class PurchaseApi {
9690
10517
  */
9691
10518
  async getUserPurchaseHistory(options) {
9692
10519
  const params = buildPaginationParams(options);
9693
- const response = await this.apiClient.get(`${this.basePath}/me/history?${params.toString()}`);
10520
+ const response = await this.apiClient.get(`${this.basePath}/me?${params.toString()}`);
9694
10521
  return normalizeToPaginated(response);
9695
10522
  }
9696
10523
  }
@@ -9777,10 +10604,10 @@ class TenantApi {
9777
10604
  * Requires: X-API-Key header + Authorization Bearer token (Admin JWT)
9778
10605
  * Returns: Full tenant information including allowedTokenIssuers and sensitive config
9779
10606
  */
9780
- async getRemoteTenant() {
9781
- const timestamp = Date.now().toString();
9782
- const url = `${this.basePath}/me?date=${timestamp}`;
9783
- return this.apiClient.get(url);
10607
+ async getRemoteTenant(include) {
10608
+ const params = new URLSearchParams({ date: Date.now().toString() });
10609
+ setIncludeParam(params, include);
10610
+ return this.apiClient.get(`${this.basePath}/me?${params.toString()}`);
9784
10611
  }
9785
10612
  /**
9786
10613
  * API KEY: Get current tenant public information
@@ -9789,10 +10616,10 @@ class TenantApi {
9789
10616
  * Requires: X-API-Key header (handled by PersApiClient)
9790
10617
  * Returns: Public tenant information (no sensitive config)
9791
10618
  */
9792
- async getRemoteTenantPublic() {
9793
- const timestamp = Date.now().toString();
9794
- const url = `${this.basePath}/me?date=${timestamp}`;
9795
- return this.apiClient.get(url);
10619
+ async getRemoteTenantPublic(include) {
10620
+ const params = new URLSearchParams({ date: Date.now().toString() });
10621
+ setIncludeParam(params, include);
10622
+ return this.apiClient.get(`${this.basePath}/me?${params.toString()}`);
9796
10623
  }
9797
10624
  /**
9798
10625
  * API KEY: Get remote login token
@@ -9817,9 +10644,11 @@ class TenantApi {
9817
10644
  *
9818
10645
  * @param tenantId - The tenant ID to retrieve public information for
9819
10646
  */
9820
- async getPublicTenantById(tenantId) {
9821
- const url = `${this.basePath}/${tenantId}`;
9822
- return this.apiClient.get(url);
10647
+ async getPublicTenantById(tenantId, include) {
10648
+ const params = new URLSearchParams();
10649
+ setIncludeParam(params, include);
10650
+ const query = params.toString();
10651
+ return this.apiClient.get(`${this.basePath}/${tenantId}${query ? `?${query}` : ''}`);
9823
10652
  }
9824
10653
  // ==========================================
9825
10654
  // ADMIN OPERATIONS
@@ -9883,14 +10712,14 @@ class TenantService {
9883
10712
  /**
9884
10713
  * Get full tenant information (Admin JWT required)
9885
10714
  */
9886
- async getRemoteTenant() {
9887
- return this.tenantApi.getRemoteTenant();
10715
+ async getRemoteTenant(include) {
10716
+ return this.tenantApi.getRemoteTenant(include);
9888
10717
  }
9889
10718
  /**
9890
10719
  * PUBLIC: Get tenant public information (API key only)
9891
10720
  */
9892
- async getRemoteTenantPublic() {
9893
- return this.tenantApi.getRemoteTenantPublic();
10721
+ async getRemoteTenantPublic(include) {
10722
+ return this.tenantApi.getRemoteTenantPublic(include);
9894
10723
  }
9895
10724
  /**
9896
10725
  * PUBLIC: Get remote login token
@@ -9943,6 +10772,177 @@ class TenantService {
9943
10772
  }
9944
10773
  }
9945
10774
 
10775
+ /**
10776
+ * IPFS Resolver - Gateway and URL resolution (chain-agnostic, no token or web3 types)
10777
+ *
10778
+ * @module @explorins/pers-sdk/ipfs
10779
+ */
10780
+ const DEFAULT_IPFS_GATEWAY = 'gateway.pinata.cloud';
10781
+ class IpfsResolver {
10782
+ constructor(config) {
10783
+ this.config = config;
10784
+ this.cache = globalCacheService.createNamespace('ipfs');
10785
+ }
10786
+ /** Tenant gateway domain; falls back to a public gateway so assets still load when unconfigured. */
10787
+ getGatewayDomain() {
10788
+ return this.cache.getOrSet('gateway', async () => {
10789
+ const { ipfsGatewayDomain } = await this.config.getClientConfig();
10790
+ if (!ipfsGatewayDomain) {
10791
+ console.warn(`[PERS SDK] No ipfsGatewayDomain configured for the tenant, using ${DEFAULT_IPFS_GATEWAY}`);
10792
+ return DEFAULT_IPFS_GATEWAY;
10793
+ }
10794
+ return ipfsGatewayDomain;
10795
+ }, CacheTTL.GATEWAY);
10796
+ }
10797
+ /** `ipfs://` and `ar://` to HTTPS; HTTP(S) and unknown schemes pass through; empty input gives ''. */
10798
+ async resolveUrl(url) {
10799
+ if (!url)
10800
+ return '';
10801
+ if (url.startsWith('ipfs://')) {
10802
+ return `https://${await this.getGatewayDomain()}/ipfs/${url.slice('ipfs://'.length)}`;
10803
+ }
10804
+ if (url.startsWith('ar://')) {
10805
+ return `https://arweave.net/${url.slice('ar://'.length)}`;
10806
+ }
10807
+ return url;
10808
+ }
10809
+ clearCache() {
10810
+ this.cache.clear();
10811
+ }
10812
+ /** Fetches JSON from any supported URI; throws on a non-OK response. */
10813
+ async fetchJson(uri) {
10814
+ const resolved = await this.resolveUrl(uri);
10815
+ const response = await fetch(resolved);
10816
+ if (!response.ok) {
10817
+ throw new Error(`HTTP ${response.status} fetching ${resolved}`);
10818
+ }
10819
+ return response.json();
10820
+ }
10821
+ }
10822
+
10823
+ /**
10824
+ * Token Metadata Resolver - The single place where ERC/OpenSea metadata JSON becomes SDK TokenMetadata
10825
+ *
10826
+ * Returns raw (language-agnostic) metadata and caches it: IPFS content is immutable.
10827
+ * Callers apply the locale when handing the data out.
10828
+ *
10829
+ * @module @explorins/pers-sdk/token/metadata
10830
+ */
10831
+ const STANDARD_KEYS = new Set([
10832
+ 'name', 'description', 'image', 'image_url', 'imageUrl',
10833
+ 'animation_url', 'animationUrl', 'external_url', 'externalUrl', 'attributes',
10834
+ ]);
10835
+ const isResolvable = (value) => typeof value === 'string' && (value.startsWith('ipfs://') || value.startsWith('ar://'));
10836
+ const text = (...candidates) => {
10837
+ const found = candidates.find(value => value !== undefined && value !== null && value !== '');
10838
+ return found === undefined ? undefined : String(found);
10839
+ };
10840
+ class TokenMetadataResolver {
10841
+ constructor(ipfs) {
10842
+ this.ipfs = ipfs;
10843
+ this.cache = globalCacheService.createNamespace('token-metadata');
10844
+ }
10845
+ /** Normalized metadata with HTTPS asset URLs, or null when it cannot be fetched. */
10846
+ fetch(tokenUri) {
10847
+ return this.cache.getOrSet(`processed:${tokenUri}`, async () => {
10848
+ try {
10849
+ return await this.load(tokenUri);
10850
+ }
10851
+ catch (error) {
10852
+ console.warn(`[PERS SDK] Error fetching metadata from ${tokenUri}:`, error);
10853
+ return null;
10854
+ }
10855
+ }, CacheTTL.METADATA);
10856
+ }
10857
+ clearCache() {
10858
+ this.cache.clear();
10859
+ }
10860
+ async load(tokenUri) {
10861
+ const raw = await this.ipfs.fetchJson(tokenUri);
10862
+ // Resolve ipfs:// and ar:// references in any top-level string field
10863
+ const resolved = { ...raw };
10864
+ for (const [key, value] of Object.entries(resolved)) {
10865
+ if (isResolvable(value))
10866
+ resolved[key] = await this.ipfs.resolveUrl(value);
10867
+ }
10868
+ const customProperties = Object.fromEntries(Object.entries(resolved).filter(([key]) => !STANDARD_KEYS.has(key)));
10869
+ return {
10870
+ name: text(resolved.name) ?? '',
10871
+ description: text(resolved.description) ?? '',
10872
+ imageUrl: text(resolved.image, resolved.image_url, resolved.imageUrl) ?? '',
10873
+ animationUrl: text(resolved.animation_url, resolved.animationUrl),
10874
+ externalUrl: text(resolved.external_url, resolved.externalUrl),
10875
+ attributes: Array.isArray(resolved.attributes) ? resolved.attributes : [],
10876
+ ...customProperties,
10877
+ };
10878
+ }
10879
+ }
10880
+ function createIpfsSources(config) {
10881
+ const resolver = new IpfsResolver(config);
10882
+ return { resolver, metadata: new TokenMetadataResolver(resolver) };
10883
+ }
10884
+
10885
+ /**
10886
+ * Pure read-time localization of token metadata resolved from IPFS/HTTP (client-side path that
10887
+ * bypasses the API). Caches keep the raw immutable JSON; callers apply the locale when handing data out.
10888
+ *
10889
+ * @module @explorins/pers-sdk/token/localization
10890
+ */
10891
+ /**
10892
+ * Replaces `name` and `description` with the translation for `locale` from the metadata's
10893
+ * `translations` field (snapshotted at pin time). Falls back per field to the base text.
10894
+ */
10895
+ function localizeTokenMetadata(metadata, locale) {
10896
+ if (!locale || !metadata.translations)
10897
+ return metadata;
10898
+ return { ...metadata, ...localizeFields(metadata, locale, ['name', 'description']) };
10899
+ }
10900
+ /** Same instance when the locale is off or the metadata has no translations, so memoized consumers are not re-rendered. */
10901
+ function localizeTokenBalance(balance, locale) {
10902
+ if (!locale || !balance.metadata)
10903
+ return balance;
10904
+ const metadata = localizeTokenMetadata(balance.metadata, locale);
10905
+ return metadata === balance.metadata ? balance : { ...balance, metadata };
10906
+ }
10907
+ function localizeTokenCollection(collection, locale) {
10908
+ if (!locale)
10909
+ return collection;
10910
+ const tokens = collection.tokens.map(token => localizeTokenBalance(token, locale));
10911
+ return tokens.every((token, i) => token === collection.tokens[i]) ? collection : { ...collection, tokens };
10912
+ }
10913
+
10914
+ /**
10915
+ * IPFS Manager - Gateway, URL and token metadata resolution (chain-agnostic, no web3 dependencies)
10916
+ *
10917
+ * @example
10918
+ * ```typescript
10919
+ * const url = await sdk.ipfs.resolveUrl('ipfs://QmXxx...');
10920
+ * const metadata = await sdk.ipfs.fetchMetadata('ipfs://QmXxx.../metadata.json');
10921
+ * ```
10922
+ */
10923
+ class IpfsManager {
10924
+ constructor(config, getLocale = () => null) {
10925
+ this.getLocale = getLocale;
10926
+ this.sources = createIpfsSources(config);
10927
+ }
10928
+ /** Tenant IPFS gateway domain (cached; falls back to a public gateway when unconfigured) */
10929
+ getGatewayDomain() {
10930
+ return this.sources.resolver.getGatewayDomain();
10931
+ }
10932
+ /** Resolve `ipfs://` and `ar://` URLs to HTTPS; other URLs pass through */
10933
+ resolveUrl(url) {
10934
+ return this.sources.resolver.resolveUrl(url);
10935
+ }
10936
+ /**
10937
+ * Fetch token metadata from a token URI (cached; IPFS content is immutable).
10938
+ * name/description follow the SDK locale when the metadata carries translations.
10939
+ */
10940
+ async fetchMetadata(tokenUri) {
10941
+ const metadata = await this.sources.metadata.fetch(tokenUri);
10942
+ return metadata ? localizeTokenMetadata(metadata, this.getLocale()) : null;
10943
+ }
10944
+ }
10945
+
9946
10946
  /**
9947
10947
  * Tenant Manager - Clean, high-level interface for tenant operations
9948
10948
  *
@@ -9961,8 +10961,8 @@ class TenantManager {
9961
10961
  *
9962
10962
  * @returns Promise resolving to public tenant data
9963
10963
  */
9964
- async getTenantPublicInfo() {
9965
- return this.tenantService.getRemoteTenantPublic();
10964
+ async getTenantPublicInfo(include) {
10965
+ return this.tenantService.getRemoteTenantPublic(include);
9966
10966
  }
9967
10967
  /**
9968
10968
  * Get full tenant information including sensitive config (Admin JWT required)
@@ -9970,10 +10970,11 @@ class TenantManager {
9970
10970
  * Returns additional fields like allowedTokenIssuers that are only
9971
10971
  * available to authenticated tenant admins.
9972
10972
  *
10973
+ * @param include - Pass `['translations']` to also receive the raw per-locale translations (admin editing)
9973
10974
  * @returns Promise resolving to full tenant data
9974
10975
  */
9975
- async getTenantInfo() {
9976
- return this.tenantService.getRemoteTenant();
10976
+ async getTenantInfo(include) {
10977
+ return this.tenantService.getRemoteTenant(include);
9977
10978
  }
9978
10979
  /**
9979
10980
  * Get tenant login token
@@ -10072,99 +11073,21 @@ class TenantManager {
10072
11073
  clearCache() {
10073
11074
  this.cache.clear();
10074
11075
  }
10075
- // ==========================================
10076
- // IPFS OPERATIONS (Lightweight, no Web3 deps)
10077
- // ==========================================
10078
- /**
10079
- * Get the IPFS gateway domain from tenant config (cached)
10080
- *
10081
- * @returns Promise resolving to IPFS gateway domain (e.g., 'gateway.pinata.cloud')
10082
- * @example
10083
- * ```typescript
10084
- * const gateway = await sdk.tenants.getIpfsGatewayDomain();
10085
- * // Returns 'gateway.pinata.cloud' or tenant-specific gateway
10086
- * ```
10087
- */
11076
+ /** @internal Single IpfsManager per SDK; sdk.ipfs and the web3 adapter reuse it. */
11077
+ get ipfs() {
11078
+ return this._ipfs ?? (this._ipfs = new IpfsManager(this, () => this.apiClient.getLocale()));
11079
+ }
11080
+ /** @deprecated Use `sdk.ipfs.getGatewayDomain()` */
10088
11081
  async getIpfsGatewayDomain() {
10089
- return this.cache.getOrSet('ipfsGateway', async () => {
10090
- const config = await this.getClientConfig();
10091
- return config.ipfsGatewayDomain || 'gateway.pinata.cloud';
10092
- }, CacheTTL.LONG);
11082
+ return this.ipfs.getGatewayDomain();
10093
11083
  }
10094
- /**
10095
- * Resolve an IPFS URL to an HTTP gateway URL
10096
- *
10097
- * @param url - The URL to resolve (ipfs://, ar://, or HTTP URL)
10098
- * @returns Promise resolving to HTTP gateway URL
10099
- *
10100
- * @example
10101
- * ```typescript
10102
- * const httpUrl = await sdk.tenants.resolveIpfsUrl('ipfs://QmXxx...');
10103
- * // Returns 'https://gateway.pinata.cloud/ipfs/QmXxx...'
10104
- * ```
10105
- */
11084
+ /** @deprecated Use `sdk.ipfs.resolveUrl()` */
10106
11085
  async resolveIpfsUrl(url) {
10107
- if (!url)
10108
- return '';
10109
- // Already an HTTP URL
10110
- if (url.startsWith('http://') || url.startsWith('https://')) {
10111
- return url;
10112
- }
10113
- const gateway = await this.getIpfsGatewayDomain();
10114
- // Handle ipfs:// protocol
10115
- if (url.startsWith('ipfs://')) {
10116
- const cid = url.replace('ipfs://', '');
10117
- return `https://${gateway}/ipfs/${cid}`;
10118
- }
10119
- // Handle ar:// (Arweave) protocol
10120
- if (url.startsWith('ar://')) {
10121
- const txId = url.replace('ar://', '');
10122
- return `https://arweave.net/${txId}`;
10123
- }
10124
- return url;
11086
+ return this.ipfs.resolveUrl(url);
10125
11087
  }
10126
- /**
10127
- * Fetch and process token metadata from IPFS/HTTP URI
10128
- *
10129
- * @param tokenUri - The token URI (ipfs://, ar://, or HTTP)
10130
- * @returns Promise resolving to processed metadata or null on error
10131
- *
10132
- * @example
10133
- * ```typescript
10134
- * const metadata = await sdk.tenants.fetchAndProcessMetadata('ipfs://QmXxx.../metadata.json');
10135
- * if (metadata) {
10136
- * console.log(metadata.name, metadata.imageUrl);
10137
- * }
10138
- * ```
10139
- */
11088
+ /** @deprecated Use `sdk.ipfs.fetchMetadata()` */
10140
11089
  async fetchAndProcessMetadata(tokenUri) {
10141
- try {
10142
- const resolvedUri = await this.resolveIpfsUrl(tokenUri);
10143
- const response = await fetch(resolvedUri);
10144
- if (!response.ok) {
10145
- console.warn(`Failed to fetch metadata from ${resolvedUri}: ${response.status}`);
10146
- return null;
10147
- }
10148
- const rawMetadata = await response.json();
10149
- // Normalize the metadata - resolve image URLs
10150
- const imageUrl = rawMetadata.image || rawMetadata.image_url || rawMetadata.imageUrl || '';
10151
- const resolvedImageUrl = await this.resolveIpfsUrl(imageUrl);
10152
- const animationUrl = rawMetadata.animation_url || rawMetadata.animationUrl || '';
10153
- const resolvedAnimationUrl = animationUrl ? await this.resolveIpfsUrl(animationUrl) : undefined;
10154
- return {
10155
- name: rawMetadata.name || 'Untitled',
10156
- description: rawMetadata.description || '',
10157
- imageUrl: resolvedImageUrl,
10158
- animationUrl: resolvedAnimationUrl,
10159
- externalUrl: rawMetadata.external_url || rawMetadata.externalUrl,
10160
- attributes: rawMetadata.attributes,
10161
- ...rawMetadata // Include any additional properties
10162
- };
10163
- }
10164
- catch (error) {
10165
- console.warn(`Error fetching metadata from ${tokenUri}:`, error);
10166
- return null;
10167
- }
11090
+ return this.ipfs.fetchMetadata(tokenUri);
10168
11091
  }
10169
11092
  }
10170
11093
 
@@ -10639,6 +11562,7 @@ class TriggerSourceApi {
10639
11562
  */
10640
11563
  async getTriggerSources(options) {
10641
11564
  const params = buildPaginationParams(options);
11565
+ setIncludeParam(params, options?.include);
10642
11566
  if (options?.type)
10643
11567
  params.set('type', options.type);
10644
11568
  if (options?.businessId)
@@ -10669,8 +11593,11 @@ class TriggerSourceApi {
10669
11593
  * @param triggerSourceId - UUID of the trigger source
10670
11594
  * @returns Trigger source details
10671
11595
  */
10672
- async getTriggerSourceById(triggerSourceId) {
10673
- return this.apiClient.get(`/trigger-sources/${triggerSourceId}`);
11596
+ async getTriggerSourceById(triggerSourceId, include) {
11597
+ const params = new URLSearchParams();
11598
+ setIncludeParam(params, include);
11599
+ const query = params.toString();
11600
+ return this.apiClient.get(`/trigger-sources/${triggerSourceId}${query ? `?${query}` : ''}`);
10674
11601
  }
10675
11602
  /**
10676
11603
  * ADMIN: Create a new trigger source
@@ -10733,8 +11660,8 @@ class TriggerSourceService {
10733
11660
  /**
10734
11661
  * Get trigger source by ID
10735
11662
  */
10736
- async getTriggerSourceById(triggerSourceId) {
10737
- return this.triggerSourceApi.getTriggerSourceById(triggerSourceId);
11663
+ async getTriggerSourceById(triggerSourceId, include) {
11664
+ return this.triggerSourceApi.getTriggerSourceById(triggerSourceId, include);
10738
11665
  }
10739
11666
  /**
10740
11667
  * ADMIN: Create a new trigger source
@@ -11212,9 +12139,14 @@ class AuthService {
11212
12139
  if (this.activeRefreshPromise) {
11213
12140
  return;
11214
12141
  }
11215
- // If auth failure is already in progress, wait for it
12142
+ // If auth failure is already in progress, return immediately.
12143
+ // DO NOT await activeAuthFailurePromise here - it causes a deadlock when
12144
+ // handleAuthFailure() is called from within the recovery attempt itself.
12145
+ // Example: recovery calls loginUser() → POST /auth/token → 401 → handleAuthFailure()
12146
+ // Awaiting the outer promise from inside it creates a circular wait that never resolves.
12147
+ // The outer failure handler will complete and emit AUTH_FAILED regardless.
11216
12148
  if (this.activeAuthFailurePromise) {
11217
- return await this.activeAuthFailurePromise;
12149
+ return;
11218
12150
  }
11219
12151
  // Create the failure promise to deduplicate concurrent calls
11220
12152
  this.activeAuthFailurePromise = this.performAuthFailureWithRecovery();
@@ -11399,6 +12331,9 @@ class TokenRefreshManager {
11399
12331
  // Retry configuration for transient failures
11400
12332
  this.MAX_RETRY_ATTEMPTS = 3;
11401
12333
  this.RETRY_DELAY_MS = 1000;
12334
+ // After a transient refresh failure, skip further attempts for a while so every request doesn't wait through the retries again
12335
+ this.lastRetryableFailureTime = 0;
12336
+ this.RETRYABLE_FAILURE_COOLDOWN_MS = 15000;
11402
12337
  // Active refresh promise for deduplication
11403
12338
  this.activeRefreshPromise = null;
11404
12339
  }
@@ -11412,33 +12347,37 @@ class TokenRefreshManager {
11412
12347
  if (now - this.lastValidationTime < this.validationCacheDurationMs) {
11413
12348
  return;
11414
12349
  }
12350
+ if (now - this.lastRetryableFailureTime < this.RETRYABLE_FAILURE_COOLDOWN_MS) {
12351
+ return;
12352
+ }
11415
12353
  try {
11416
12354
  const token = await this.authProvider.getToken();
11417
12355
  if (!token) {
11418
- return;
11419
- }
11420
- // Calculate dynamic refresh margin based on token lifetime
11421
- const marginSeconds = this.calculateRefreshMargin(token);
11422
- if (isTokenExpired(token, marginSeconds)) {
11423
- const result = await this.attemptRefreshWithRetry();
11424
- if (!result.success) {
11425
- if (result.retryable) {
11426
- // Transient error - don't logout, let next request retry
11427
- console.warn('[TokenRefreshManager] Refresh failed with retryable error, will retry on next request');
11428
- return;
11429
- }
11430
- // Definitive auth failure - logout required
11431
- await this.authService.handleAuthFailure();
11432
- }
11433
- else {
11434
- // Refresh successful
11435
- this.lastValidationTime = now;
12356
+ // No access token but a stored refresh token: refresh now instead of sending an anonymous request
12357
+ const refreshToken = await this.authProvider.getRefreshToken?.();
12358
+ if (!refreshToken) {
12359
+ return;
11436
12360
  }
11437
12361
  }
11438
- else {
12362
+ else if (!isTokenExpired(token, this.calculateRefreshMargin(token))) {
11439
12363
  // Token is valid
11440
12364
  this.lastValidationTime = now;
12365
+ return;
12366
+ }
12367
+ const result = await this.attemptRefreshWithRetry();
12368
+ if (result.success) {
12369
+ this.lastValidationTime = now;
12370
+ this.lastRetryableFailureTime = 0;
12371
+ return;
12372
+ }
12373
+ if (result.retryable) {
12374
+ // Transient error - don't logout, let a later request retry after the cooldown
12375
+ console.warn('[TokenRefreshManager] Refresh failed with retryable error, will retry after cooldown');
12376
+ this.lastRetryableFailureTime = Date.now();
12377
+ return;
11441
12378
  }
12379
+ // Definitive auth failure - logout required
12380
+ await this.authService.handleAuthFailure();
11442
12381
  }
11443
12382
  catch (error) {
11444
12383
  // Unexpected error - log but don't logout (conservative approach)
@@ -11508,13 +12447,24 @@ class TokenRefreshManager {
11508
12447
  if (this.activeRefreshPromise) {
11509
12448
  return this.activeRefreshPromise;
11510
12449
  }
12450
+ if (Date.now() - this.lastRetryableFailureTime < this.RETRYABLE_FAILURE_COOLDOWN_MS) {
12451
+ return { success: false, retryable: true };
12452
+ }
11511
12453
  this.activeRefreshPromise = (async () => {
11512
12454
  const result = await this.attemptRefreshWithRetry();
11513
- if (result.success)
12455
+ if (result.success) {
12456
+ this.lastRetryableFailureTime = 0;
11514
12457
  return result;
12458
+ }
11515
12459
  // Internal failed - try provider token recovery
11516
12460
  const recovered = await this.authService.attemptProviderTokenRecovery();
11517
- return recovered ? { success: true, retryable: false } : result;
12461
+ if (recovered) {
12462
+ this.lastRetryableFailureTime = 0;
12463
+ return { success: true, retryable: false };
12464
+ }
12465
+ if (result.retryable)
12466
+ this.lastRetryableFailureTime = Date.now();
12467
+ return result;
11518
12468
  })();
11519
12469
  try {
11520
12470
  return await this.activeRefreshPromise;
@@ -11602,7 +12552,9 @@ class AuthTokenManager {
11602
12552
  const val = await this.storage.get(AUTH_STORAGE_KEYS.ACCESS_TOKEN);
11603
12553
  // Ensure we return string for tokens
11604
12554
  const token = typeof val === 'string' ? val : null;
11605
- this.cache.accessToken = token;
12555
+ // Never cache a miss: a transient storage failure (e.g. locked iOS Keychain) would otherwise log the user out for the whole session
12556
+ if (token)
12557
+ this.cache.accessToken = token;
11606
12558
  return token;
11607
12559
  }
11608
12560
  async setAccessToken(token) {
@@ -11616,7 +12568,8 @@ class AuthTokenManager {
11616
12568
  }
11617
12569
  const val = await this.storage.get(AUTH_STORAGE_KEYS.REFRESH_TOKEN);
11618
12570
  const token = typeof val === 'string' ? val : null;
11619
- this.cache.refreshToken = token;
12571
+ if (token)
12572
+ this.cache.refreshToken = token;
11620
12573
  return token;
11621
12574
  }
11622
12575
  async setRefreshToken(token) {
@@ -11630,7 +12583,8 @@ class AuthTokenManager {
11630
12583
  }
11631
12584
  const val = await this.storage.get(AUTH_STORAGE_KEYS.PROVIDER_TOKEN);
11632
12585
  const token = typeof val === 'string' ? val : null;
11633
- this.cache.providerToken = token;
12586
+ if (token)
12587
+ this.cache.providerToken = token;
11634
12588
  return token;
11635
12589
  }
11636
12590
  async setProviderToken(token) {
@@ -11644,7 +12598,8 @@ class AuthTokenManager {
11644
12598
  }
11645
12599
  const authType = await this.storage.get(AUTH_STORAGE_KEYS.AUTH_TYPE);
11646
12600
  const type = typeof authType === 'string' ? authType : null;
11647
- this.cache.authType = type;
12601
+ if (type)
12602
+ this.cache.authType = type;
11648
12603
  return type;
11649
12604
  }
11650
12605
  async setAuthType(authType) {
@@ -12327,7 +13282,7 @@ class DefaultAuthProvider {
12327
13282
  /** SDK package name */
12328
13283
  const SDK_NAME = "@explorins/pers-sdk";
12329
13284
  /** SDK version - injected from package.json at build time */
12330
- const SDK_VERSION = "2.3.12";
13285
+ const SDK_VERSION = "2.3.38";
12331
13286
  /** Full SDK identifier for headers */
12332
13287
  const SDK_USER_AGENT = `${SDK_NAME}/${SDK_VERSION}`;
12333
13288
 
@@ -12460,6 +13415,56 @@ class TrackingManager {
12460
13415
  }
12461
13416
  }
12462
13417
 
13418
+ /**
13419
+ * Locale Manager - Content language for localized API responses
13420
+ *
13421
+ * Opt-in: no locale means no `x-locale` header and base-language strings.
13422
+ *
13423
+ * @module @explorins/pers-sdk/core/locale
13424
+ */
13425
+ /** Device locale via `Intl`, or null where it is unavailable. */
13426
+ function detectDeviceLocale() {
13427
+ try {
13428
+ return normalizeLocale(Intl.DateTimeFormat().resolvedOptions().locale);
13429
+ }
13430
+ catch {
13431
+ return null;
13432
+ }
13433
+ }
13434
+ /**
13435
+ * Holds the configured locale and resolves it to a normalized tag.
13436
+ */
13437
+ class LocaleManager {
13438
+ constructor(locale) {
13439
+ this.listeners = new Set();
13440
+ this.setting = locale;
13441
+ }
13442
+ /** Set the locale (`'auto'`, a BCP 47 tag, or `null` to disable). Listeners run when the effective locale changes. */
13443
+ setLocale(locale) {
13444
+ const before = this.getLocale();
13445
+ this.setting = locale;
13446
+ const after = this.getLocale();
13447
+ if (after !== before)
13448
+ this.listeners.forEach(listener => listener(after));
13449
+ }
13450
+ /** Subscribe to effective-locale changes (e.g. to drop locale-dependent caches). Returns an unsubscribe function. */
13451
+ onChange(listener) {
13452
+ this.listeners.add(listener);
13453
+ return () => this.listeners.delete(listener);
13454
+ }
13455
+ /** Normalized locale in effect, or null when disabled or invalid. */
13456
+ getLocale() {
13457
+ if (!this.setting)
13458
+ return null;
13459
+ return this.setting === 'auto' ? detectDeviceLocale() : normalizeLocale(this.setting);
13460
+ }
13461
+ /** @internal - Used by PersApiClient */
13462
+ buildHeaders() {
13463
+ const locale = this.getLocale();
13464
+ return locale ? { [LOCALE_HEADER]: locale } : {};
13465
+ }
13466
+ }
13467
+
12463
13468
  // packages/pers-sdk/src/core/pers-api-client.ts
12464
13469
  /**
12465
13470
  * PERS API Client - Platform-agnostic HTTP client with authentication
@@ -12513,6 +13518,7 @@ class PersApiClient {
12513
13518
  dataSource: this.mergedConfig.dataSource,
12514
13519
  platform: this.mergedConfig.platform,
12515
13520
  });
13521
+ this.localeManager = new LocaleManager(this.mergedConfig.locale);
12516
13522
  if (this.mergedConfig.authProvider) {
12517
13523
  this.initializationPromise = this.initialize();
12518
13524
  }
@@ -12570,7 +13576,8 @@ class PersApiClient {
12570
13576
  await this.ensureInitialized();
12571
13577
  }
12572
13578
  const { retryCount = 0, responseType = 'json', bypassAuth = false } = options || {};
12573
- const url = `${this.apiRoot}${endpoint}`;
13579
+ const cacheBust = options?.cacheBust ? `${endpoint.includes('?') ? '&' : '?'}_=${Date.now()}` : '';
13580
+ const url = `${this.apiRoot}${endpoint}${cacheBust}`;
12574
13581
  // TOKEN VALIDATION: Ensure token is valid before request
12575
13582
  // This blocks the request until token validation completes - prevents 401s from expired tokens
12576
13583
  if (!bypassAuth && this.mergedConfig.authProvider && retryCount === 0) {
@@ -12599,6 +13606,9 @@ class PersApiClient {
12599
13606
  case 'PUT':
12600
13607
  result = await this.httpClient.put(url, body, requestOptions);
12601
13608
  break;
13609
+ case 'PATCH':
13610
+ result = await this.httpClient.patch(url, body, requestOptions);
13611
+ break;
12602
13612
  case 'DELETE':
12603
13613
  result = await this.httpClient.delete(url, requestOptions);
12604
13614
  break;
@@ -12610,11 +13620,15 @@ class PersApiClient {
12610
13620
  catch (error) {
12611
13621
  const status = ErrorUtils.getStatus(error);
12612
13622
  const errorMessage = ErrorUtils.getMessage(error);
13623
+ // A bare 304 means the platform HTTP cache did not merge it into a 200: refetch once under a unique URL
13624
+ if (status === 304 && method === 'GET' && !options?.cacheBust) {
13625
+ return await this.request(method, endpoint, body, { ...options, cacheBust: true });
13626
+ }
12613
13627
  // Handle 401 Unauthorized errors
12614
13628
  if (status === 401) {
12615
13629
  const backendError = ErrorUtils.extractBackendErrorDetails(error);
12616
13630
  // ===========================================
12617
- // FATAL AUTH ERRORS → Immediate logout
13631
+ // FATAL AUTH ERRORS → Immediate logout
12618
13632
  // ===========================================
12619
13633
  // Fatal codes: REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, INVALID_REFRESH_TOKEN, etc.
12620
13634
  if (this.authService.isFatalAuthError(backendError.code)) {
@@ -12624,7 +13638,7 @@ class PersApiClient {
12624
13638
  throw new AuthenticationError(errorMessage, endpoint, method, backendError);
12625
13639
  }
12626
13640
  // ===========================================
12627
- // REFRESH REQUEST FAILED → Logout (prevents infinite loop)
13641
+ // REFRESH REQUEST FAILED → Logout (prevents infinite loop)
12628
13642
  // ===========================================
12629
13643
  if (options?.isRefreshRequest) {
12630
13644
  console.warn('[PersApiClient] Refresh request itself failed with 401');
@@ -12633,12 +13647,12 @@ class PersApiClient {
12633
13647
  throw new AuthenticationError(errorMessage, endpoint, method, backendError);
12634
13648
  }
12635
13649
  // ===========================================
12636
- // RETRYABLE 401 (TOKEN_EXPIRED) → Try refresh, then retry
13650
+ // RETRYABLE 401 (TOKEN_EXPIRED) → Try refresh, then retry
12637
13651
  // ===========================================
12638
13652
  if (retryCount === 0 && this.mergedConfig.authProvider) {
12639
13653
  const refreshResult = await this.refreshManager.attemptInternalRefresh();
12640
13654
  if (refreshResult.success) {
12641
- // Refresh succeeded → retry request with new token
13655
+ // Refresh succeeded → retry request with new token
12642
13656
  this._events?.emitSuccess({
12643
13657
  type: 'token_refreshed',
12644
13658
  domain: 'authentication',
@@ -12648,19 +13662,19 @@ class PersApiClient {
12648
13662
  }
12649
13663
  // Refresh failed - check if it's a definitive failure
12650
13664
  if (!refreshResult.retryable) {
12651
- // Non-retryable failure (e.g., refresh token expired) → logout
13665
+ // Non-retryable failure (e.g., refresh token expired) → logout
12652
13666
  console.warn('[PersApiClient] Refresh failed with non-retryable error, logging out');
12653
13667
  await this.authService.handleAuthFailure();
12654
13668
  }
12655
13669
  else {
12656
- // Retryable failure (network error, etc.) → DON'T logout, let app retry later
13670
+ // Retryable failure (network error, etc.) → DON'T logout, let app retry later
12657
13671
  console.warn('[PersApiClient] Refresh failed with retryable error, NOT logging out');
12658
13672
  }
12659
13673
  this.emitErrorEvent(backendError, errorMessage, endpoint, method, status, error);
12660
13674
  throw new AuthenticationError(errorMessage, endpoint, method, backendError);
12661
13675
  }
12662
13676
  // ===========================================
12663
- // RETRY FAILED (retryCount > 0) → Something wrong, but DON'T logout
13677
+ // RETRY FAILED (retryCount > 0) → Something wrong, but DON'T logout
12664
13678
  // ===========================================
12665
13679
  // We already refreshed successfully, but retry still got 401
12666
13680
  // This is unexpected - don't logout, let app investigate
@@ -12737,6 +13751,9 @@ class PersApiClient {
12737
13751
  async put(endpoint, body) {
12738
13752
  return this.request('PUT', endpoint, body);
12739
13753
  }
13754
+ async patch(endpoint, body) {
13755
+ return this.request('PATCH', endpoint, body);
13756
+ }
12740
13757
  /**
12741
13758
  * Generic DELETE request
12742
13759
  */
@@ -12799,6 +13816,8 @@ class PersApiClient {
12799
13816
  }
12800
13817
  // Add tracking headers (data source + platform)
12801
13818
  Object.assign(headers, this.tracking.buildHeaders());
13819
+ // Opt-in content locale
13820
+ Object.assign(headers, this.localeManager.buildHeaders());
12802
13821
  return headers;
12803
13822
  }
12804
13823
  // ==========================================
@@ -12822,6 +13841,15 @@ class PersApiClient {
12822
13841
  getOriginalConfig() {
12823
13842
  return this.config;
12824
13843
  }
13844
+ /**
13845
+ * Get the resolved API root URL (e.g. `https://api.pers.ninja/v2`)
13846
+ *
13847
+ * Useful for building fully-qualified URLs outside of `apiClient.get/post/...`,
13848
+ * such as public redirect/QR target links.
13849
+ */
13850
+ getApiRoot() {
13851
+ return this.apiRoot;
13852
+ }
12825
13853
  // ==========================================
12826
13854
  // DATA SOURCE TRACKING
12827
13855
  // ==========================================
@@ -12849,6 +13877,21 @@ class PersApiClient {
12849
13877
  validateTracking() {
12850
13878
  return this.tracking.validate();
12851
13879
  }
13880
+ // ==========================================
13881
+ // CONTENT LOCALE
13882
+ // ==========================================
13883
+ /** Set the content locale (`'auto'`, a BCP 47 tag, or `null` to disable) */
13884
+ setLocale(locale) {
13885
+ this.localeManager.setLocale(locale);
13886
+ }
13887
+ /** Normalized content locale in effect, or null when disabled */
13888
+ getLocale() {
13889
+ return this.localeManager.getLocale();
13890
+ }
13891
+ /** Subscribe to effective-locale changes; returns an unsubscribe function */
13892
+ onLocaleChange(listener) {
13893
+ return this.localeManager.onChange(listener);
13894
+ }
12852
13895
  }
12853
13896
 
12854
13897
  /**
@@ -13661,6 +14704,9 @@ class AuthManager {
13661
14704
  * key: 'city',
13662
14705
  * value: 'New York'
13663
14706
  * });
14707
+ *
14708
+ * // Get a single user's public profile by ID (no auth required)
14709
+ * const oneProfile = await sdk.users.getPublicProfile('user-123');
13664
14710
  * ```
13665
14711
  *
13666
14712
  * @example Administrative Operations
@@ -13715,7 +14761,14 @@ class UserManager {
13715
14761
  * ```
13716
14762
  */
13717
14763
  async getCurrentUser(options) {
13718
- return this.userService.getRemoteUser(options);
14764
+ const user = await this.userService.getRemoteUser(options);
14765
+ this.events?.emitSuccess({
14766
+ domain: 'user',
14767
+ type: 'user_loaded',
14768
+ userMessage: 'User loaded',
14769
+ details: { user }
14770
+ });
14771
+ return user;
13719
14772
  }
13720
14773
  /**
13721
14774
  * Update current user profile
@@ -13748,7 +14801,7 @@ class UserManager {
13748
14801
  const result = await this.userService.updateRemoteUser(userData);
13749
14802
  this.events?.emitSuccess({
13750
14803
  domain: 'user',
13751
- type: 'profile_updated',
14804
+ type: 'user_updated',
13752
14805
  userMessage: 'Profile updated successfully',
13753
14806
  details: { userId: result.id }
13754
14807
  });
@@ -13830,6 +14883,26 @@ class UserManager {
13830
14883
  async getAllUsersPublic(filter, options) {
13831
14884
  return this.userService.getAllUsersPublicProfiles(filter || null, options);
13832
14885
  }
14886
+ /**
14887
+ * Get a single user's public profile by ID
14888
+ *
14889
+ * Retrieves the publicly viewable profile for one user, without requiring
14890
+ * authentication. Useful for public profile pages/widgets where the viewer
14891
+ * may or may not be logged in as that user.
14892
+ *
14893
+ * @param id - The user's unique ID
14894
+ * @returns Promise resolving to the user's public profile
14895
+ * @throws {PersApiError} When no user exists with that ID
14896
+ *
14897
+ * @example Public Profile Page
14898
+ * ```typescript
14899
+ * const profile = await sdk.users.getPublicProfile(routeId);
14900
+ * console.log(profile.publicProfile);
14901
+ * ```
14902
+ */
14903
+ async getPublicProfile(id) {
14904
+ return this.userService.getPublicProfileById(id);
14905
+ }
13833
14906
  /**
13834
14907
  * Admin: Get all users
13835
14908
  *
@@ -14138,6 +15211,12 @@ class UserStatusManager {
14138
15211
  async getUserStatusTypes(options) {
14139
15212
  return this.userStatusSDK.getRemoteUserStatusTypes(options);
14140
15213
  }
15214
+ /**
15215
+ * Get one user status type by ID; `include: ['translations']` adds the raw per-locale map for editing
15216
+ */
15217
+ async getUserStatusTypeById(id, include) {
15218
+ return this.userStatusSDK.getRemoteUserStatusTypeById(id, include);
15219
+ }
14141
15220
  /**
14142
15221
  * Get earned user status for authenticated user
14143
15222
  *
@@ -14434,6 +15513,19 @@ class TokenManager {
14434
15513
  async getTokenMetadata(options) {
14435
15514
  return this.tokenService.getTokenMetadata(options);
14436
15515
  }
15516
+ /**
15517
+ * Get one reward or stamp metadata by ID (admin/business)
15518
+ *
15519
+ * @param include - Same relations as the list; `translations` adds the raw per-locale map for editing
15520
+ *
15521
+ * @example
15522
+ * ```typescript
15523
+ * const metadata = await sdk.tokens.getTokenMetadataById(id, ['translations']);
15524
+ * ```
15525
+ */
15526
+ async getTokenMetadataById(metadataId, include) {
15527
+ return this.tokenService.getTokenMetadataById(metadataId, include);
15528
+ }
14437
15529
  /**
14438
15530
  * Get rewards (ERC1155 token metadata) with filtering, pagination, and include relations
14439
15531
  *
@@ -14634,7 +15726,7 @@ class TokenManager {
14634
15726
  return this.tokenService;
14635
15727
  }
14636
15728
  // ==========================================
14637
- // 📊 CSV EXPORT
15729
+ // 📊 CSV EXPORT
14638
15730
  // ==========================================
14639
15731
  /**
14640
15732
  * Admin: Export token metadata to CSV
@@ -14668,6 +15760,29 @@ class TokenManager {
14668
15760
  async exportMetadataCSV(options) {
14669
15761
  return this.tokenService.exportMetadataCSV(options);
14670
15762
  }
15763
+ // ==========================================
15764
+ // ✅ APPROVAL WORKFLOW
15765
+ // ==========================================
15766
+ /**
15767
+ * Admin: Approve token metadata
15768
+ *
15769
+ * Approves token metadata that is pending approval. This operation is only available
15770
+ * when the tenant has token metadata approval enabled in their approval settings.
15771
+ *
15772
+ * @param tokenMetadataId - ID of the token metadata to approve
15773
+ * @returns Promise resolving to updated token metadata with approval metadata
15774
+ * @throws {PersApiError} When not authenticated as tenant admin or token metadata not found
15775
+ *
15776
+ * @example
15777
+ * ` ypescript
15778
+ * const approved = await sdk.tokens.approveTokenMetadata('metadata-123');
15779
+ * console.log('Token metadata approved at:', approved.approval?.approvedAt);
15780
+ * `
15781
+ */
15782
+ async setTokenMetadataApproval(tokenMetadataId, status, reason) {
15783
+ const tokenApi = new TokenApi(this.apiClient);
15784
+ return tokenApi.setTokenMetadataApproval(tokenMetadataId, status, reason);
15785
+ }
14671
15786
  }
14672
15787
 
14673
15788
  /**
@@ -14763,6 +15878,20 @@ class BusinessManager {
14763
15878
  async getActiveBusinesses(options) {
14764
15879
  return this.businessService.getActiveBusinesses(options);
14765
15880
  }
15881
+ /**
15882
+ * Get the distinct list of cities that have at least one business, for populating
15883
+ * city filter options before calling `getBusinesses({ city })`.
15884
+ *
15885
+ * @example
15886
+ * ```typescript
15887
+ * const cities = await sdk.business.getBusinessCities();
15888
+ * // Render as filter options, then:
15889
+ * const dohaBusinesses = await sdk.business.getBusinesses({ city: cities[0] });
15890
+ * ```
15891
+ */
15892
+ async getBusinessCities(options) {
15893
+ return this.businessService.getBusinessCities(options);
15894
+ }
14766
15895
  /**
14767
15896
  * Get business by ID
14768
15897
  *
@@ -14790,8 +15919,8 @@ class BusinessManager {
14790
15919
  * }
14791
15920
  * ```
14792
15921
  */
14793
- async getBusinessById(businessId) {
14794
- return this.businessService.getBusinessById(businessId);
15922
+ async getBusinessById(businessId, include) {
15923
+ return this.businessService.getBusinessById(businessId, include);
14795
15924
  }
14796
15925
  /**
14797
15926
  * Get business by account address
@@ -14845,7 +15974,13 @@ class BusinessManager {
14845
15974
  * ```
14846
15975
  */
14847
15976
  async getBusinessTypes(options) {
14848
- return this.businessService.getBusinessTypes();
15977
+ return this.businessService.getBusinessTypes(options);
15978
+ }
15979
+ /**
15980
+ * Get one business type by ID; `include: ['translations']` adds the raw per-locale map for editing
15981
+ */
15982
+ async getBusinessTypeById(id, include) {
15983
+ return this.businessService.getBusinessTypeById(id, include);
14849
15984
  }
14850
15985
  /**
14851
15986
  * Admin: Create a new business type
@@ -15004,8 +16139,8 @@ class BusinessManager {
15004
16139
  * });
15005
16140
  * ```
15006
16141
  */
15007
- async createBusiness(displayName) {
15008
- const result = await this.businessService.createBusinessByDisplayName(displayName);
16142
+ async createBusiness(dto) {
16143
+ const result = await this.businessService.createBusiness(dto);
15009
16144
  this.events?.emitSuccess({
15010
16145
  domain: 'business',
15011
16146
  type: 'business_created',
@@ -15310,7 +16445,7 @@ class BusinessManager {
15310
16445
  return this.membershipService;
15311
16446
  }
15312
16447
  // ==========================================
15313
- // 📊 CSV EXPORT
16448
+ // 📊 CSV EXPORT
15314
16449
  // ==========================================
15315
16450
  /**
15316
16451
  * Admin: Export businesses to CSV
@@ -15341,6 +16476,33 @@ class BusinessManager {
15341
16476
  * a.click();
15342
16477
  * ```
15343
16478
  */
16479
+ // ==========================================
16480
+ // ✅ APPROVAL WORKFLOW
16481
+ // ==========================================
16482
+ /**
16483
+ * Admin: Approve a business
16484
+ *
16485
+ * Approves a business that is pending approval. This operation is only available
16486
+ * when the tenant has business approval enabled in their approval settings.
16487
+ *
16488
+ * @param businessId - ID of the business to approve
16489
+ * @returns Promise resolving to updated business with approval metadata
16490
+ * @throws {PersApiError} When not authenticated as tenant admin or business not found
16491
+ *
16492
+ * @example
16493
+ * ` ypescript
16494
+ * // Approve pending business
16495
+ * const approvedBusiness = await sdk.businesses.approveBusiness('business-123');
16496
+ *
16497
+ * console.log('Business approved!');
16498
+ * console.log('Approved by:', approvedBusiness.approval?.approvedBy);
16499
+ * console.log('Approved at:', approvedBusiness.approval?.approvedAt);
16500
+ * console.log('Status:', approvedBusiness.approval?.status); // 'approved'
16501
+ * `
16502
+ */
16503
+ async setBusinessApproval(businessId, status, reason) {
16504
+ return this.businessApi.setBusinessApproval(businessId, status, reason);
16505
+ }
15344
16506
  async exportCSV(options) {
15345
16507
  return this.businessApi.exportCSV(options);
15346
16508
  }
@@ -15557,7 +16719,7 @@ class CampaignManager {
15557
16719
  * if (claim.rewards?.length) {
15558
16720
  * console.log(` Rewards:`);
15559
16721
  * claim.rewards.forEach(reward => {
15560
- * console.log(` • ${reward.amount} ${reward.token.symbol}`);
16722
+ * console.log(` • ${reward.amount} ${reward.token.symbol}`);
15561
16723
  * });
15562
16724
  * }
15563
16725
  * });
@@ -16368,6 +17530,29 @@ class CampaignManager {
16368
17530
  getCampaignService() {
16369
17531
  return this.campaignService;
16370
17532
  }
17533
+ // ==========================================
17534
+ // ✅ APPROVAL WORKFLOW
17535
+ // ==========================================
17536
+ /**
17537
+ * Admin: Approve a campaign
17538
+ *
17539
+ * Approves a campaign that is pending approval. This operation is only available
17540
+ * when the tenant has campaign approval enabled in their approval settings.
17541
+ *
17542
+ * @param campaignId - ID of the campaign to approve
17543
+ * @returns Promise resolving to updated campaign with approval metadata
17544
+ * @throws {PersApiError} When not authenticated as tenant admin or campaign not found
17545
+ *
17546
+ * @example
17547
+ * ` ypescript
17548
+ * const approvedCampaign = await sdk.campaigns.approveCampaign('campaign-123');
17549
+ * console.log('Campaign approved at:', approvedCampaign.approval?.approvedAt);
17550
+ * `
17551
+ */
17552
+ async setCampaignApproval(campaignId, status, reason) {
17553
+ const campaignApi = new CampaignApi(this.apiClient);
17554
+ return campaignApi.setCampaignApproval(campaignId, status, reason);
17555
+ }
16371
17556
  }
16372
17557
 
16373
17558
  /**
@@ -16633,6 +17818,12 @@ class RedemptionManager {
16633
17818
  async getRedemptionTypes(options) {
16634
17819
  return this.redemptionService.getRedemptionTypes(options);
16635
17820
  }
17821
+ /**
17822
+ * Get one redemption type by ID; `include: ['translations']` adds the raw per-locale map for editing
17823
+ */
17824
+ async getRedemptionTypeById(id, include) {
17825
+ return this.redemptionService.getRedemptionTypeById(id, include);
17826
+ }
16636
17827
  /**
16637
17828
  * Redeem a redemption offer
16638
17829
  *
@@ -16645,8 +17836,9 @@ class RedemptionManager {
16645
17836
  * issues ERC721 tokens. Context can include validity dates, AI prompt placeholders,
16646
17837
  * and custom metadata.
16647
17838
  *
16648
- * @param redemptionId - ID of the redemption offer to redeem
16649
- * @param context - Optional dynamic context for NFT personalization (validity dates, AI prompts)
17839
+ * @param request - Redemption ID string, or a full `RedemptionRedeemRequestDTO` object
17840
+ * (supports `redemptionId`, `businessId`, and `context` fields)
17841
+ * @param context - Optional dynamic context when passing a plain `redemptionId` string
16650
17842
  * @returns Promise resolving to redemption result with confirmation details
16651
17843
  * @throws {PersApiError} When redemption is not available or user lacks required tokens
16652
17844
  *
@@ -16741,16 +17933,16 @@ class RedemptionManager {
16741
17933
  * }
16742
17934
  * ```
16743
17935
  */
16744
- async redeem(redemptionId, context) {
16745
- const request = context
16746
- ? { redemptionId, context }
16747
- : { redemptionId };
16748
- const result = await this.redemptionService.redeemRedemption(request);
17936
+ async redeem(request, context) {
17937
+ const requestBody = typeof request === 'string'
17938
+ ? { redemptionId: request, ...(context ? { context } : {}) }
17939
+ : request;
17940
+ const result = await this.redemptionService.redeemRedemption(requestBody);
16749
17941
  this.events?.emitSuccess({
16750
17942
  domain: 'redemption',
16751
17943
  type: 'redeem_success',
16752
17944
  userMessage: 'Reward redeemed successfully',
16753
- details: { redemptionId, hasContext: !!context }
17945
+ details: { redemptionId: requestBody.redemptionId, hasContext: !!(requestBody.context ?? context) }
16754
17946
  });
16755
17947
  return result;
16756
17948
  }
@@ -17231,6 +18423,29 @@ class RedemptionManager {
17231
18423
  getRedemptionService() {
17232
18424
  return this.redemptionService;
17233
18425
  }
18426
+ // ==========================================
18427
+ // ✅ APPROVAL WORKFLOW
18428
+ // ==========================================
18429
+ /**
18430
+ * Admin: Approve a redemption
18431
+ *
18432
+ * Approves a redemption that is pending approval. This operation is only available
18433
+ * when the tenant has redemption approval enabled in their approval settings.
18434
+ *
18435
+ * @param redemptionId - ID of the redemption to approve
18436
+ * @returns Promise resolving to updated redemption with approval metadata
18437
+ * @throws {PersApiError} When not authenticated as tenant admin or redemption not found
18438
+ *
18439
+ * @example
18440
+ * ` ypescript
18441
+ * const approved = await sdk.redemptions.approveRedemption('redemption-123');
18442
+ * console.log('Redemption approved at:', approved.approval?.approvedAt);
18443
+ * `
18444
+ */
18445
+ async setRedemptionApproval(redemptionId, status, reason) {
18446
+ const redemptionApi = new RedemptionApi(this.apiClient);
18447
+ return redemptionApi.setRedemptionApproval(redemptionId, status, reason);
18448
+ }
17234
18449
  }
17235
18450
 
17236
18451
  /**
@@ -18826,7 +20041,7 @@ class ApiKeyManager {
18826
20041
  */
18827
20042
  async listApiKeys(type, includeRevoked = false, options) {
18828
20043
  try {
18829
- return await this.apiKeyApi.getTenantApiKeys(type, includeRevoked);
20044
+ return await this.apiKeyApi.getTenantApiKeys(type, includeRevoked, options);
18830
20045
  }
18831
20046
  catch (error) {
18832
20047
  console.error('[ApiKeyManager] Failed to list API keys:', error);
@@ -19585,8 +20800,8 @@ class TriggerSourceManager {
19585
20800
  * }
19586
20801
  * ```
19587
20802
  */
19588
- async getById(triggerSourceId) {
19589
- return this.triggerSourceService.getTriggerSourceById(triggerSourceId);
20803
+ async getById(triggerSourceId, include) {
20804
+ return this.triggerSourceService.getTriggerSourceById(triggerSourceId, include);
19590
20805
  }
19591
20806
  /**
19592
20807
  * Admin: Create a new trigger source
@@ -19707,6 +20922,207 @@ class TriggerSourceManager {
19707
20922
  }
19708
20923
  }
19709
20924
 
20925
+ /**
20926
+ * Platform-Agnostic Redirect API Client
20927
+ *
20928
+ * Handles QR code / short-link redirect operations:
20929
+ * - CRUD for tenant/business-owned redirects
20930
+ * - Per-redirect scan analytics
20931
+ *
20932
+ * Uses @explorins/pers-shared DTOs for consistency with backend.
20933
+ */
20934
+ class RedirectApi {
20935
+ constructor(apiClient) {
20936
+ this.apiClient = apiClient;
20937
+ }
20938
+ /**
20939
+ * ADMIN: Create a new redirect
20940
+ *
20941
+ * @example
20942
+ * ```typescript
20943
+ * const redirect = await redirectApi.createRedirect({
20944
+ * targetUrl: 'https://app.pers.ninja/campaigns/abc',
20945
+ * label: 'Summer Campaign QR'
20946
+ * });
20947
+ * ```
20948
+ */
20949
+ async createRedirect(redirect) {
20950
+ return this.apiClient.post('/redirects', redirect);
20951
+ }
20952
+ /**
20953
+ * ADMIN: List all redirects for the current tenant
20954
+ */
20955
+ async getRedirects() {
20956
+ return this.apiClient.get('/redirects');
20957
+ }
20958
+ /**
20959
+ * ADMIN: Get a redirect by ID
20960
+ */
20961
+ async getRedirectById(redirectId) {
20962
+ return this.apiClient.get(`/redirects/${redirectId}`);
20963
+ }
20964
+ /**
20965
+ * ADMIN: Update a redirect's label or target URL
20966
+ */
20967
+ async updateRedirect(redirectId, redirect) {
20968
+ return this.apiClient.patch(`/redirects/${redirectId}`, redirect);
20969
+ }
20970
+ /**
20971
+ * ADMIN: Soft-delete a redirect
20972
+ */
20973
+ async deleteRedirect(redirectId) {
20974
+ await this.apiClient.delete(`/redirects/${redirectId}`);
20975
+ }
20976
+ /**
20977
+ * TENANT ONLY: Get scan analytics for a redirect
20978
+ *
20979
+ * @example
20980
+ * ```typescript
20981
+ * const stats = await redirectApi.getRedirectAnalytics(redirectId, { from: '2025-01-01' });
20982
+ * ```
20983
+ */
20984
+ async getRedirectAnalytics(redirectId, options) {
20985
+ const params = new URLSearchParams();
20986
+ if (options?.from)
20987
+ params.set('from', this.toDateParam(options.from));
20988
+ if (options?.to)
20989
+ params.set('to', this.toDateParam(options.to));
20990
+ const query = params.toString();
20991
+ return this.apiClient.get(`/redirects/${redirectId}/analytics${query ? `?${query}` : ''}`);
20992
+ }
20993
+ toDateParam(value) {
20994
+ return value instanceof Date ? value.toISOString() : value;
20995
+ }
20996
+ }
20997
+
20998
+ /**
20999
+ * Platform-Agnostic Redirect Service
21000
+ *
21001
+ * Contains redirect business logic and operations that work across platforms.
21002
+ * No framework dependencies - pure TypeScript business logic.
21003
+ */
21004
+ class RedirectService {
21005
+ constructor(redirectApi) {
21006
+ this.redirectApi = redirectApi;
21007
+ }
21008
+ /** ADMIN: Create a new redirect */
21009
+ async createRedirect(redirect) {
21010
+ return this.redirectApi.createRedirect(redirect);
21011
+ }
21012
+ /** ADMIN: List all redirects for the current tenant */
21013
+ async getRedirects() {
21014
+ return this.redirectApi.getRedirects();
21015
+ }
21016
+ /** ADMIN: Get a redirect by ID */
21017
+ async getRedirectById(redirectId) {
21018
+ return this.redirectApi.getRedirectById(redirectId);
21019
+ }
21020
+ /** ADMIN: Update a redirect's label or target URL */
21021
+ async updateRedirect(redirectId, redirect) {
21022
+ return this.redirectApi.updateRedirect(redirectId, redirect);
21023
+ }
21024
+ /** ADMIN: Soft-delete a redirect */
21025
+ async deleteRedirect(redirectId) {
21026
+ return this.redirectApi.deleteRedirect(redirectId);
21027
+ }
21028
+ /** TENANT ONLY: Get scan analytics for a redirect */
21029
+ async getRedirectAnalytics(redirectId, options) {
21030
+ return this.redirectApi.getRedirectAnalytics(redirectId, options);
21031
+ }
21032
+ }
21033
+
21034
+ /**
21035
+ * Redirect Manager - Clean, high-level interface for QR code / short-link tracking
21036
+ *
21037
+ * Manages redirects: short-coded links (e.g. printed on QR codes) that resolve
21038
+ * to a target URL and record per-scan analytics (device type, day, channel).
21039
+ *
21040
+ * @group Managers
21041
+ * @category Redirect Management
21042
+ *
21043
+ * @example Basic Redirect Operations
21044
+ * ```typescript
21045
+ * // Create a redirect
21046
+ * const redirect = await sdk.redirects.create({
21047
+ * targetUrl: 'https://app.pers.ninja/campaigns/abc',
21048
+ * label: 'Summer Campaign QR'
21049
+ * });
21050
+ *
21051
+ * // Build the QR target URL
21052
+ * const qrUrl = sdk.redirects.getQrUrl(redirect.code);
21053
+ *
21054
+ * // Get scan analytics
21055
+ * const stats = await sdk.redirects.getAnalytics(redirect.id, { from: '2025-01-01' });
21056
+ * ```
21057
+ */
21058
+ class RedirectManager {
21059
+ constructor(apiClient, events) {
21060
+ this.apiClient = apiClient;
21061
+ this.events = events;
21062
+ const redirectApi = new RedirectApi(apiClient);
21063
+ this.redirectService = new RedirectService(redirectApi);
21064
+ }
21065
+ /**
21066
+ * ADMIN: Create a new redirect
21067
+ *
21068
+ * @example
21069
+ * ```typescript
21070
+ * const redirect = await sdk.redirects.create({ targetUrl, label: 'Summer QR' });
21071
+ * ```
21072
+ */
21073
+ async create(redirect) {
21074
+ return this.redirectService.createRedirect(redirect);
21075
+ }
21076
+ /**
21077
+ * ADMIN: Get all redirects for the current tenant
21078
+ */
21079
+ async getAll() {
21080
+ return this.redirectService.getRedirects();
21081
+ }
21082
+ /**
21083
+ * ADMIN: Get a redirect by ID
21084
+ */
21085
+ async getById(redirectId) {
21086
+ return this.redirectService.getRedirectById(redirectId);
21087
+ }
21088
+ /**
21089
+ * ADMIN: Update a redirect's label or target URL
21090
+ */
21091
+ async update(redirectId, redirect) {
21092
+ return this.redirectService.updateRedirect(redirectId, redirect);
21093
+ }
21094
+ /**
21095
+ * ADMIN: Soft-delete a redirect
21096
+ */
21097
+ async delete(redirectId) {
21098
+ return this.redirectService.deleteRedirect(redirectId);
21099
+ }
21100
+ /**
21101
+ * TENANT ONLY: Get scan analytics for a redirect
21102
+ *
21103
+ * @param redirectId - UUID of the redirect
21104
+ * @param options - Optional `from`/`to` date range filters
21105
+ *
21106
+ * @example
21107
+ * ```typescript
21108
+ * const stats = await sdk.redirects.getAnalytics(redirect.id, { from: '2025-01-01' });
21109
+ * console.log(stats.total, stats.byDevice);
21110
+ * ```
21111
+ */
21112
+ async getAnalytics(redirectId, options) {
21113
+ return this.redirectService.getRedirectAnalytics(redirectId, options);
21114
+ }
21115
+ /**
21116
+ * Build the public QR target URL for a redirect's code.
21117
+ *
21118
+ * The public endpoint (`GET /redirects/go/:code`) requires no auth and issues
21119
+ * a 302 to the redirect's `targetUrl`.
21120
+ */
21121
+ getQrUrl(code) {
21122
+ return `${this.apiClient.getApiRoot()}/redirects/go/${code}`;
21123
+ }
21124
+ }
21125
+
19710
21126
  /**
19711
21127
  * Webhook API - Low-level API client for webhook operations
19712
21128
  *
@@ -20386,7 +21802,7 @@ class WebhookManager {
20386
21802
  */
20387
21803
  const DEFAULT_CONFIG = {
20388
21804
  autoReconnect: true,
20389
- maxReconnectAttempts: 10,
21805
+ maxReconnectAttempts: Infinity,
20390
21806
  reconnectDelay: 1000,
20391
21807
  connectionTimeout: 30000,
20392
21808
  debug: false,
@@ -20402,8 +21818,13 @@ class PersEventsClient {
20402
21818
  // Event handlers by type
20403
21819
  this.handlers = new Map();
20404
21820
  this.stateHandlers = new Set();
21821
+ this.notificationHandlers = new Set();
20405
21822
  // Connection info from server
20406
21823
  this.connectionInfo = null;
21824
+ // Notification routing context (channelId + topicIds), resolved server-side by the relay
21825
+ this.notificationContext = { notificationChannelId: null, topicIds: [] };
21826
+ // Explicit channelId to (re-)bind on next connect, set via bindNotificationChannel() before connect resolves
21827
+ this.pendingNotificationChannelId = null;
20407
21828
  // Current subscription state
20408
21829
  this.subscriptionState = { wallets: [], chains: [], activeChains: [] };
20409
21830
  // Pending subscription promise resolver
@@ -20430,6 +21851,9 @@ class PersEventsClient {
20430
21851
  this.cleanup();
20431
21852
  this.setState('error');
20432
21853
  reject(new Error(`Connection timeout after ${this.config.connectionTimeout}ms`));
21854
+ // Trigger auto-reconnect: cleanup() nullified ws.onclose so it won't fire.
21855
+ // Without this call, a timed-out initial connection silently dies with no retry.
21856
+ this.handleDisconnect();
20433
21857
  }, this.config.connectionTimeout);
20434
21858
  const clearTimeoutAndResolve = () => {
20435
21859
  clearTimeout(connectionTimeout);
@@ -20530,6 +21954,52 @@ class PersEventsClient {
20530
21954
  return { ...this.subscriptionState };
20531
21955
  }
20532
21956
  // ─────────────────────────────────────────────────────────────────────────────
21957
+ // Notifications (Flow 1 — personal channel real-time wake signal)
21958
+ // ─────────────────────────────────────────────────────────────────────────────
21959
+ /**
21960
+ * Bind this socket to a personal notification channelId (from `GET /notifications/channel-id`).
21961
+ *
21962
+ * The relay auto-resolves the channelId server-side on connect, so this is an optimization
21963
+ * for immediate binding rather than a requirement — safe to call any time after connect().
21964
+ * If not yet connected, the channelId is sent as soon as the connection opens.
21965
+ */
21966
+ bindNotificationChannel(channelId, topicIds) {
21967
+ this.pendingNotificationChannelId = channelId;
21968
+ if (this.state !== 'connected' || !this.ws)
21969
+ return;
21970
+ const message = { type: 'subscribe', notificationChannelId: channelId, ...(topicIds ? { topicIds } : {}) };
21971
+ this.ws.send(JSON.stringify(message));
21972
+ this.log('Sent notification channel bind:', message);
21973
+ }
21974
+ /**
21975
+ * Rebind the personal notification channelId on an already-connected socket
21976
+ * (e.g. after re-auth resolves a new channelId).
21977
+ */
21978
+ updateNotificationChannel(channelId) {
21979
+ this.pendingNotificationChannelId = channelId;
21980
+ if (this.state !== 'connected' || !this.ws)
21981
+ return;
21982
+ const message = { type: 'subscription.updated', notificationChannelId: channelId };
21983
+ this.ws.send(JSON.stringify(message));
21984
+ this.log('Sent notification channel update:', message);
21985
+ }
21986
+ /**
21987
+ * Get the notification routing context (channelId + topicIds) resolved for this socket.
21988
+ */
21989
+ getNotificationContext() {
21990
+ return { ...this.notificationContext };
21991
+ }
21992
+ /**
21993
+ * Subscribe to real-time notification wake signals.
21994
+ *
21995
+ * The signal carries no notification content — always follow up with
21996
+ * `sdk.notifications.list()` / `getUnreadCount()`.
21997
+ */
21998
+ onNotificationPending(handler) {
21999
+ this.notificationHandlers.add(handler);
22000
+ return () => { this.notificationHandlers.delete(handler); };
22001
+ }
22002
+ // ─────────────────────────────────────────────────────────────────────────────
20533
22003
  // Event Handlers
20534
22004
  // ─────────────────────────────────────────────────────────────────────────────
20535
22005
  /**
@@ -20623,15 +22093,29 @@ class PersEventsClient {
20623
22093
  switch (message.type) {
20624
22094
  case 'connected':
20625
22095
  this.connectionInfo = message.payload;
22096
+ this.notificationContext = {
22097
+ notificationChannelId: message.payload.notificationChannelId ?? null,
22098
+ topicIds: message.payload.topicIds ?? [],
22099
+ };
20626
22100
  this.reconnectAttempts = 0;
20627
22101
  this.setState('connected');
20628
22102
  this.log('Connected:', message.payload);
20629
22103
  onConnected?.();
20630
22104
  // Restore subscriptions on reconnect
20631
22105
  this.restoreSubscriptions();
22106
+ // Re-bind an explicitly-set notification channel (overrides the relay's auto-resolved one)
22107
+ if (this.pendingNotificationChannelId) {
22108
+ this.bindNotificationChannel(this.pendingNotificationChannelId);
22109
+ }
20632
22110
  break;
20633
22111
  case 'subscribed':
20634
22112
  this.subscriptionState = message.payload;
22113
+ if (message.payload.notificationChannelId !== undefined || message.payload.topicIds !== undefined) {
22114
+ this.notificationContext = {
22115
+ notificationChannelId: message.payload.notificationChannelId ?? this.notificationContext.notificationChannelId,
22116
+ topicIds: message.payload.topicIds ?? this.notificationContext.topicIds,
22117
+ };
22118
+ }
20635
22119
  this.log('Subscription confirmed:', message.payload);
20636
22120
  this.pendingSubscription?.resolve();
20637
22121
  break;
@@ -20643,6 +22127,9 @@ class PersEventsClient {
20643
22127
  case 'event':
20644
22128
  this.routeEvent(message.payload);
20645
22129
  break;
22130
+ case 'notification.pending':
22131
+ this.routeNotification(message.payload);
22132
+ break;
20646
22133
  case 'ping':
20647
22134
  // Respond to server ping with pong
20648
22135
  this.sendPong();
@@ -20708,6 +22195,17 @@ class PersEventsClient {
20708
22195
  }
20709
22196
  });
20710
22197
  }
22198
+ routeNotification(info) {
22199
+ this.log('Notification pending:', info);
22200
+ this.notificationHandlers.forEach(handler => {
22201
+ try {
22202
+ handler(info);
22203
+ }
22204
+ catch (err) {
22205
+ console.error('[PERS-Events] Notification handler error:', err);
22206
+ }
22207
+ });
22208
+ }
20711
22209
  handleDisconnect() {
20712
22210
  this.cleanup();
20713
22211
  this.setState('disconnected');
@@ -20716,8 +22214,8 @@ class PersEventsClient {
20716
22214
  this.attemptReconnect();
20717
22215
  }
20718
22216
  else {
22217
+ // maxReconnectAttempts exceeded (only reachable if explicitly set to a finite number)
20719
22218
  this.log(`Max reconnect attempts (${this.config.maxReconnectAttempts}) reached. Call connect() manually to retry.`);
20720
- // Reset counter so manual connect() will work
20721
22219
  this.reconnectAttempts = 0;
20722
22220
  }
20723
22221
  }
@@ -20726,7 +22224,10 @@ class PersEventsClient {
20726
22224
  const delay = Math.min(this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000 // Max 30 second delay
20727
22225
  );
20728
22226
  this.reconnectAttempts++;
20729
- this.log(`🔄 Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts})`);
22227
+ const attemptLabel = isFinite(this.config.maxReconnectAttempts)
22228
+ ? `${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`
22229
+ : `${this.reconnectAttempts}`;
22230
+ this.log(`🔄 Reconnecting in ${delay}ms (attempt ${attemptLabel})`);
20730
22231
  this.setState('reconnecting');
20731
22232
  this.reconnectTimeout = setTimeout(async () => {
20732
22233
  try {
@@ -20836,6 +22337,8 @@ class WalletEventsManager {
20836
22337
  this.eventEmitter = eventEmitter;
20837
22338
  this.client = null;
20838
22339
  this.pendingHandlers = [];
22340
+ this.pendingNotificationHandlers = [];
22341
+ this.pendingNotificationChannelId = null;
20839
22342
  this.unsubscribes = [];
20840
22343
  // Track if WS gave up reconnecting (so we can retry on token refresh)
20841
22344
  this.reconnectGaveUp = false;
@@ -20864,8 +22367,9 @@ class WalletEventsManager {
20864
22367
  * For auto-subscription, use `sdk.connectWalletEvents()` instead.
20865
22368
  */
20866
22369
  async connect() {
20867
- // Already connected?
20868
- if (this.client?.getState() === 'connected') {
22370
+ // Return early if already active — don't discard an in-progress connection
22371
+ const currentState = this.client?.getState();
22372
+ if (currentState === 'connected' || currentState === 'connecting' || currentState === 'reconnecting') {
20869
22373
  return;
20870
22374
  }
20871
22375
  const sdkConfig = this.apiClient.getConfig();
@@ -20899,44 +22403,63 @@ class WalletEventsManager {
20899
22403
  debug: this.config.debug,
20900
22404
  tokenRefresher,
20901
22405
  });
20902
- await this.client.connect(token);
20903
- // Reset gave-up flag on successful connection
20904
- this.reconnectGaveUp = false;
20905
- // Track previous state to detect reconnections and give-ups
20906
- let previousState = 'connected';
22406
+ // ── Wire up all handlers BEFORE connecting ────────────────────────────
22407
+ // This ensures state changes and events are captured even if the initial
22408
+ // connect() times out and PersEventsClient retries in the background.
20907
22409
  // Clean up previous state change listener (prevent duplicate listeners)
20908
22410
  this.stateChangeUnsubscribe?.();
20909
- // Listen for state changes
22411
+ // Track whether a reconnect cycle is in progress.
22412
+ // Cannot use previousState === 'reconnecting' because PersEventsClient emits
22413
+ // 'connecting' between 'reconnecting' and 'connected', stomping previousState.
22414
+ let wasReconnecting = false;
20910
22415
  this.stateChangeUnsubscribe = this.client.onStateChange((state) => {
20911
- if (state === 'disconnected' && previousState === 'reconnecting') {
20912
- // Transitioned from reconnecting to disconnected = exhausted all attempts
22416
+ if (state === 'reconnecting') {
22417
+ wasReconnecting = true;
22418
+ }
22419
+ else if (state === 'disconnected' && wasReconnecting) {
22420
+ // Exhausted all reconnect attempts
20913
22421
  this.reconnectGaveUp = true;
22422
+ wasReconnecting = false;
20914
22423
  }
20915
22424
  else if (state === 'connected') {
20916
22425
  this.reconnectGaveUp = false;
20917
- // Emit reconnected event so apps can refresh state (catch missed events)
20918
- if (previousState === 'reconnecting') {
22426
+ if (wasReconnecting) {
22427
+ wasReconnecting = false;
22428
+ // Emit reconnected event so apps can refresh state (catch missed events)
22429
+ // and re-subscribe wallets if the initial subscription never completed.
20919
22430
  this.eventEmitter.emitSuccess({
20920
22431
  type: 'wallet_reconnected',
20921
22432
  domain: 'wallet',
20922
22433
  userMessage: 'Wallet events reconnected',
20923
- details: { previousState }
22434
+ details: {}
20924
22435
  });
20925
22436
  }
20926
22437
  }
20927
- previousState = state;
20928
22438
  });
20929
22439
  // Clear previous unsubscribes on new connection (prevent memory leak)
20930
22440
  this.unsubscribes.forEach(unsub => unsub());
20931
22441
  this.unsubscribes = [];
20932
22442
  // Route all events through PersEventEmitter for unified stream
20933
22443
  // Note: Cast needed because web3-types uses string for event.type while pers-shared uses strict union
20934
- this.client.on('*', (event) => this.emitToPersEvents(event));
22444
+ this.unsubscribes.push(this.client.on('*', (event) => this.emitToPersEvents(event)));
20935
22445
  // Re-attach any handlers registered before connect
20936
22446
  for (const { type, handler } of this.pendingHandlers) {
20937
22447
  this.unsubscribes.push(this.client.on(type, handler));
20938
22448
  }
20939
22449
  this.pendingHandlers = [];
22450
+ // Re-attach any notification handlers registered before connect
22451
+ for (const handler of this.pendingNotificationHandlers) {
22452
+ this.unsubscribes.push(this.client.onNotificationPending(handler));
22453
+ }
22454
+ this.pendingNotificationHandlers = [];
22455
+ // Rebind an explicitly-set notification channel across reconnects (new client instance each time)
22456
+ if (this.pendingNotificationChannelId) {
22457
+ this.client.bindNotificationChannel(this.pendingNotificationChannelId);
22458
+ }
22459
+ // ─────────────────────────────────────────────────────────────────────
22460
+ await this.client.connect(token);
22461
+ // Reset gave-up flag on successful initial connection
22462
+ this.reconnectGaveUp = false;
20940
22463
  }
20941
22464
  /**
20942
22465
  * Disconnect from real-time events
@@ -20961,6 +22484,8 @@ class WalletEventsManager {
20961
22484
  this.authEventUnsubscribe = undefined;
20962
22485
  this.reconnectGaveUp = false;
20963
22486
  this.lastSubscriptions = { wallets: [], chains: [] };
22487
+ this.pendingNotificationHandlers = [];
22488
+ this.pendingNotificationChannelId = null;
20964
22489
  }
20965
22490
  // ─────────────────────────────────────────────────────────────────────────────
20966
22491
  // Subscription Methods (v1.2.0)
@@ -21046,6 +22571,47 @@ class WalletEventsManager {
21046
22571
  return this.client?.getSubscriptionState() ?? { wallets: [], chains: [], activeChains: [] };
21047
22572
  }
21048
22573
  // ─────────────────────────────────────────────────────────────────────────────
22574
+ // Notifications (Flow 1 — shares this same socket, see NotificationManager)
22575
+ // ─────────────────────────────────────────────────────────────────────────────
22576
+ /**
22577
+ * Bind this socket to a personal notification channelId (from `sdk.notifications.getChannelId()`).
22578
+ * Safe to call before or after connect() — queued and sent once the socket is open.
22579
+ */
22580
+ bindNotificationChannel(channelId, topicIds) {
22581
+ this.pendingNotificationChannelId = channelId;
22582
+ this.client?.bindNotificationChannel(channelId, topicIds);
22583
+ }
22584
+ /**
22585
+ * Rebind the personal notification channelId on an already-connected socket.
22586
+ */
22587
+ updateNotificationChannel(channelId) {
22588
+ this.pendingNotificationChannelId = channelId;
22589
+ this.client?.updateNotificationChannel(channelId);
22590
+ }
22591
+ /**
22592
+ * Get the notification routing context (channelId + topicIds) resolved for this socket.
22593
+ */
22594
+ getNotificationContext() {
22595
+ return this.client?.getNotificationContext() ?? { notificationChannelId: null, topicIds: [] };
22596
+ }
22597
+ /**
22598
+ * Subscribe to real-time notification wake signals (`notification.pending`).
22599
+ * The signal carries no content — always follow up with `sdk.notifications.list()`.
22600
+ */
22601
+ onNotificationPending(handler) {
22602
+ if (this.client) {
22603
+ const unsub = this.client.onNotificationPending(handler);
22604
+ this.unsubscribes.push(unsub);
22605
+ return unsub;
22606
+ }
22607
+ this.pendingNotificationHandlers.push(handler);
22608
+ return () => {
22609
+ const idx = this.pendingNotificationHandlers.indexOf(handler);
22610
+ if (idx !== -1)
22611
+ this.pendingNotificationHandlers.splice(idx, 1);
22612
+ };
22613
+ }
22614
+ // ─────────────────────────────────────────────────────────────────────────────
21049
22615
  // Event Handlers
21050
22616
  // ─────────────────────────────────────────────────────────────────────────────
21051
22617
  /**
@@ -21208,6 +22774,263 @@ class WalletEventsManager {
21208
22774
  }
21209
22775
  }
21210
22776
 
22777
+ /**
22778
+ * Notification API - Low-level API client for the notification inbox
22779
+ *
22780
+ * Backend routes (`src/notification/infrastructure/notifications.controller.ts`):
22781
+ * - `GET /notifications/channel-id` - Besu channelId for real-time WS binding
22782
+ * - `GET /notifications` - paginated inbox
22783
+ * - `GET /notifications/unread-count` - unread badge count
22784
+ * - `PATCH /notifications/:id/read` - mark as read
22785
+ * - `DELETE /notifications/:id` - delete
22786
+ * - `POST /notifications/send` - direct push (BUSINESS → USER, TENANT → USER|BUSINESS)
22787
+ *
22788
+ * @internal Use NotificationService or NotificationManager for higher-level operations
22789
+ */
22790
+ class NotificationApi {
22791
+ constructor(apiClient) {
22792
+ this.apiClient = apiClient;
22793
+ this.basePath = '/notifications';
22794
+ }
22795
+ /**
22796
+ * Get the Besu channelId for the authenticated account (auto-provisioned on first call).
22797
+ * GET /notifications/channel-id
22798
+ */
22799
+ async getChannelId() {
22800
+ const res = await this.apiClient.get(`${this.basePath}/channel-id`);
22801
+ return res.channelId;
22802
+ }
22803
+ /**
22804
+ * List the authenticated account's notification inbox (paginated).
22805
+ * GET /notifications
22806
+ */
22807
+ async list(options) {
22808
+ const params = new URLSearchParams();
22809
+ if (options?.category)
22810
+ params.append('category', options.category);
22811
+ if (options?.unreadOnly !== undefined)
22812
+ params.append('unreadOnly', String(options.unreadOnly));
22813
+ if (options?.page)
22814
+ params.append('page', String(options.page));
22815
+ if (options?.limit)
22816
+ params.append('limit', String(options.limit));
22817
+ const queryString = params.toString();
22818
+ const url = queryString ? `${this.basePath}?${queryString}` : this.basePath;
22819
+ return this.apiClient.get(url);
22820
+ }
22821
+ /**
22822
+ * Get unread notification count.
22823
+ * GET /notifications/unread-count
22824
+ */
22825
+ async getUnreadCount() {
22826
+ return this.apiClient.get(`${this.basePath}/unread-count`);
22827
+ }
22828
+ /**
22829
+ * Mark a notification as read.
22830
+ * PATCH /notifications/:id/read
22831
+ */
22832
+ async markAsRead(id) {
22833
+ return this.apiClient.patch(`${this.basePath}/${id}/read`);
22834
+ }
22835
+ /**
22836
+ * Delete a notification.
22837
+ * DELETE /notifications/:id
22838
+ */
22839
+ async delete(id) {
22840
+ await this.apiClient.delete(`${this.basePath}/${id}`);
22841
+ }
22842
+ /**
22843
+ * Send a direct notification (BUSINESS → USER, or TENANT → USER|BUSINESS).
22844
+ * POST /notifications/send
22845
+ */
22846
+ async send(dto) {
22847
+ return this.apiClient.post(`${this.basePath}/send`, dto);
22848
+ }
22849
+ }
22850
+
22851
+ /**
22852
+ * Notification Service - Business logic layer for the notification inbox
22853
+ *
22854
+ * Caches the resolved `channelId` per session (stable per-recipient per-tenant,
22855
+ * per the SDK integration guide) to avoid refetching it on every WS (re)connect.
22856
+ *
22857
+ * @group Services
22858
+ * @category Notification
22859
+ */
22860
+ class NotificationService {
22861
+ constructor(notificationApi) {
22862
+ this.notificationApi = notificationApi;
22863
+ }
22864
+ /**
22865
+ * Get the Besu channelId for the authenticated account.
22866
+ * Cached in-memory for the lifetime of this service instance.
22867
+ */
22868
+ async getChannelId(forceRefresh = false) {
22869
+ if (!forceRefresh && this.cachedChannelId !== undefined) {
22870
+ return this.cachedChannelId;
22871
+ }
22872
+ this.cachedChannelId = await this.notificationApi.getChannelId();
22873
+ return this.cachedChannelId;
22874
+ }
22875
+ async getNotifications(options) {
22876
+ return this.notificationApi.list(options);
22877
+ }
22878
+ async getUnreadCount() {
22879
+ return this.notificationApi.getUnreadCount();
22880
+ }
22881
+ async markAsRead(id) {
22882
+ if (!id?.trim()) {
22883
+ throw new Error('Notification id is required');
22884
+ }
22885
+ return this.notificationApi.markAsRead(id);
22886
+ }
22887
+ async deleteNotification(id) {
22888
+ if (!id?.trim()) {
22889
+ throw new Error('Notification id is required');
22890
+ }
22891
+ return this.notificationApi.delete(id);
22892
+ }
22893
+ async sendNotification(dto) {
22894
+ if (!dto.recipientId?.trim()) {
22895
+ throw new Error('recipientId is required');
22896
+ }
22897
+ if (!dto.title?.trim()) {
22898
+ throw new Error('title is required');
22899
+ }
22900
+ return this.notificationApi.send(dto);
22901
+ }
22902
+ }
22903
+
22904
+ /**
22905
+ * Notification Manager - Clean, high-level interface for the notification inbox (Flow 1)
22906
+ *
22907
+ * Notifications are delivered through two independent channels — use both together:
22908
+ * - **REST inbox** (this manager): source of truth, full content, pagination, read/unread state
22909
+ * - **WS Relay `notification.pending`** (`onPending()`): real-time wake signal, no content —
22910
+ * always follow up with a REST fetch
22911
+ *
22912
+ * Real-time delivery **shares the same WebSocket connection** as `sdk.walletEvents` — no
22913
+ * second socket is opened. Call `connectRealtime()` once (after auth) to bind the channel.
22914
+ *
22915
+ * @group Managers
22916
+ * @category Notification
22917
+ *
22918
+ * @example Recommended app flow
22919
+ * ```typescript
22920
+ * await sdk.notifications.connectRealtime();
22921
+ * const inbox = await sdk.notifications.list({ unreadOnly: true });
22922
+ * const { count } = await sdk.notifications.getUnreadCount();
22923
+ *
22924
+ * sdk.notifications.onPending(async () => {
22925
+ * // Signal is a hint — always re-fetch for actual content
22926
+ * await sdk.notifications.list();
22927
+ * await sdk.notifications.getUnreadCount();
22928
+ * });
22929
+ * ```
22930
+ *
22931
+ * @example Business → user direct push
22932
+ * ```typescript
22933
+ * await sdk.notifications.send({
22934
+ * recipientType: 'user',
22935
+ * recipientId: userId,
22936
+ * title: 'New reward available!',
22937
+ * body: "You've earned 100 points.",
22938
+ * category: 'campaign',
22939
+ * entityId: campaignId,
22940
+ * });
22941
+ * ```
22942
+ */
22943
+ class NotificationManager {
22944
+ constructor(apiClient, walletEvents, events) {
22945
+ this.walletEvents = walletEvents;
22946
+ this.events = events;
22947
+ this.notificationService = new NotificationService(new NotificationApi(apiClient));
22948
+ }
22949
+ // ================================
22950
+ // REST Inbox
22951
+ // ================================
22952
+ /**
22953
+ * Get the Besu channelId for the authenticated account (auto-provisioned on first call).
22954
+ * Cached in-memory; pass `forceRefresh` to bypass the cache (e.g. after re-auth).
22955
+ */
22956
+ async getChannelId(forceRefresh = false) {
22957
+ return this.notificationService.getChannelId(forceRefresh);
22958
+ }
22959
+ /**
22960
+ * List the authenticated account's notification inbox (paginated).
22961
+ */
22962
+ async list(options) {
22963
+ return this.notificationService.getNotifications(options);
22964
+ }
22965
+ /**
22966
+ * Get unread notification count (for a badge).
22967
+ */
22968
+ async getUnreadCount() {
22969
+ return this.notificationService.getUnreadCount();
22970
+ }
22971
+ /**
22972
+ * Mark a notification as read.
22973
+ */
22974
+ async markAsRead(id) {
22975
+ return this.notificationService.markAsRead(id);
22976
+ }
22977
+ /**
22978
+ * Delete a notification.
22979
+ */
22980
+ async delete(id) {
22981
+ return this.notificationService.deleteNotification(id);
22982
+ }
22983
+ /**
22984
+ * Send a direct notification. BUSINESS accounts may only target `recipientType: 'user'`;
22985
+ * TENANT (admin) accounts may target `'user'` or `'business'`.
22986
+ */
22987
+ async send(dto) {
22988
+ const result = await this.notificationService.sendNotification(dto);
22989
+ this.events?.emitSuccess({
22990
+ domain: 'notification',
22991
+ type: 'notification_sent',
22992
+ userMessage: 'Notification sent successfully',
22993
+ details: { notificationId: result.id, recipientType: result.recipientType }
22994
+ });
22995
+ return result;
22996
+ }
22997
+ // ================================
22998
+ // Real-Time (shares sdk.walletEvents' socket)
22999
+ // ================================
23000
+ /**
23001
+ * Resolve this account's channelId and bind it on the shared wallet-events socket,
23002
+ * connecting first if needed. Call once after authentication.
23003
+ */
23004
+ async connectRealtime() {
23005
+ await this.walletEvents.connect();
23006
+ const channelId = await this.getChannelId();
23007
+ if (channelId) {
23008
+ this.walletEvents.bindNotificationChannel(channelId);
23009
+ }
23010
+ }
23011
+ /**
23012
+ * Get the notification routing context (channelId + topicIds) resolved for the socket.
23013
+ */
23014
+ getNotificationContext() {
23015
+ return this.walletEvents.getNotificationContext();
23016
+ }
23017
+ /**
23018
+ * Subscribe to real-time notification wake signals (`notification.pending`).
23019
+ * The signal carries no content — always follow up with `list()` / `getUnreadCount()`.
23020
+ */
23021
+ onPending(handler) {
23022
+ return this.walletEvents.onNotificationPending((info) => {
23023
+ this.events?.emitSuccess({
23024
+ domain: 'notification',
23025
+ type: 'notification_pending',
23026
+ userMessage: 'A new notification arrived',
23027
+ details: { messageId: info.messageId, category: info.notificationCategory }
23028
+ });
23029
+ handler(info);
23030
+ });
23031
+ }
23032
+ }
23033
+
21211
23034
  /**
21212
23035
  * Custom Field Definitions API
21213
23036
  *
@@ -21225,11 +23048,12 @@ class CustomFieldApi {
21225
23048
  * List all custom field definitions
21226
23049
  * Backend returns non-paginated array sorted by sortOrder
21227
23050
  */
21228
- async getDefinitions(entityType) {
23051
+ async getDefinitions(entityType, include) {
21229
23052
  const params = new URLSearchParams();
21230
23053
  if (entityType) {
21231
23054
  params.append('entityType', entityType);
21232
23055
  }
23056
+ setIncludeParam(params, include);
21233
23057
  const query = params.toString();
21234
23058
  const url = query ? `${this.basePath}?${query}` : this.basePath;
21235
23059
  return this.apiClient.get(url);
@@ -21237,8 +23061,11 @@ class CustomFieldApi {
21237
23061
  /**
21238
23062
  * Get a single custom field definition by ID
21239
23063
  */
21240
- async getDefinition(id) {
21241
- return this.apiClient.get(`${this.basePath}/${id}`);
23064
+ async getDefinition(id, include) {
23065
+ const params = new URLSearchParams();
23066
+ setIncludeParam(params, include);
23067
+ const query = params.toString();
23068
+ return this.apiClient.get(`${this.basePath}/${id}${query ? `?${query}` : ''}`);
21242
23069
  }
21243
23070
  /**
21244
23071
  * Create a new custom field definition
@@ -21350,7 +23177,7 @@ class CustomFieldDefinitionManager {
21350
23177
  * ```
21351
23178
  */
21352
23179
  async getDefinitions(options) {
21353
- return this.api.getDefinitions(options?.entityType);
23180
+ return this.api.getDefinitions(options?.entityType, options?.include);
21354
23181
  }
21355
23182
  /**
21356
23183
  * Get a single custom field definition by ID
@@ -21371,8 +23198,8 @@ class CustomFieldDefinitionManager {
21371
23198
  * }
21372
23199
  * ```
21373
23200
  */
21374
- async getDefinition(id) {
21375
- return this.api.getDefinition(id);
23201
+ async getDefinition(id, include) {
23202
+ return this.api.getDefinition(id, include);
21376
23203
  }
21377
23204
  /**
21378
23205
  * Create a new custom field definition
@@ -22233,6 +24060,16 @@ class PersSDK {
22233
24060
  console.warn('[PersSDK] Failed to auto-connect wallet events:', err.message);
22234
24061
  });
22235
24062
  }
24063
+ // Re-subscribe after reconnect. This covers two cases:
24064
+ // 1. Normal reconnect after a drop (restoreSubscriptions handles it, but this
24065
+ // also refreshes the subscription from the latest user state).
24066
+ // 2. Initial connection timed out → client auto-reconnected → subscription
24067
+ // was never sent because connectWalletEvents() failed before subscribeWallets().
24068
+ if (event.level === 'success' && event.type === 'wallet_reconnected') {
24069
+ this.connectWalletEvents().catch((err) => {
24070
+ console.warn('[PersSDK] Failed to re-subscribe wallet events after reconnect:', err.message);
24071
+ });
24072
+ }
22236
24073
  // Disconnect on logout
22237
24074
  if (event.level === 'success' && event.type === 'logout_success') {
22238
24075
  this.walletEvents.disconnect();
@@ -22640,6 +24477,18 @@ class PersSDK {
22640
24477
  }
22641
24478
  return this._tenants;
22642
24479
  }
24480
+ /**
24481
+ * IPFS manager - Gateway, URL and token metadata resolution (chain-agnostic, no web3 dependencies)
24482
+ *
24483
+ * @example
24484
+ * ```typescript
24485
+ * const url = await sdk.ipfs.resolveUrl('ipfs://QmXxx...');
24486
+ * const metadata = await sdk.ipfs.fetchMetadata('ipfs://QmXxx.../metadata.json');
24487
+ * ```
24488
+ */
24489
+ get ipfs() {
24490
+ return this.tenants.ipfs;
24491
+ }
22643
24492
  /**
22644
24493
  * API Key manager - High-level API key management operations (Admin Only)
22645
24494
  *
@@ -22729,6 +24578,35 @@ class PersSDK {
22729
24578
  }
22730
24579
  return this._triggerSources;
22731
24580
  }
24581
+ /**
24582
+ * Redirect manager - High-level redirect (QR code / short-link) operations
24583
+ *
24584
+ * Provides methods for creating short-code redirects, updating their target
24585
+ * URL/label, and retrieving per-scan analytics (device, day, channel breakdowns).
24586
+ *
24587
+ * @returns RedirectManager instance
24588
+ *
24589
+ * @example Redirect Operations
24590
+ * ```typescript
24591
+ * // Create a redirect
24592
+ * const redirect = await sdk.redirects.create({
24593
+ * targetUrl: 'https://app.pers.ninja/campaigns/abc',
24594
+ * label: 'Summer Campaign QR'
24595
+ * });
24596
+ *
24597
+ * // Build the QR target URL
24598
+ * const qrUrl = sdk.redirects.getQrUrl(redirect.code);
24599
+ *
24600
+ * // Get scan analytics
24601
+ * const stats = await sdk.redirects.getAnalytics(redirect.id, { from: '2025-01-01' });
24602
+ * ```
24603
+ */
24604
+ get redirects() {
24605
+ if (!this._redirects) {
24606
+ this._redirects = new RedirectManager(this.apiClient, this._events);
24607
+ }
24608
+ return this._redirects;
24609
+ }
22732
24610
  /**
22733
24611
  * Webhook manager - High-level webhook operations
22734
24612
  *
@@ -22898,6 +24776,39 @@ class PersSDK {
22898
24776
  // Reset manager so it picks up new config
22899
24777
  this._walletEvents = undefined;
22900
24778
  }
24779
+ /**
24780
+ * Notification Manager - Per-recipient notification inbox (Flow 1 — transactional/private)
24781
+ *
24782
+ * Notifications are delivered through two independent channels — use both together:
24783
+ * - **REST inbox** (`list`, `getUnreadCount`, `markAsRead`, `delete`): source of truth
24784
+ * - **WS Relay `notification.pending`** (`onPending`): real-time wake signal only
24785
+ *
24786
+ * Real-time delivery shares the same WebSocket connection as `sdk.walletEvents` —
24787
+ * calling `connectRealtime()` connects it (if needed) and binds the channelId.
24788
+ *
24789
+ * @returns NotificationManager instance
24790
+ *
24791
+ * @example Basic Usage
24792
+ * ```typescript
24793
+ * await sdk.notifications.connectRealtime();
24794
+ *
24795
+ * const inbox = await sdk.notifications.list({ unreadOnly: true });
24796
+ * const { count } = await sdk.notifications.getUnreadCount();
24797
+ *
24798
+ * sdk.notifications.onPending(() => {
24799
+ * // Signal carries no content — re-fetch the inbox
24800
+ * sdk.notifications.list();
24801
+ * });
24802
+ * ```
24803
+ *
24804
+ * @see {@link NotificationManager} for detailed documentation
24805
+ */
24806
+ get notifications() {
24807
+ if (!this._notifications) {
24808
+ this._notifications = new NotificationManager(this.apiClient, this.walletEvents, this._events);
24809
+ }
24810
+ return this._notifications;
24811
+ }
22901
24812
  // ==========================================
22902
24813
  // DATA SOURCE TRACKING
22903
24814
  // ==========================================
@@ -22966,6 +24877,30 @@ class PersSDK {
22966
24877
  setPlatform(platform) {
22967
24878
  this.apiClient.setPlatform(platform);
22968
24879
  }
24880
+ // ==========================================
24881
+ // CONTENT LOCALE
24882
+ // ==========================================
24883
+ /**
24884
+ * Set the content locale for localized API responses and IPFS token metadata
24885
+ *
24886
+ * Opt-in: until set (or configured via `PersConfig.locale`), base-language strings are returned.
24887
+ * Already-loaded content is not re-translated: refetch (or invalidate app caches) after changing it.
24888
+ *
24889
+ * @param locale - A BCP 47 tag (e.g. 'de-DE'), 'auto' for the device locale, or null to disable
24890
+ *
24891
+ * @example
24892
+ * ```typescript
24893
+ * sdk.setLocale('fr-FR');
24894
+ * const campaigns = await sdk.campaigns.getActiveCampaigns();
24895
+ * ```
24896
+ */
24897
+ setLocale(locale) {
24898
+ this.apiClient.setLocale(locale);
24899
+ }
24900
+ /** Normalized content locale in effect, or null when disabled */
24901
+ getLocale() {
24902
+ return this.apiClient.getLocale();
24903
+ }
22969
24904
  /**
22970
24905
  * Gets the API client for direct PERS API requests
22971
24906
  *
@@ -23373,17 +25308,30 @@ class ReactNativeSecureStorage {
23373
25308
  async get(key) {
23374
25309
  const prefixedKey = this.getKeyName(key);
23375
25310
  if (this.SECURE_KEYS.has(key)) {
23376
- try {
23377
- if (Keychain) {
23378
- const credentials = await Keychain.getGenericPassword({ service: prefixedKey });
23379
- if (credentials) {
23380
- return this.tryParse(credentials.password);
25311
+ // The iOS Keychain can be briefly unreadable (device locked, background launch), so retry before giving up
25312
+ let keychainError;
25313
+ if (Keychain) {
25314
+ for (let attempt = 0; attempt < 3; attempt++) {
25315
+ try {
25316
+ const credentials = await Keychain.getGenericPassword({ service: prefixedKey });
25317
+ if (credentials) {
25318
+ return this.tryParse(credentials.password);
25319
+ }
25320
+ keychainError = undefined;
25321
+ break;
25322
+ }
25323
+ catch (e) {
25324
+ keychainError = e;
25325
+ if (attempt < 2)
25326
+ await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
23381
25327
  }
23382
25328
  }
23383
25329
  }
23384
- catch (e) {
23385
- console.warn(`[ReactNativeSecureStorage] Failed to access keychain for ${key}`, e);
23386
- // Continue to fallback check...
25330
+ if (keychainError) {
25331
+ console.warn(`[ReactNativeSecureStorage] Failed to access keychain for ${key}`, keychainError);
25332
+ // Returning "missing" here would make the DPoP manager regenerate the key pair and log the user out
25333
+ if (key === DPOP_STORAGE_KEYS.PRIVATE)
25334
+ throw keychainError;
23387
25335
  }
23388
25336
  // Fallback: Check AsyncStorage if not found in Keychain or Keychain failed
23389
25337
  try {
@@ -23718,6 +25666,9 @@ class ReactNativeHttpClient {
23718
25666
  async put(url, body, options) {
23719
25667
  return this.request('PUT', url, body, options);
23720
25668
  }
25669
+ async patch(url, body, options) {
25670
+ return this.request('PATCH', url, body, options);
25671
+ }
23721
25672
  async delete(url, options) {
23722
25673
  return this.request('DELETE', url, undefined, options);
23723
25674
  }
@@ -23736,8 +25687,8 @@ class ReactNativeHttpClient {
23736
25687
  config.signal = controller.signal;
23737
25688
  setTimeout(() => controller.abort(), options.timeout);
23738
25689
  }
23739
- // Add body for POST/PUT requests
23740
- if (body && (method === 'POST' || method === 'PUT')) {
25690
+ // Add body for POST/PUT/PATCH requests
25691
+ if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
23741
25692
  config.body = JSON.stringify(body);
23742
25693
  }
23743
25694
  try {
@@ -23893,6 +25844,7 @@ const PersSDKProvider = ({ children, config }) => {
23893
25844
  const [isInitialized, setIsInitialized] = react.useState(false);
23894
25845
  const [isAuthenticated, setIsAuthenticated] = react.useState(false);
23895
25846
  const [user, setUser] = react.useState(null);
25847
+ const [locale, setLocaleState] = react.useState(null);
23896
25848
  // Keep state refs in sync immediately (not in useEffect to avoid race conditions)
23897
25849
  sdkRef.current = sdk;
23898
25850
  isInitializedRef.current = isInitialized;
@@ -23970,6 +25922,7 @@ const PersSDKProvider = ({ children, config }) => {
23970
25922
  }, { domains: ['authentication'], replay: true });
23971
25923
  setAuthProvider(authProvider);
23972
25924
  setSdk(sdkInstance);
25925
+ setLocaleState(sdkInstance.getLocale());
23973
25926
  setIsInitialized(true);
23974
25927
  }
23975
25928
  catch (error) {
@@ -24002,6 +25955,14 @@ const PersSDKProvider = ({ children, config }) => {
24002
25955
  });
24003
25956
  }
24004
25957
  }, [config, isInitialized, initialize]);
25958
+ const setLocale = react.useCallback((next) => {
25959
+ const currentSdk = sdkRef.current;
25960
+ if (!currentSdk) {
25961
+ throw new Error('SDK not initialized. Call initialize() first.');
25962
+ }
25963
+ currentSdk.setLocale(next);
25964
+ setLocaleState(currentSdk.getLocale());
25965
+ }, []);
24005
25966
  const refreshUserData = react.useCallback(async () => {
24006
25967
  // Read from refs to get current values
24007
25968
  const currentSdk = sdkRef.current;
@@ -24068,8 +26029,10 @@ const PersSDKProvider = ({ children, config }) => {
24068
26029
  isInitialized,
24069
26030
  isAuthenticated,
24070
26031
  user,
26032
+ locale,
24071
26033
  // Methods - expose functions directly, not through refs
24072
26034
  initialize,
26035
+ setLocale,
24073
26036
  setAuthenticationState,
24074
26037
  refreshUserData,
24075
26038
  restoreSession,
@@ -24079,7 +26042,9 @@ const PersSDKProvider = ({ children, config }) => {
24079
26042
  isInitialized,
24080
26043
  isAuthenticated,
24081
26044
  user,
26045
+ locale,
24082
26046
  initialize,
26047
+ setLocale,
24083
26048
  setAuthenticationState,
24084
26049
  refreshUserData,
24085
26050
  restoreSession
@@ -44438,9 +46403,9 @@ class Web3ChainService {
44438
46403
  * - Metadata: CACHED 24h - IPFS content is immutable
44439
46404
  */
44440
46405
  class TokenDomainService {
44441
- constructor(web3Api, metadataService, contractService) {
46406
+ constructor(web3Api, metadataSource, contractService) {
44442
46407
  this.web3Api = web3Api;
44443
- this.metadataService = metadataService;
46408
+ this.metadataSource = metadataSource;
44444
46409
  this.contractService = contractService;
44445
46410
  this.cache = globalCacheService.createNamespace('token-domain');
44446
46411
  }
@@ -44461,7 +46426,7 @@ class TokenDomainService {
44461
46426
  const abi = convertAbiToInterface(params.abi);
44462
46427
  const tokenUri = await this.web3Api.getTokenUri({ ...params, abi });
44463
46428
  const metadata = tokenUri
44464
- ? await this.metadataService.fetchAndProcessMetadata(tokenUri)
46429
+ ? await this.metadataSource.fetchAndProcessMetadata(tokenUri)
44465
46430
  : null;
44466
46431
  return { tokenId: params.tokenId, tokenUri, metadata };
44467
46432
  }
@@ -44643,47 +46608,6 @@ class TokenDomainService {
44643
46608
  }
44644
46609
  }
44645
46610
 
44646
- /**
44647
- * MetadataDomainService - Clean IPFS metadata resolution with centralized caching
44648
- *
44649
- * IPFS gateway is resolved from tenant configuration (chain-agnostic).
44650
- */
44651
- class MetadataDomainService {
44652
- constructor(ipfsApi) {
44653
- this.ipfsApi = ipfsApi;
44654
- this.cache = globalCacheService.createNamespace('metadata');
44655
- }
44656
- async fetchAndProcessMetadata(tokenUri) {
44657
- const cacheKey = `fetch:${tokenUri}`;
44658
- return this.cache.getOrSet(cacheKey, async () => {
44659
- try {
44660
- return await this.ipfsApi.fetchAndProcessMetadata(tokenUri);
44661
- }
44662
- catch (error) {
44663
- console.error(`Error fetching metadata for ${tokenUri}:`, error);
44664
- return null;
44665
- }
44666
- }, CacheTTL.METADATA);
44667
- }
44668
- async resolveIPFSUrl(url) {
44669
- const cacheKey = `resolve:${url}`;
44670
- return this.cache.getOrSet(cacheKey, () => this.ipfsApi.resolveIPFSUrl(url), CacheTTL.GATEWAY);
44671
- }
44672
- /**
44673
- * Get IPFS gateway domain from tenant configuration.
44674
- * Useful for pipes that need to resolve URLs synchronously after initial fetch.
44675
- */
44676
- async getIpfsGatewayDomain() {
44677
- return this.ipfsApi.getIpfsGatewayDomain();
44678
- }
44679
- /**
44680
- * Clear all cached metadata and URLs for this service
44681
- */
44682
- clearCache() {
44683
- this.cache.clear();
44684
- }
44685
- }
44686
-
44687
46611
  /**
44688
46612
  * ContractDomainService - Clean contract analysis without external dependencies
44689
46613
  */
@@ -44761,7 +46685,8 @@ class TokenCollectionManager {
44761
46685
  this.config = {
44762
46686
  autoRefreshOnWalletEvents: config?.autoRefreshOnWalletEvents ?? true,
44763
46687
  eventDebounceMs: config?.eventDebounceMs ?? 500,
44764
- debug: config?.debug ?? false
46688
+ debug: config?.debug ?? false,
46689
+ getLocale: config?.getLocale ?? (() => null)
44765
46690
  };
44766
46691
  if (this.config.autoRefreshOnWalletEvents && eventEmitter) {
44767
46692
  this.setupEventSubscription();
@@ -44791,10 +46716,10 @@ class TokenCollectionManager {
44791
46716
  // Return cached if available and not stale
44792
46717
  if (cached?.lastCollection) {
44793
46718
  this.log(`Cache HIT for ${key}`);
44794
- return cached.lastCollection;
46719
+ return localizeTokenCollection(cached.lastCollection, this.config.getLocale());
44795
46720
  }
44796
46721
  this.log(`Cache MISS for ${key}, fetching...`);
44797
- return this.fetchAndCache(params);
46722
+ return localizeTokenCollection(await this.fetchAndCache(params), this.config.getLocale());
44798
46723
  }
44799
46724
  /**
44800
46725
  * Force refresh a specific collection
@@ -44836,7 +46761,7 @@ class TokenCollectionManager {
44836
46761
  return Array.from(this.collections.entries()).map(([_, entry]) => ({
44837
46762
  contractAddress: entry.params.contractAddress,
44838
46763
  accountAddress: entry.params.accountAddress,
44839
- collection: entry.lastCollection
46764
+ collection: entry.lastCollection ? localizeTokenCollection(entry.lastCollection, this.config.getLocale()) : null
44840
46765
  }));
44841
46766
  }
44842
46767
  /**
@@ -44957,7 +46882,7 @@ class TokenCollectionManager {
44957
46882
  // Notify subscribers
44958
46883
  for (const handler of this.handlers) {
44959
46884
  try {
44960
- handler(newCollection, entry.params.contractAddress, {
46885
+ handler(localizeTokenCollection(newCollection, this.config.getLocale()), entry.params.contractAddress, {
44961
46886
  type: source === 'refresh' ? 'refresh' : 'wallet_event',
44962
46887
  source
44963
46888
  });
@@ -45342,23 +47267,25 @@ class BalanceManager {
45342
47267
  * Simplified architecture with concrete classes
45343
47268
  */
45344
47269
  class Web3ApplicationService {
45345
- constructor(web3Api, ipfsApi) {
47270
+ constructor(web3Api, ipfsApi, getLocale = () => null) {
47271
+ this.ipfsApi = ipfsApi;
47272
+ this.getLocale = getLocale;
45346
47273
  // Create domain services with injected infrastructure dependencies
45347
47274
  this.contractDomainService = new ContractDomainService();
45348
- this.metadataDomainService = new MetadataDomainService(ipfsApi);
45349
- this.tokenDomainService = new TokenDomainService(web3Api, this.metadataDomainService, this.contractDomainService);
47275
+ this.tokenDomainService = new TokenDomainService(web3Api, this.ipfsApi, this.contractDomainService);
45350
47276
  }
45351
47277
  /**
45352
47278
  * Get balance and metadata for a specific token
45353
47279
  */
45354
47280
  async getSpecificTokenBalance(request) {
45355
- return this.tokenDomainService.getSpecificTokenBalance({
47281
+ const balance = await this.tokenDomainService.getSpecificTokenBalance({
45356
47282
  accountAddress: request.accountAddress || '',
45357
47283
  contractAddress: request.contractAddress,
45358
47284
  abi: request.abi,
45359
47285
  tokenId: request.tokenId || '',
45360
47286
  chainId: request.chainId
45361
47287
  });
47288
+ return localizeTokenBalance(balance, this.getLocale());
45362
47289
  }
45363
47290
  /**
45364
47291
  * Get metadata for a specific token from on-chain.
@@ -45378,14 +47305,15 @@ class Web3ApplicationService {
45378
47305
  tokenId: request.tokenId || '',
45379
47306
  chainId: request.chainId
45380
47307
  });
45381
- // Metadata is already normalized at infrastructure layer
45382
- return domainResult.metadata;
47308
+ // Metadata is already normalized at infrastructure layer; the cache holds the raw JSON,
47309
+ // so the language is applied here, at read time
47310
+ return domainResult.metadata ? localizeTokenMetadata(domainResult.metadata, this.getLocale()) : null;
45383
47311
  }
45384
47312
  /**
45385
47313
  * Retrieve entire collection of tokens with balance and metadata
45386
47314
  */
45387
47315
  async getTokenCollection(request) {
45388
- return this.tokenDomainService.getTokenCollection({
47316
+ const collection = await this.tokenDomainService.getTokenCollection({
45389
47317
  accountAddress: request.accountAddress || '',
45390
47318
  contractAddress: request.contractAddress,
45391
47319
  abi: request.abi,
@@ -45393,6 +47321,7 @@ class Web3ApplicationService {
45393
47321
  maxTokens: request.maxTokens,
45394
47322
  tokenIds: request.tokenIds
45395
47323
  });
47324
+ return localizeTokenCollection(collection, this.getLocale());
45396
47325
  }
45397
47326
  /**
45398
47327
  * Resolve IPFS URLs to HTTPS if needed.
@@ -45400,7 +47329,7 @@ class Web3ApplicationService {
45400
47329
  * IPFS gateway is resolved from tenant configuration (chain-agnostic).
45401
47330
  */
45402
47331
  async resolveIPFSUrl(url) {
45403
- return this.metadataDomainService.resolveIPFSUrl(url);
47332
+ return this.ipfsApi.resolveIPFSUrl(url);
45404
47333
  }
45405
47334
  /**
45406
47335
  * Get IPFS gateway domain from tenant configuration.
@@ -45409,7 +47338,7 @@ class Web3ApplicationService {
45409
47338
  * @returns Gateway domain (e.g., 'pers.mypinata.cloud')
45410
47339
  */
45411
47340
  async getIpfsGatewayDomain() {
45412
- return this.metadataDomainService.getIpfsGatewayDomain();
47341
+ return this.ipfsApi.getIpfsGatewayDomain();
45413
47342
  }
45414
47343
  /**
45415
47344
  * Fetch and process metadata from any URI with IPFS conversion.
@@ -45419,7 +47348,8 @@ class Web3ApplicationService {
45419
47348
  * IPFS gateway is resolved from tenant configuration (chain-agnostic).
45420
47349
  */
45421
47350
  async fetchAndProcessMetadata(tokenUri) {
45422
- return this.metadataDomainService.fetchAndProcessMetadata(tokenUri);
47351
+ const metadata = await this.ipfsApi.fetchAndProcessMetadata(tokenUri);
47352
+ return metadata ? localizeTokenMetadata(metadata, this.getLocale()) : null;
45423
47353
  }
45424
47354
  }
45425
47355
 
@@ -45496,109 +47426,37 @@ class Web3InfrastructureApi {
45496
47426
  }
45497
47427
 
45498
47428
  /**
45499
- * IPFSInfrastructureApi - Infrastructure implementation for IPFS operations
45500
- * Uses TenantManager for IPFS gateway resolution with centralized caching
45501
- *
45502
- * IMPORTANT: IPFS gateway domain is now fetched from tenant configuration (TenantClientConfigDTO),
45503
- * not from chain data. This is the correct architecture since IPFS is chain-agnostic.
47429
+ * IPFSInfrastructureApi - Web3-side adapter over the shared IPFS and token metadata resolvers.
47430
+ * Pass the SDK's shared `sources` to reuse its resolvers; otherwise they are built from `config`.
47431
+ * Results are raw (language-agnostic); the locale is applied where data is handed out.
45504
47432
  */
45505
47433
  class IPFSInfrastructureApi {
45506
- constructor(tenantManager) {
45507
- this.tenantManager = tenantManager;
45508
- this.cache = globalCacheService.createNamespace('ipfs');
47434
+ constructor(config, sources = createIpfsSources(config)) {
47435
+ this.resolver = sources.resolver;
47436
+ this.metadata = sources.metadata;
45509
47437
  }
45510
- /**
45511
- * Get IPFS gateway domain from tenant configuration.
45512
- *
45513
- * @returns The IPFS gateway domain from tenant configuration
45514
- * @throws Error if tenant config is not found or ipfsGatewayDomain is not configured
45515
- */
45516
- async getIpfsGatewayDomain() {
45517
- const cacheKey = 'gateway';
45518
- return this.cache.getOrSet(cacheKey, async () => {
45519
- const clientConfig = await this.tenantManager.getClientConfig();
45520
- if (!clientConfig.ipfsGatewayDomain) {
45521
- throw new Error(`IPFS gateway domain not configured for tenant. ` +
45522
- `Please configure ipfsGatewayDomain in the tenant configuration.`);
45523
- }
45524
- return clientConfig.ipfsGatewayDomain;
45525
- }, CacheTTL.GATEWAY);
47438
+ getIpfsGatewayDomain() {
47439
+ return this.resolver.getGatewayDomain();
45526
47440
  }
45527
- /**
45528
- * Resolve IPFS URL to HTTPS URL using tenant's configured gateway.
45529
- *
45530
- * @param url - The URL to resolve (can be ipfs:// or https://)
45531
- * @returns Resolved HTTPS URL
45532
- */
45533
- async resolveIPFSUrl(url) {
45534
- if (url.startsWith('ipfs://')) {
45535
- const gateway = await this.getIpfsGatewayDomain();
45536
- return url.replace('ipfs://', `https://${gateway}/ipfs/`);
45537
- }
45538
- return url;
47441
+ resolveIPFSUrl(url) {
47442
+ return this.resolver.resolveUrl(url);
45539
47443
  }
45540
- /**
45541
- * Fetch metadata from tokenURI, resolve all IPFS URLs, and normalize to SDK format.
45542
- *
45543
- * NORMALIZATION BOUNDARY: This is the single place where ERC metadata standard
45544
- * (snake_case: image, animation_url) is converted to SDK format (camelCase: imageUrl, animationUrl).
45545
- * All downstream consumers receive normalized SDK format with resolved HTTPS URLs.
45546
- *
45547
- * @param tokenUri - Token URI (can be ipfs:// or https://)
45548
- * @returns Normalized TokenMetadata with resolved HTTPS URLs, or null on error
45549
- */
45550
- async fetchAndProcessMetadata(tokenUri) {
45551
- try {
45552
- const resolvedUri = await this.resolveIPFSUrl(tokenUri);
45553
- const response = await fetch(resolvedUri);
45554
- if (!response.ok) {
45555
- throw new Error(`HTTP error! status: ${response.status}`);
45556
- }
45557
- const rawMetadata = await response.json();
45558
- // Resolve any IPFS URLs in string fields
45559
- for (const key of Object.keys(rawMetadata)) {
45560
- const value = rawMetadata[key];
45561
- if (typeof value === 'string' && value.startsWith('ipfs://')) {
45562
- rawMetadata[key] = await this.resolveIPFSUrl(value);
45563
- }
45564
- }
45565
- // Extract custom properties (anything not in ERC standard)
45566
- const customProperties = Object.fromEntries(Object.entries(rawMetadata).filter(([key]) => !['name', 'description', 'image', 'animation_url', 'external_url', 'attributes'].includes(key)));
45567
- // Return normalized SDK format
45568
- const result = {
45569
- name: String(rawMetadata.name ?? ''),
45570
- description: String(rawMetadata.description ?? ''),
45571
- imageUrl: String(rawMetadata.image ?? ''),
45572
- animationUrl: rawMetadata.animation_url ? String(rawMetadata.animation_url) : undefined,
45573
- externalUrl: rawMetadata.external_url ? String(rawMetadata.external_url) : undefined,
45574
- attributes: Array.isArray(rawMetadata.attributes) ? rawMetadata.attributes : [],
45575
- ...customProperties
45576
- };
45577
- return result;
45578
- }
45579
- catch (error) {
45580
- console.error('Error fetching metadata:', error);
45581
- return null;
45582
- }
47444
+ fetchAndProcessMetadata(tokenUri) {
47445
+ return this.metadata.fetch(tokenUri);
45583
47446
  }
45584
47447
  async fetchFromUrl(url) {
45585
47448
  try {
45586
- const response = await fetch(url);
45587
- if (!response.ok) {
45588
- throw new Error(`Failed to fetch from ${url}: ${response.statusText}`);
45589
- }
45590
- return await response.json();
47449
+ return await this.resolver.fetchJson(url);
45591
47450
  }
45592
47451
  catch (error) {
45593
47452
  console.error(`Error fetching from URL ${url}:`, error);
45594
47453
  throw error;
45595
47454
  }
45596
47455
  }
45597
- /**
45598
- * Clear IPFS cache for this service
45599
- */
47456
+ /** Clear cached gateway and metadata entries */
45600
47457
  clearCache() {
45601
- this.cache.clear();
47458
+ this.resolver.clearCache();
47459
+ this.metadata.clearCache();
45602
47460
  }
45603
47461
  }
45604
47462
 
@@ -45715,12 +47573,11 @@ class Web3Manager {
45715
47573
  this.web3ChainService = new Web3ChainService(web3ChainApi);
45716
47574
  // Initialize Web3 Application service with TenantManager for IPFS
45717
47575
  const web3InfrastructureApi = new Web3InfrastructureApi(this.web3ChainService);
45718
- const ipfsInfrastructureApi = new IPFSInfrastructureApi(this.tenantManager);
45719
- this.web3ApplicationService = new Web3ApplicationService(web3InfrastructureApi, ipfsInfrastructureApi);
47576
+ const ipfsInfrastructureApi = new IPFSInfrastructureApi(this.tenantManager, this.tenantManager.ipfs.sources);
47577
+ this.web3ApplicationService = new Web3ApplicationService(web3InfrastructureApi, ipfsInfrastructureApi, () => apiClient.getLocale());
45720
47578
  // Initialize domain services for managers
45721
47579
  const contractDomainService = new ContractDomainService();
45722
- const metadataDomainService = new MetadataDomainService(ipfsInfrastructureApi);
45723
- this.tokenDomainService = new TokenDomainService(web3InfrastructureApi, metadataDomainService, contractDomainService);
47580
+ this.tokenDomainService = new TokenDomainService(web3InfrastructureApi, ipfsInfrastructureApi, contractDomainService);
45724
47581
  }
45725
47582
  /**
45726
47583
  * Get the TokenCollectionManager for NFT collection operations.
@@ -45748,7 +47605,7 @@ class Web3Manager {
45748
47605
  */
45749
47606
  get collectionManager() {
45750
47607
  if (!this._collectionManager) {
45751
- this._collectionManager = new TokenCollectionManager(this.tokenDomainService, this.eventEmitter);
47608
+ this._collectionManager = new TokenCollectionManager(this.tokenDomainService, this.eventEmitter, { getLocale: () => this.apiClient.getLocale() });
45752
47609
  }
45753
47610
  return this._collectionManager;
45754
47611
  }
@@ -45819,10 +47676,10 @@ class Web3Manager {
45819
47676
  *
45820
47677
  * @param tokenUri - Token URI to fetch metadata from
45821
47678
  * @returns Promise resolving to processed metadata or null if not found
45822
- * @deprecated Use sdk.tenants.fetchAndProcessMetadata() instead - IPFS is chain-agnostic
47679
+ * @deprecated Use sdk.ipfs.fetchMetadata() instead - IPFS is chain-agnostic
45823
47680
  */
45824
47681
  async fetchAndProcessMetadata(tokenUri) {
45825
- return this.tenantManager.fetchAndProcessMetadata(tokenUri);
47682
+ return this.web3ApplicationService.fetchAndProcessMetadata(tokenUri);
45826
47683
  }
45827
47684
  /**
45828
47685
  * Get blockchain chain data by chain ID
@@ -46145,7 +48002,7 @@ const useWeb3 = () => {
46145
48002
  * Converts an ipfs:// URL to an HTTPS URL using the tenant's configured IPFS gateway.
46146
48003
  * Non-IPFS URLs are returned unchanged.
46147
48004
  *
46148
- * Note: Uses sdk.tenants for IPFS resolution (chain-agnostic).
48005
+ * Note: Uses sdk.ipfs for IPFS resolution (chain-agnostic).
46149
48006
  *
46150
48007
  * @param url - IPFS URL to resolve (ipfs://...)
46151
48008
  * @returns Promise resolving to HTTP gateway URL
@@ -46162,7 +48019,7 @@ const useWeb3 = () => {
46162
48019
  throw new Error('SDK not initialized. Call initialize() first.');
46163
48020
  }
46164
48021
  try {
46165
- const result = await sdk.tenants.resolveIpfsUrl(url);
48022
+ const result = await sdk.ipfs.resolveUrl(url);
46166
48023
  return result;
46167
48024
  }
46168
48025
  catch (error) {
@@ -46173,7 +48030,7 @@ const useWeb3 = () => {
46173
48030
  /**
46174
48031
  * Fetch and process token metadata from a URI
46175
48032
  *
46176
- * Note: Uses sdk.tenants for metadata fetching (chain-agnostic IPFS).
48033
+ * Note: Uses sdk.ipfs for metadata fetching (chain-agnostic IPFS).
46177
48034
  *
46178
48035
  * @param tokenUri - Token URI to fetch metadata from
46179
48036
  * @returns Promise resolving to processed metadata or null
@@ -46183,7 +48040,7 @@ const useWeb3 = () => {
46183
48040
  throw new Error('SDK not initialized. Call initialize() first.');
46184
48041
  }
46185
48042
  try {
46186
- const result = await sdk.tenants.fetchAndProcessMetadata(tokenUri);
48043
+ const result = await sdk.ipfs.fetchMetadata(tokenUri);
46187
48044
  return result;
46188
48045
  }
46189
48046
  catch (error) {
@@ -46810,7 +48667,7 @@ class AngularHttpClientAdapter {
46810
48667
  }
46811
48668
  async convertObservableToPromise(observable) {
46812
48669
  // Dynamically import firstValueFrom to avoid build-time dependency
46813
- const { firstValueFrom } = await Promise.resolve().then(function () { return require('./index-CzLxqePZ.js'); }).then(function (n) { return n.index; });
48670
+ const { firstValueFrom } = await import('rxjs');
46814
48671
  return firstValueFrom(observable);
46815
48672
  }
46816
48673
  mergeOptions(options) {
@@ -46876,7 +48733,10 @@ class BrowserFetchClientAdapter {
46876
48733
  }
46877
48734
  const response = await fetch(url, fetchOptions);
46878
48735
  if (!response.ok) {
46879
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
48736
+ const httpError = new Error(`HTTP ${response.status}: ${response.statusText}`);
48737
+ httpError.status = response.status;
48738
+ httpError.statusText = response.statusText;
48739
+ throw httpError;
46880
48740
  }
46881
48741
  // Handle different response types
46882
48742
  const contentType = response.headers.get('content-type');
@@ -48219,12 +50079,12 @@ const useBusiness = () => {
48219
50079
  * console.log('Business created:', business);
48220
50080
  * ```
48221
50081
  */
48222
- const createBusiness = react.useCallback(async (displayName) => {
50082
+ const createBusiness = react.useCallback(async (dto) => {
48223
50083
  if (!isInitialized || !sdk) {
48224
50084
  throw new Error('SDK not initialized. Call initialize() first.');
48225
50085
  }
48226
50086
  try {
48227
- const result = await sdk.businesses.createBusiness(displayName);
50087
+ const result = await sdk.businesses.createBusiness(dto);
48228
50088
  return result;
48229
50089
  }
48230
50090
  catch (error) {
@@ -49083,7 +50943,7 @@ const useRedemptions = () => {
49083
50943
  * Automatically handles IPFS URLs by resolving them to HTTPS via tenant's gateway.
49084
50944
  * Non-IPFS URLs are returned as-is. Handles loading and error states.
49085
50945
  *
49086
- * Uses sdk.tenants for IPFS resolution (lightweight, no Web3 deps).
50946
+ * Uses sdk.ipfs for IPFS resolution (lightweight, no Web3 deps).
49087
50947
  *
49088
50948
  * @param url - The URL to resolve (can be ipfs://, https://, or undefined)
49089
50949
  * @returns Object with resolved URL, loading state, and error
@@ -49115,7 +50975,7 @@ function useResolvedUrl(url) {
49115
50975
  }
49116
50976
  setIsLoading(true);
49117
50977
  setError(null);
49118
- sdk.tenants.resolveIpfsUrl(url)
50978
+ sdk.ipfs.resolveUrl(url)
49119
50979
  .then((resolved) => {
49120
50980
  setResolvedUrl(resolved);
49121
50981
  })
@@ -49142,7 +51002,7 @@ function useResolvedUrl(url) {
49142
51002
  * Handles IPFS resolution automatically and returns parsed metadata
49143
51003
  * with all nested IPFS URLs already resolved to HTTPS.
49144
51004
  *
49145
- * Uses sdk.tenants for metadata fetching (lightweight, no Web3 deps).
51005
+ * Uses sdk.ipfs for metadata fetching (lightweight, no Web3 deps).
49146
51006
  *
49147
51007
  * @param metadataUri - The metadata URI to fetch (can be ipfs:// or https://)
49148
51008
  * @returns Object with metadata, loading state, error, and refresh function
@@ -49167,7 +51027,7 @@ function useResolvedUrl(url) {
49167
51027
  * ```
49168
51028
  */
49169
51029
  function useMetadata(metadataUri) {
49170
- const { sdk, isInitialized } = usePersSDK();
51030
+ const { sdk, isInitialized, locale } = usePersSDK();
49171
51031
  const [metadata, setMetadata] = react.useState(null);
49172
51032
  const [isLoading, setIsLoading] = react.useState(false);
49173
51033
  const [error, setError] = react.useState(null);
@@ -49179,7 +51039,7 @@ function useMetadata(metadataUri) {
49179
51039
  setIsLoading(true);
49180
51040
  setError(null);
49181
51041
  try {
49182
- const result = await sdk.tenants.fetchAndProcessMetadata(metadataUri);
51042
+ const result = await sdk.ipfs.fetchMetadata(metadataUri);
49183
51043
  setMetadata(result);
49184
51044
  }
49185
51045
  catch (err) {
@@ -49189,12 +51049,12 @@ function useMetadata(metadataUri) {
49189
51049
  finally {
49190
51050
  setIsLoading(false);
49191
51051
  }
49192
- }, [metadataUri, isInitialized, sdk]);
51052
+ }, [metadataUri, isInitialized, sdk, locale]);
49193
51053
  react.useEffect(() => {
49194
51054
  fetchMetadata();
49195
51055
  }, [fetchMetadata]);
49196
51056
  return {
49197
- /** Parsed metadata with resolved IPFS URLs (imageUrl, animationUrl, etc.) */
51057
+ /** Parsed metadata with resolved IPFS URLs (imageUrl, animationUrl, etc.), localized for the current locale */
49198
51058
  metadata,
49199
51059
  /** Whether fetch is in progress */
49200
51060
  isLoading,
@@ -50823,6 +52683,8 @@ exports.AuthManager = AuthManager;
50823
52683
  exports.AuthService = AuthService;
50824
52684
  exports.AuthTokenManager = AuthTokenManager;
50825
52685
  exports.AuthenticationError = AuthenticationError;
52686
+ exports.BUSINESS_TRANSLATABLE_FIELDS = BUSINESS_TRANSLATABLE_FIELDS;
52687
+ exports.BUSINESS_TYPE_TRANSLATABLE_FIELDS = BUSINESS_TYPE_TRANSLATABLE_FIELDS;
50826
52688
  exports.BaseTokenService = BaseTokenService;
50827
52689
  exports.BookingApi = BookingApi;
50828
52690
  exports.BookingManager = BookingManager;
@@ -50833,6 +52695,11 @@ exports.BusinessManager = BusinessManager;
50833
52695
  exports.BusinessMembershipApi = BusinessMembershipApi;
50834
52696
  exports.BusinessMembershipService = BusinessMembershipService;
50835
52697
  exports.BusinessService = BusinessService;
52698
+ exports.CAMPAIGN_TRANSLATABLE_FIELDS = CAMPAIGN_TRANSLATABLE_FIELDS;
52699
+ exports.CAMPAIGN_TRIGGER_TRANSLATABLE_FIELDS = CAMPAIGN_TRIGGER_TRANSLATABLE_FIELDS;
52700
+ exports.CHAIN_READ_PERMISSIONS = CHAIN_READ_PERMISSIONS;
52701
+ exports.CHAIN_WRITE_PERMISSIONS = CHAIN_WRITE_PERMISSIONS;
52702
+ exports.CUSTOM_FIELD_TRANSLATABLE_FIELDS = CUSTOM_FIELD_TRANSLATABLE_FIELDS;
50836
52703
  exports.CacheService = CacheService;
50837
52704
  exports.CacheTTL = CacheTTL;
50838
52705
  exports.CampaignApi = CampaignApi;
@@ -50844,6 +52711,8 @@ exports.CommonErrorCodes = CommonErrorCodes;
50844
52711
  exports.CustomFieldDefinitionManager = CustomFieldDefinitionManager;
50845
52712
  exports.DATA_SOURCE_CHANNELS = DATA_SOURCE_CHANNELS;
50846
52713
  exports.DEFAULT_DATA_SOURCE = DEFAULT_DATA_SOURCE;
52714
+ exports.DEFAULT_PAGE = DEFAULT_PAGE;
52715
+ exports.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
50847
52716
  exports.DEFAULT_PERS_CONFIG = DEFAULT_PERS_CONFIG;
50848
52717
  exports.DPOP_STORAGE_KEYS = DPOP_STORAGE_KEYS;
50849
52718
  exports.DPoPManager = DPoPManager;
@@ -50858,6 +52727,7 @@ exports.ENGAGEMENT_RATE_FORMULA = ENGAGEMENT_RATE_FORMULA;
50858
52727
  exports.EntityTypes = EntityTypes;
50859
52728
  exports.ErrorDomains = ErrorDomains;
50860
52729
  exports.ErrorUtils = ErrorUtils;
52730
+ exports.ExternalEventType = ExternalEventType;
50861
52731
  exports.FATAL_AUTH_CODES = FATAL_AUTH_CODES;
50862
52732
  exports.FileApi = FileApi;
50863
52733
  exports.FileManager = FileManager;
@@ -50871,16 +52741,24 @@ exports.IMAGE_CDN_MAX_INPUT_SIZE = IMAGE_CDN_MAX_INPUT_SIZE;
50871
52741
  exports.IMAGE_CDN_PRESETS = IMAGE_CDN_PRESETS;
50872
52742
  exports.IMAGE_CDN_SUPPORTED_FORMATS = IMAGE_CDN_SUPPORTED_FORMATS;
50873
52743
  exports.IndexedDBTokenStorage = IndexedDBTokenStorage;
52744
+ exports.IpfsManager = IpfsManager;
50874
52745
  exports.KNOWN_SOURCE_DOMAINS = KNOWN_SOURCE_DOMAINS;
52746
+ exports.LOCALE_HEADER = LOCALE_HEADER;
52747
+ exports.LOCALE_MAX_LENGTH = LOCALE_MAX_LENGTH;
50875
52748
  exports.LOCATION_TYPES = LOCATION_TYPES;
50876
52749
  exports.LocalStorageTokenStorage = LocalStorageTokenStorage;
52750
+ exports.LocaleManager = LocaleManager;
50877
52751
  exports.LogoutRequired = LogoutRequired;
50878
52752
  exports.MEMBERSHIP_ROLE_HIERARCHY = MEMBERSHIP_ROLE_HIERARCHY;
50879
52753
  exports.MemoryTokenStorage = MemoryTokenStorage;
50880
52754
  exports.NativeTokenTypes = NativeTokenTypes;
50881
52755
  exports.NetworkError = NetworkError;
50882
52756
  exports.NodeHttpClientAdapter = NodeHttpClientAdapter;
52757
+ exports.NotificationApi = NotificationApi;
52758
+ exports.NotificationManager = NotificationManager;
52759
+ exports.NotificationService = NotificationService;
50883
52760
  exports.OttAuthMethod = OttAuthMethod;
52761
+ exports.PURCHASE_TOKEN_TRANSLATABLE_FIELDS = PURCHASE_TOKEN_TRANSLATABLE_FIELDS;
50884
52762
  exports.PaymentApi = PurchaseApi;
50885
52763
  exports.PaymentService = PaymentService;
50886
52764
  exports.PersApiClient = PersApiClient;
@@ -50892,12 +52770,18 @@ exports.PersSDKProvider = PersSDKProvider;
50892
52770
  exports.ProviderTokenRefreshNeeded = ProviderTokenRefreshNeeded;
50893
52771
  exports.PurchaseManager = PurchaseManager;
50894
52772
  exports.QUERY_OPERATOR_SQL_MAP = QUERY_OPERATOR_SQL_MAP;
52773
+ exports.REDEMPTION_ELIGIBILITY_GATES = REDEMPTION_ELIGIBILITY_GATES;
52774
+ exports.REDEMPTION_TRANSLATABLE_FIELDS = REDEMPTION_TRANSLATABLE_FIELDS;
52775
+ exports.REDEMPTION_TYPE_TRANSLATABLE_FIELDS = REDEMPTION_TYPE_TRANSLATABLE_FIELDS;
50895
52776
  exports.ReactNativeDPoPProvider = ReactNativeDPoPProvider;
50896
52777
  exports.ReactNativeHttpClient = ReactNativeHttpClient;
50897
52778
  exports.ReactNativeSecureStorage = ReactNativeSecureStorage;
50898
52779
  exports.RedemptionApi = RedemptionApi;
50899
52780
  exports.RedemptionManager = RedemptionManager;
50900
52781
  exports.RedemptionService = RedemptionService;
52782
+ exports.RedirectApi = RedirectApi;
52783
+ exports.RedirectManager = RedirectManager;
52784
+ exports.RedirectService = RedirectService;
50901
52785
  exports.SDK_NAME = SDK_NAME;
50902
52786
  exports.SDK_USER_AGENT = SDK_USER_AGENT;
50903
52787
  exports.SDK_VERSION = SDK_VERSION;
@@ -50906,9 +52790,16 @@ exports.SOURCE_LOGIC_TYPES = SOURCE_LOGIC_TYPES;
50906
52790
  exports.SOURCE_LOGIC_TYPE_VALUES = SOURCE_LOGIC_TYPE_VALUES;
50907
52791
  exports.SdkErrorCodes = SdkErrorCodes;
50908
52792
  exports.StaticJwtAuthProvider = StaticJwtAuthProvider;
52793
+ exports.TENANT_TRANSLATABLE_FIELDS = TENANT_TRANSLATABLE_FIELDS;
52794
+ exports.TOKEN_METADATA_TRANSLATABLE_FIELDS = TOKEN_METADATA_TRANSLATABLE_FIELDS;
50909
52795
  exports.TOKEN_VALIDITY_TYPE_VALUES = TOKEN_VALIDITY_TYPE_VALUES;
50910
52796
  exports.TRANSACTION_FORMATS = TRANSACTION_FORMATS;
50911
52797
  exports.TRANSACTION_FORMAT_DESCRIPTIONS = TRANSACTION_FORMAT_DESCRIPTIONS;
52798
+ exports.TRANSLATIONS_MAX_LOCALES = TRANSLATIONS_MAX_LOCALES;
52799
+ exports.TRANSLATIONS_MAX_SIZE = TRANSLATIONS_MAX_SIZE;
52800
+ exports.TRANSLATION_FIELD_MAX_LENGTH = TRANSLATION_FIELD_MAX_LENGTH;
52801
+ exports.TRANSLATION_VALUE_MAX_LENGTH = TRANSLATION_VALUE_MAX_LENGTH;
52802
+ exports.TRIGGER_SOURCE_TRANSLATABLE_FIELDS = TRIGGER_SOURCE_TRANSLATABLE_FIELDS;
50912
52803
  exports.TRIGGER_SOURCE_TYPES = TRIGGER_SOURCE_TYPES;
50913
52804
  exports.TRIGGER_SOURCE_TYPE_VALUES = TRIGGER_SOURCE_TYPE_VALUES;
50914
52805
  exports.TenantApi = TenantApi;
@@ -50928,6 +52819,7 @@ exports.TriggerSourceApi = TriggerSourceApi;
50928
52819
  exports.TriggerSourceManager = TriggerSourceManager;
50929
52820
  exports.TriggerSourceService = TriggerSourceService;
50930
52821
  exports.URL_REGEX = URL_REGEX;
52822
+ exports.USER_STATUS_TYPE_TRANSLATABLE_FIELDS = USER_STATUS_TYPE_TRANSLATABLE_FIELDS;
50931
52823
  exports.UUID_REGEX = UUID_REGEX;
50932
52824
  exports.UserApi = UserApi;
50933
52825
  exports.UserManager = UserManager;
@@ -50936,6 +52828,7 @@ exports.UserStatusApi = UserStatusApi;
50936
52828
  exports.UserStatusManager = UserStatusManager;
50937
52829
  exports.UserStatusService = UserStatusService;
50938
52830
  exports.VALID_BUSINESS_MEMBERSHIP_RELATIONS = VALID_BUSINESS_MEMBERSHIP_RELATIONS;
52831
+ exports.VALID_BUSINESS_RELATIONS = VALID_BUSINESS_RELATIONS;
50939
52832
  exports.VALID_CAMPAIGN_CLAIM_RELATIONS = VALID_CAMPAIGN_CLAIM_RELATIONS;
50940
52833
  exports.VALID_CAMPAIGN_RELATIONS = VALID_CAMPAIGN_RELATIONS;
50941
52834
  exports.VALID_REDEMPTION_REDEEM_RELATIONS = VALID_REDEMPTION_REDEEM_RELATIONS;
@@ -50951,6 +52844,7 @@ exports.WebhookApi = WebhookApi;
50951
52844
  exports.WebhookManager = WebhookManager;
50952
52845
  exports.WebhookService = WebhookService;
50953
52846
  exports.apiPublicKeyTestPrefix = apiPublicKeyTestPrefix;
52847
+ exports.assertAvailable = assertAvailable;
50954
52848
  exports.buildApiRoot = buildApiRoot;
50955
52849
  exports.buildBurnRequest = buildBurnRequest;
50956
52850
  exports.buildImageCdnUrl = buildImageCdnUrl;
@@ -50962,9 +52856,12 @@ exports.buildPaginationParams = buildPaginationParams;
50962
52856
  exports.buildPendingTransactionData = buildPendingTransactionData;
50963
52857
  exports.buildSubmissionRequest = buildSubmissionRequest;
50964
52858
  exports.buildTransferRequest = buildTransferRequest;
52859
+ exports.buildTranslationsPatch = buildTranslationsPatch;
50965
52860
  exports.buildWalletEventsWsUrl = buildWalletEventsWsUrl;
50966
52861
  exports.builtInUserFieldMetadata = builtInUserFieldMetadata;
50967
52862
  exports.builtInUserFields = builtInUserFields;
52863
+ exports.canManageCampaignTriggerSources = canManageCampaignTriggerSources;
52864
+ exports.cleanTranslations = cleanTranslations;
50968
52865
  exports.compareValues = compareValues;
50969
52866
  exports.computeBookingStatus = computeBookingStatus;
50970
52867
  exports.createPersEventsClient = createPersEventsClient;
@@ -50974,18 +52871,21 @@ exports.createUserIdentifierObject = createUserIdentifierObject;
50974
52871
  exports.createUserStatusSDK = createUserStatusSDK;
50975
52872
  exports.customFieldEntityTypes = customFieldEntityTypes;
50976
52873
  exports.detectChannelFromUserAgent = detectChannelFromUserAgent;
52874
+ exports.detectDeviceLocale = detectDeviceLocale;
50977
52875
  exports.detectEnvironment = detectEnvironment;
50978
52876
  exports.detectIdentifierType = detectIdentifierType;
50979
52877
  exports.detectReactNativePlatform = detectReactNativePlatform;
50980
52878
  exports.detectSourceFromReferrer = detectSourceFromReferrer;
50981
52879
  exports.detectWebPlatform = detectWebPlatform;
50982
52880
  exports.environment = environment;
52881
+ exports.evaluateRedemptionEligibility = evaluateRedemptionEligibility;
50983
52882
  exports.extractData = extractData;
50984
52883
  exports.extractDeadlineFromSigningData = extractDeadlineFromSigningData;
50985
52884
  exports.extractIpfsCid = extractIpfsCid;
50986
52885
  exports.extractPagination = extractPagination;
50987
52886
  exports.fetchAllPages = fetchAllPages;
50988
52887
  exports.getAllBuiltInFieldDefinitions = getAllBuiltInFieldDefinitions;
52888
+ exports.getAvailabilityStatus = getAvailabilityStatus;
50989
52889
  exports.getBuiltInFieldDefinition = getBuiltInFieldDefinition;
50990
52890
  exports.getMetadataFromTokenUnitResponse = getMetadataFromTokenUnitResponse;
50991
52891
  exports.getOperatorText = getOperatorText;
@@ -50994,6 +52894,7 @@ exports.getSelectOptionsLabelDefault = getSelectOptionsLabelDefault;
50994
52894
  exports.getSigningUrl = getSigningUrl;
50995
52895
  exports.globalCacheService = globalCacheService;
50996
52896
  exports.hasMinimumRole = hasMinimumRole;
52897
+ exports.inheritTranslations = inheritTranslations;
50997
52898
  exports.initializeReactNativePolyfills = initializeReactNativePolyfills;
50998
52899
  exports.isBookingStatusValid = isBookingStatusValid;
50999
52900
  exports.isBuiltInUserField = isBuiltInUserField;
@@ -51003,6 +52904,8 @@ exports.isErrorMessage = isErrorMessage;
51003
52904
  exports.isEventMessage = isEventMessage;
51004
52905
  exports.isFatalAuthErrorInMessage = isFatalAuthErrorInMessage;
51005
52906
  exports.isIpfsUrl = isIpfsUrl;
52907
+ exports.isNftTokenType = isNftTokenType;
52908
+ exports.isNotificationPendingMessage = isNotificationPendingMessage;
51006
52909
  exports.isPaginatedResponse = isPaginatedResponse;
51007
52910
  exports.isPingMessage = isPingMessage;
51008
52911
  exports.isServerMessage = isServerMessage;
@@ -51012,6 +52915,7 @@ exports.isUUID = isUUID;
51012
52915
  exports.isUnsubscribedMessage = isUnsubscribedMessage;
51013
52916
  exports.isUserIdentifierObject = isUserIdentifierObject;
51014
52917
  exports.isValidBusinessMembershipRelation = isValidBusinessMembershipRelation;
52918
+ exports.isValidBusinessRelation = isValidBusinessRelation;
51015
52919
  exports.isValidCampaignClaimRelation = isValidCampaignClaimRelation;
51016
52920
  exports.isValidCampaignRelation = isValidCampaignRelation;
51017
52921
  exports.isValidEmail = isValidEmail;
@@ -51026,16 +52930,23 @@ exports.isValidUrl = isValidUrl;
51026
52930
  exports.isValidUserRelation = isValidUserRelation;
51027
52931
  exports.isValidUuid = isValidUuid;
51028
52932
  exports.isValidWalletAddress = isValidWalletAddress;
52933
+ exports.localizeFields = localizeFields;
52934
+ exports.meetsMinimumStatusRequirement = meetsMinimumStatusRequirement;
52935
+ exports.mergeTranslations = mergeTranslations;
51029
52936
  exports.mergeWithDefaults = mergeWithDefaults;
51030
52937
  exports.needsExternalSigning = needsExternalSigning;
51031
52938
  exports.needsSubmission = needsSubmission;
51032
52939
  exports.normalizeFilters = normalizeFilters;
52940
+ exports.normalizeLocale = normalizeLocale;
51033
52941
  exports.normalizeToPaginated = normalizeToPaginated;
51034
52942
  exports.operatorText = operatorText;
51035
52943
  exports.parseUserAgentForPlatform = parseUserAgentForPlatform;
51036
52944
  exports.parseUtmParams = parseUtmParams;
52945
+ exports.pickTranslationFields = pickTranslationFields;
51037
52946
  exports.registerIdentifierType = registerIdentifierType;
52947
+ exports.resolveTranslation = resolveTranslation;
51038
52948
  exports.selectOptionsSourceEntities = selectOptionsSourceEntities;
52949
+ exports.setIncludeParam = setIncludeParam;
51039
52950
  exports.testnetPrefix = testnetPrefix;
51040
52951
  exports.testnetShortPrefix = testnetShortPrefix;
51041
52952
  exports.uniqueUserIdentifierKeys = uniqueUserIdentifierKeys;
@@ -51067,5 +52978,7 @@ exports.userModelFields = userModelFields;
51067
52978
  exports.validateAllFields = validateAllFields;
51068
52979
  exports.validateFieldComparisons = validateFieldComparisons;
51069
52980
  exports.validateFieldValue = validateFieldValue;
52981
+ exports.validateTranslationsLimits = validateTranslationsLimits;
52982
+ exports.validateTranslationsPatch = validateTranslationsPatch;
51070
52983
  exports.warnIfProblematicEnvironment = warnIfProblematicEnvironment;
51071
52984
  //# sourceMappingURL=index.js.map