diff --git a/.eas/README.md b/.eas/README.md index ff46c28..bc60318 100644 --- a/.eas/README.md +++ b/.eas/README.md @@ -4,6 +4,17 @@ Kokio uses EAS Workflows to automate native builds, store submissions, TestFligh These workflows are run on-demand from GitHub Actions after protected pull request merges to `staging` and `production`. +## Platform Model + +Workflows are **scoped to a single platform**. The router resolves which platforms a release targets: + +- **Android is always released.** No label required. +- **iOS is opt-in.** Add the `ios` label to the promotion PR (or choose `ios`/`both` on manual dispatch). + +iOS workflow files exist but are **dormant** — they are not invoked until an Apple Developer account is connected. This keeps a missing Apple account from failing an Android release, and keeps the iOS cutover to a label rather than a config change. + +Platform resolution lives **only** in `.github/workflows/route-eas-release.yml`. `scripts/validate-release-pr.mjs` stays platform-agnostic on purpose, so there is one place to edit when iOS comes online. + ## Branch Purposes ### `dev` @@ -26,7 +37,7 @@ The `staging` branch is for release candidate testing. - Runs OTA updates for PRs labeled `ota` only when they change approved OTA-safe paths and a compatible finished build already exists on Expo for both platforms. - Falls back to native builds for version, native, config, dependency, and uncertain changes. - Submits Android builds to Google Play open testing. -- Distributes iOS builds through TestFlight external testing in the `Public` group. +- Distributes iOS builds through TestFlight external testing in the `Public` group (when `ios` is opted in). ### `production` @@ -39,7 +50,7 @@ The `production` branch is for real app releases. - Runs OTA updates for PRs labeled `ota` only when they change approved OTA-safe paths and a compatible finished build already exists on Expo for both platforms. - Falls back to native builds for version, native, config, dependency, and uncertain changes. - Submits Android builds to the Google Play production track. -- Submits iOS builds to App Store Connect. +- Submits iOS builds to App Store Connect (when `ios` is opted in). ## Release Versioning @@ -61,69 +72,41 @@ The `Release Policy` GitHub Action validates that: ### `workflows/create-dev-builds.yml` -Runs on pushes to `dev`. - -This workflow builds: - -- Android with the `development` EAS build profile. -- iOS device builds with the `development` EAS build profile. - -### `workflows/deploy-staging.yml` - -Runs on-demand from GitHub Actions for merged PRs to `staging` that do not have the `ota` label. - -This workflow: - -1. Creates a new Android build with the `staging` profile. -2. Creates a new iOS build with the `staging` profile. -3. Submits Android to Google Play open testing. -4. Distributes iOS through TestFlight external testing in the `Public` group. - -The staging workflow uses the EAS `preview` environment for submission and distribution jobs so cloud jobs pull staging-safe environment variables instead of production values. - -### `workflows/publish-staging-ota.yml` - -Runs on-demand from GitHub Actions for merged PRs to `staging` that have the `ota` label. - -This workflow: - -1. Publishes an Android OTA update to the `staging` branch. -2. Publishes an iOS OTA update to the `staging` branch. - -### `workflows/deploy-production.yml` - -Runs on-demand from GitHub Actions for merged PRs to `production` that do not have the `ota` label. - -This workflow: +Runs on pushes to `dev`. Builds Android and iOS device builds with the `development` profile. -1. Creates a new Android build with the `production` profile. -2. Creates a new iOS build with the `production` profile. -3. Submits Android to the Google Play production track. -4. Submits iOS to App Store Connect. +### Native build & submit -### `workflows/publish-production-ota.yml` +| File | Runs when | Does | +|---|---|---| +| `workflows/deploy-staging-android.yml` | merged PR to `staging`, no `ota` label | Android `staging` build → Google Play open testing | +| `workflows/deploy-staging-ios.yml` | as above **+ `ios` label** | iOS `staging` build → TestFlight (`Public`, beta review submitted) | +| `workflows/deploy-production-android.yml` | merged PR to `production`, no `ota` label | Android `production` build → Play production track | +| `workflows/deploy-production-ios.yml` | as above **+ `ios` label** | iOS `production` build → App Store Connect | -Runs on-demand from GitHub Actions for merged PRs to `production` that have the `ota` label. +Staging workflows use the EAS `preview` environment so cloud jobs pull staging-safe environment variables instead of production values. -This workflow: +### OTA -1. Publishes an Android OTA update to the `production` branch. -2. Publishes an iOS OTA update to the `production` branch. +| File | Runs when | Does | +|---|---|---| +| `workflows/publish-staging-ota-android.yml` | merged PR to `staging` with `ota` | Android OTA to the `staging` branch | +| `workflows/publish-staging-ota-ios.yml` | as above **+ `ios` label** | iOS OTA to the `staging` branch | +| `workflows/publish-production-ota-android.yml` | merged PR to `production` with `ota` | Android OTA to the `production` branch | +| `workflows/publish-production-ota-ios.yml` | as above **+ `ios` label** | iOS OTA to the `production` branch | ### `.github/workflows/route-eas-release.yml` Runs when a PR is merged into `staging` or `production`. -This workflow: +1. Reads the merged PR's target branch and labels. +2. Resolves platforms: Android always; iOS if the `ios` label is present. +3. Derives the workflow file per platform: + - `ota` label → `.eas/workflows/publish-{base}-ota-{platform}.yml` + - no `ota` label → `.eas/workflows/deploy-{base}-{platform}.yml` +4. For OTA-labeled PRs, verifies Expo already has a finished store build **for each resolved platform** at the current app/runtime version on the target profile (`scripts/verify-ota-preflight.mjs`, via `OTA_PLATFORMS`). +5. Calls `eas workflow:run` once per resolved platform with the exact merge commit SHA. -1. Reads the merged PR target branch and labels. -2. Chooses the correct EAS workflow: - - `ota` label on `staging` -> `publish-staging-ota.yml` - - no `ota` label on `staging` -> `deploy-staging.yml` - - `ota` label on `production` -> `publish-production-ota.yml` - - no `ota` label on `production` -> `deploy-production.yml` -3. For OTA-labeled PRs, verifies Expo already has finished store builds for Android and iOS at the current app/runtime version on the target profile. -4. Calls `eas workflow:run` with the exact merge commit SHA. +`workflow_dispatch` supports an explicit `workflow_file`, which bypasses platform resolution entirely. ## OTA Trigger Rules @@ -132,7 +115,7 @@ OTA is triggered only when all of these are true: - the merged PR targets `staging` or `production` - the merged PR carried the `ota` label and passed release validation - the PR changed only approved OTA-safe paths -- Expo already has finished store builds for the target app/runtime version on both platforms +- Expo already has a finished store build at the target app/runtime version **for each platform being published** OTA is not triggered when any of these are true: diff --git a/.eas/workflows/deploy-production.yml b/.eas/workflows/deploy-production-android.yml similarity index 50% rename from .eas/workflows/deploy-production.yml rename to .eas/workflows/deploy-production-android.yml index 5126566..e46630d 100644 --- a/.eas/workflows/deploy-production.yml +++ b/.eas/workflows/deploy-production-android.yml @@ -1,4 +1,4 @@ -name: Deploy production +name: Deploy production (Android) jobs: build_android: @@ -8,13 +8,6 @@ jobs: platform: android profile: production - build_ios: - name: Build iOS - type: build - params: - platform: ios - profile: production - submit_android: name: Submit Android to Play production needs: [build_android] @@ -23,12 +16,3 @@ jobs: params: build_id: ${{ needs.build_android.outputs.build_id }} profile: production - - submit_ios: - name: Submit iOS to App Store Connect - needs: [build_ios] - type: submit - environment: production - params: - build_id: ${{ needs.build_ios.outputs.build_id }} - profile: production diff --git a/.eas/workflows/deploy-production-ios.yml b/.eas/workflows/deploy-production-ios.yml new file mode 100644 index 0000000..10fef64 --- /dev/null +++ b/.eas/workflows/deploy-production-ios.yml @@ -0,0 +1,18 @@ +name: Deploy production (iOS) + +jobs: + build_ios: + name: Build iOS + type: build + params: + platform: ios + profile: production + + submit_ios: + name: Submit iOS to App Store Connect + needs: [build_ios] + type: submit + environment: production + params: + build_id: ${{ needs.build_ios.outputs.build_id }} + profile: production diff --git a/.eas/workflows/deploy-staging-android.yml b/.eas/workflows/deploy-staging-android.yml new file mode 100644 index 0000000..6a512bd --- /dev/null +++ b/.eas/workflows/deploy-staging-android.yml @@ -0,0 +1,18 @@ +name: Deploy staging (Android) + +jobs: + build_android: + name: Build Android + type: build + params: + platform: android + profile: staging + + submit_android: + name: Submit Android to open testing + needs: [build_android] + type: submit + environment: preview + params: + build_id: ${{ needs.build_android.outputs.build_id }} + profile: staging diff --git a/.eas/workflows/deploy-staging.yml b/.eas/workflows/deploy-staging-ios.yml similarity index 52% rename from .eas/workflows/deploy-staging.yml rename to .eas/workflows/deploy-staging-ios.yml index f42a102..3719fca 100644 --- a/.eas/workflows/deploy-staging.yml +++ b/.eas/workflows/deploy-staging-ios.yml @@ -1,13 +1,6 @@ -name: Deploy staging +name: Deploy staging (iOS) jobs: - build_android: - name: Build Android - type: build - params: - platform: android - profile: staging - build_ios: name: Build iOS type: build @@ -15,15 +8,6 @@ jobs: platform: ios profile: staging - submit_android: - name: Submit Android to open testing - needs: [build_android] - type: submit - environment: preview - params: - build_id: ${{ needs.build_android.outputs.build_id }} - profile: staging - distribute_ios_testflight: name: Distribute iOS to TestFlight needs: [build_ios] diff --git a/.eas/workflows/publish-producion-ota-ios.yml b/.eas/workflows/publish-producion-ota-ios.yml new file mode 100644 index 0000000..c1e9891 --- /dev/null +++ b/.eas/workflows/publish-producion-ota-ios.yml @@ -0,0 +1,10 @@ +name: Publish production OTA (iOS) + +jobs: + publish_ios_update: + name: Publish iOS production OTA update + type: update + environment: production + params: + branch: production + platform: ios diff --git a/.eas/workflows/publish-production-ota-android.yml b/.eas/workflows/publish-production-ota-android.yml new file mode 100644 index 0000000..17abb7c --- /dev/null +++ b/.eas/workflows/publish-production-ota-android.yml @@ -0,0 +1,10 @@ +name: Publish production OTA (Android) + +jobs: + publish_android_update: + name: Publish Android production OTA update + type: update + environment: production + params: + branch: production + platform: android diff --git a/.eas/workflows/publish-production-ota.yml b/.eas/workflows/publish-production-ota.yml deleted file mode 100644 index aee3106..0000000 --- a/.eas/workflows/publish-production-ota.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Publish production OTA - -jobs: - publish_android_update: - name: Publish Android production OTA update - type: update - environment: production - params: - branch: production - platform: android - - publish_ios_update: - name: Publish iOS production OTA update - type: update - environment: production - params: - branch: production - platform: ios diff --git a/.eas/workflows/publish-staging-ota-android.yml b/.eas/workflows/publish-staging-ota-android.yml new file mode 100644 index 0000000..b35727c --- /dev/null +++ b/.eas/workflows/publish-staging-ota-android.yml @@ -0,0 +1,10 @@ +name: Publish staging OTA (Android) + +jobs: + publish_android_update: + name: Publish Android staging OTA update + type: update + environment: preview + params: + branch: staging + platform: android diff --git a/.eas/workflows/publish-staging-ota-ios.yml b/.eas/workflows/publish-staging-ota-ios.yml new file mode 100644 index 0000000..30039c0 --- /dev/null +++ b/.eas/workflows/publish-staging-ota-ios.yml @@ -0,0 +1,10 @@ +name: Publish staging OTA (iOS) + +jobs: + publish_ios_update: + name: Publish iOS staging OTA update + type: update + environment: preview + params: + branch: staging + platform: ios diff --git a/.eas/workflows/publish-staging-ota.yml b/.eas/workflows/publish-staging-ota.yml deleted file mode 100644 index e43e03a..0000000 --- a/.eas/workflows/publish-staging-ota.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Publish staging OTA - -jobs: - publish_android_update: - name: Publish Android staging OTA update - type: update - environment: preview - params: - branch: staging - platform: android - - publish_ios_update: - name: Publish iOS staging OTA update - type: update - environment: preview - params: - branch: staging - platform: ios diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b458826 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Default code owners for all files. +# These users will be automatically requested for review on PRs to default branch. +* @ManulParihar +* @ArpitxGit diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7ca23fb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,107 @@ +# Dependabot configuration for Kokio app. +# Runs weekly on Monday against default branch (`dev`). +# Major version bumps are always individual PRs. +# Expo SDK packages are grouped to surface coordinated updates as a unit. +# React Native ecosystem packages are grouped separately as they require native rebuilds. + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + timezone: "UTC" + target-branch: "dev" + open-pull-requests-limit: 10 + reviewers: + - "ManulParihar" + - "ArpitxGit" + labels: + - "dependencies" + commit-message: + prefix: "deps" + groups: + expo-ecosystem: + patterns: + - "expo" + - "expo-*" + - "@expo/*" + - "jest-expo" + update-types: + - "minor" + - "patch" + react-native-ecosystem: + patterns: + - "react" + - "react-dom" + - "react-native" + - "react-native-*" + - "@react-native-*" + - "@react-navigation/*" + - "nativewind" + - "tailwind-merge" + update-types: + - "minor" + - "patch" + dev-tooling: + patterns: + - "typescript" + - "eslint" + - "eslint-*" + - "@typescript-eslint/*" + - "jest" + - "@types/*" + - "@babel/*" + - "babel-*" + - "audit-ci" + - "tailwindcss" + - "openapi-typescript" + - "patch-package" + update-types: + - "minor" + - "patch" + production-deps: + patterns: + - "*" + exclude-patterns: + - "expo" + - "expo-*" + - "@expo/*" + - "jest-expo" + - "react" + - "react-dom" + - "react-native" + - "react-native-*" + - "@react-native-*" + - "@react-navigation/*" + - "nativewind" + - "tailwind-merge" + - "typescript" + - "eslint" + - "eslint-*" + - "@typescript-eslint/*" + - "jest" + - "@types/*" + - "@babel/*" + - "babel-*" + - "audit-ci" + - "tailwindcss" + - "openapi-typescript" + - "patch-package" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + timezone: "UTC" + target-branch: "dev" + open-pull-requests-limit: 5 + labels: + - "ci" + commit-message: + prefix: "ci" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a14ef00 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,53 @@ +## Summary + + + +## Type + +- [ ] Feature +- [ ] Bug fix +- [ ] Refactor +- [ ] Tests +- [ ] CI / DevOps +- [ ] Documentation +- [ ] Dependency update + +## Checklist + +**General** +- [ ] No secrets, credentials, or environment values in diff +- [ ] No `console.log` or debug statements left in production code +- [ ] No new `^` or `~` semver ranges introduced in `package.json` + +**Quality gate** *(skip for documentation-only PRs)* +- [ ] `tsc --noEmit` passes +- [ ] `npm run lint` passes +- [ ] `npm run audit` passes + +**Testing** +- [ ] Tested locally on iOS +- [ ] Tested locally on Android +- [ ] Affected auth flow verified (passkey register / login / step-up) + +**Native changes** *(if applicable)* +- [ ] Requires native rebuild — reviewer and CI are aware +- [ ] `ios/` and `android/` changes are intentional +- [ ] Podfile.lock updated and committed + +**API / contract changes** *(if applicable)* +- [ ] Behaviour verified against the BFF or Auth Server OpenAPI spec +- [ ] Generated types regenerated (`npm run gen:bff-types` / `npm run gen:auth-types`) + +**Dependencies** *(if applicable)* +- [ ] `package.json` version bumped +- [ ] Added packages are compatible with installed Expo SDK (`npx expo install --check`) +- [ ] No new vulnerabilities introduced (`npm run audit`) + +**Release** *(for `dev` → `staging` PRs)* +- [ ] Version bumped in `package.json` +- [ ] PR labelled correctly (`ota` if applicable) + +## Notes for reviewer + + diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 124a8b9..7dd35d2 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -51,6 +51,9 @@ jobs: - name: Lint run: npm run lint + - name: Stray Logs + run: npm run lint:console + # Deferred # The suite is not actively maintained against this PR gate yet. # Re-enable this step once test suite is up-to-date w.r.t. tooling and functionality. diff --git a/.github/workflows/route-eas-release.yml b/.github/workflows/route-eas-release.yml index 812f4ff..59130ce 100644 --- a/.github/workflows/route-eas-release.yml +++ b/.github/workflows/route-eas-release.yml @@ -1,5 +1,8 @@ name: Route EAS Release +# - Android is always released. +# - iOS is opt-in: add the `ios` label to the promotion PR. +# iOS workflow files configured to stay dormant until an Apple Developer account is connected. on: pull_request_target: branches: @@ -14,7 +17,7 @@ on: required: true type: string workflow_file: - description: Optional explicit EAS workflow file path + description: Optional explicit EAS workflow file path (bypasses platform resolution) required: false default: "" type: string @@ -31,6 +34,15 @@ on: required: false default: false type: boolean + platforms: + description: Platforms to release + required: false + default: android + type: choice + options: + - android + - ios + - both jobs: route-release: @@ -66,7 +78,7 @@ jobs: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - - name: Select EAS workflow + - name: Select EAS workflows id: select env: EVENT_NAME: ${{ github.event_name }} @@ -76,49 +88,59 @@ jobs: INPUT_OTA: ${{ inputs.ota }} INPUT_WORKFLOW_FILE: ${{ inputs.workflow_file }} INPUT_REF: ${{ inputs.ref }} + INPUT_PLATFORMS: ${{ inputs.platforms }} MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | node - <<'EOF' - const labels = JSON.parse(process.env.LABELS_JSON || "[]"); - const isManual = process.env.EVENT_NAME === "workflow_dispatch"; + const fs = require("node:fs"); + + const labels = JSON.parse(process.env.LABELS_JSON || "[]"); + const isManual = process.env.EVENT_NAME === "workflow_dispatch"; + const hasOtaLabel = isManual ? process.env.INPUT_OTA === "true" : labels.includes("ota"); + const baseBranch = isManual ? process.env.INPUT_BASE_BRANCH : process.env.BASE_BRANCH; + const targetRef = isManual ? process.env.INPUT_REF : process.env.MERGE_COMMIT_SHA; - const manualWorkflow = process.env.INPUT_WORKFLOW_FILE.trim(); - - const workflowFromInputs = manualWorkflow - ? manualWorkflow - : baseBranch === "staging" - ? hasOtaLabel - ? ".eas/workflows/publish-staging-ota.yml" - : ".eas/workflows/deploy-staging.yml" - : hasOtaLabel - ? ".eas/workflows/publish-production-ota.yml" - : ".eas/workflows/deploy-production.yml"; - - require("node:fs").appendFileSync( - process.env.GITHUB_OUTPUT, - `base_branch=${baseBranch}\n` - ); - require("node:fs").appendFileSync( - process.env.GITHUB_OUTPUT, - `is_ota=${hasOtaLabel}\n` - ); - - require("node:fs").appendFileSync( - process.env.GITHUB_OUTPUT, - `workflow=${workflowFromInputs}\n` - ); - require("node:fs").appendFileSync( - process.env.GITHUB_OUTPUT, - `target_ref=${targetRef}\n` - ); + + const manualWorkflow = (process.env.INPUT_WORKFLOW_FILE || "").trim(); + + let platforms; + if (isManual) { + const choice = process.env.INPUT_PLATFORMS || "android"; + platforms = choice === "both" ? ["android", "ios"] : [choice]; + } else { + platforms = ["android"]; + if (labels.includes("ios")) platforms.push("ios"); + } + + // ── Workflow file resolution ────────────────────────────────────── + const fileFor = (platform) => + hasOtaLabel + ? `.eas/workflows/publish-${baseBranch}-ota-${platform}.yml` + : `.eas/workflows/deploy-${baseBranch}-${platform}.yml`; + + // An explicit manual workflow_file bypasses resolution entirely. + const workflows = manualWorkflow + ? [manualWorkflow] + : platforms.map(fileFor); + + const out = [ + `base_branch=${baseBranch}`, + `is_ota=${hasOtaLabel}`, + `target_ref=${targetRef}`, + `platforms=${platforms.join(" ")}`, + `workflows=${workflows.join(" ")}`, + ].join("\n"); + + fs.appendFileSync(process.env.GITHUB_OUTPUT, out + "\n"); + console.log(out); EOF - name: Verify OTA preflight @@ -126,10 +148,17 @@ jobs: env: RELEASE_BASE_BRANCH: ${{ steps.select.outputs.base_branch }} RELEASE_HAS_OTA_LABEL: "true" + OTA_PLATFORMS: ${{ steps.select.outputs.platforms }} run: node scripts/verify-ota-preflight.mjs - - name: Run EAS workflow - run: > - eas workflow:run ${{ steps.select.outputs.workflow }} - --ref ${{ steps.select.outputs.target_ref }} - --non-interactive + - name: Run EAS workflows + env: + WORKFLOWS: ${{ steps.select.outputs.workflows }} + TARGET_REF: ${{ steps.select.outputs.target_ref }} + run: | + set -euo pipefail + for workflow in $WORKFLOWS; do + echo "::group::eas workflow:run $workflow" + eas workflow:run "$workflow" --ref "$TARGET_REF" --non-interactive + echo "::endgroup::" + done diff --git a/.gitignore b/.gitignore index 675754b..e142d74 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ npm-debug.* *.key *.mobileprovision *.orig.* -web-build/ .env* # ios/ # android/ @@ -31,7 +30,6 @@ node_modules/ # Expo .expo/ dist/ -web-build/ expo-env.d.ts # Native @@ -68,4 +66,4 @@ yarn-error.* docs/ # Test coverage -coverage/ \ No newline at end of file +coverage/ diff --git a/__tests__/integration/bff-topup-flow.test.ts b/__tests__/integration/bff-topup-flow.test.ts index d9c0017..19d05ed 100644 --- a/__tests__/integration/bff-topup-flow.test.ts +++ b/__tests__/integration/bff-topup-flow.test.ts @@ -73,7 +73,7 @@ describe('single compatible eSIM → topup', () => { catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, - eSimId: selectedEsim, + esimId: selectedEsim, isCryptoPayment: true, payeeAddress: DEVICE_WALLET, } as any); @@ -81,7 +81,7 @@ describe('single compatible eSIM → topup', () => { expect(order.orderId).toBe('ord-topup-001'); }); - it('topup order body has isNewESim: false and the selected eSimId', async () => { + it('topup order body has isNewESim: false and the selected esimId', async () => { mockGet.mockResolvedValueOnce(compatEnvelope([COMPAT_A])); const compat = await checkEsimCompatibility({ planId: PLAN_ID }, ESIM_A); const selectedEsim = compat.results.find((r: any) => r.compatible)!.esimId; @@ -91,20 +91,20 @@ describe('single compatible eSIM → topup', () => { catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, - eSimId: selectedEsim, + esimId: selectedEsim, isCryptoPayment: true, payeeAddress: DEVICE_WALLET, } as any); const [, body] = mockPost.mock.calls[0]; - expect(body).toMatchObject({ isNewESim: false, eSimId: ESIM_A }); + expect(body).toMatchObject({ isNewESim: false, esimId: ESIM_A }); }); it('topup order body does NOT include deviceId', async () => { mockPost.mockResolvedValue(orderEnvelope()); await createOrder({ catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, - eSimId: ESIM_A, isCryptoPayment: true, payeeAddress: DEVICE_WALLET, + esimId: ESIM_A, isCryptoPayment: true, payeeAddress: DEVICE_WALLET, } as any); const [, body] = mockPost.mock.calls[0]; @@ -124,7 +124,7 @@ describe('multiple compatible eSIMs — user selects one', () => { expect(compatible.map((r: any) => r.esimId)).toEqual([ESIM_A, ESIM_B]); }); - it('user selects the second compatible eSIM — that eSimId is used in the order', async () => { + it('user selects the second compatible eSIM — that esimId is used in the order', async () => { mockGet.mockResolvedValueOnce(compatEnvelope([COMPAT_A, COMPAT_B])); const compat = await checkEsimCompatibility({ planId: PLAN_ID }, ESIM_A); @@ -134,11 +134,11 @@ describe('multiple compatible eSIMs — user selects one', () => { mockPost.mockResolvedValueOnce(orderEnvelope(TOPUP_RESPONSE)); await createOrder({ catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, - eSimId: userSelection, isCryptoPayment: true, payeeAddress: DEVICE_WALLET, + esimId: userSelection, isCryptoPayment: true, payeeAddress: DEVICE_WALLET, } as any); const [, body] = mockPost.mock.calls[0]; - expect(body).toMatchObject({ eSimId: ESIM_B }); + expect(body).toMatchObject({ esimId: ESIM_B }); }); it('incompatible eSIMs (vendorMismatch) are excluded by the caller', async () => { @@ -180,7 +180,7 @@ describe('topup via external wallet', () => { catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, - eSimId: ESIM_A, + esimId: ESIM_A, isCryptoPayment: true, payeeAddress: EXTERNAL_ADDR, txnHash: TXN_HASH, @@ -191,7 +191,7 @@ describe('topup via external wallet', () => { const [, body] = mockPost.mock.calls[0]; expect(body).toMatchObject({ isNewESim: false, - eSimId: ESIM_A, + esimId: ESIM_A, payeeAddress: EXTERNAL_ADDR, txnHash: TXN_HASH, tokenName: 'USDC', @@ -203,7 +203,7 @@ describe('topup via external wallet', () => { mockPost.mockResolvedValue(orderEnvelope()); await createOrder({ catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, - eSimId: ESIM_A, isCryptoPayment: true, payeeAddress: EXTERNAL_ADDR, + esimId: ESIM_A, isCryptoPayment: true, payeeAddress: EXTERNAL_ADDR, txnHash: TXN_HASH, tokenName: 'USDC', network: 'BASE', } as any); @@ -238,12 +238,12 @@ describe('topup flow error handling', () => { it('ORDER_CREATION_FAILED on topup propagates as BffError', async () => { mockPost.mockResolvedValue(bffError('ORDER_CREATION_FAILED')); await expect( - createOrder({ catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, eSimId: ESIM_A, isCryptoPayment: true, payeeAddress: DEVICE_WALLET } as any) + createOrder({ catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, esimId: ESIM_A, isCryptoPayment: true, payeeAddress: DEVICE_WALLET } as any) ).rejects.toBeInstanceOf(BffError); }); it('failed topup can be retried and succeeds', async () => { - const req = { catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, eSimId: ESIM_A, isCryptoPayment: true, payeeAddress: DEVICE_WALLET } as any; + const req = { catalogueId: PLAN_ID, currency: 'USD', isNewESim: false, esimId: ESIM_A, isCryptoPayment: true, payeeAddress: DEVICE_WALLET } as any; mockPost .mockResolvedValueOnce(bffError('ORDER_CREATION_FAILED')) .mockResolvedValueOnce(orderEnvelope()); diff --git a/android/app/build.gradle b/android/app/build.gradle index 2919a5c..3b15c62 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,6 +2,12 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" +import groovy.json.JsonSlurper + +def kokioVersion = new JsonSlurper() + .parseText(file("../../package.json").text) + .version + def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() /** @@ -93,7 +99,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "1.2.0" + versionName kokioVersion buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 47bc670..f5a2287 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,8 +2,7 @@ - - + @@ -48,4 +47,4 @@ - \ No newline at end of file + diff --git a/app.config.js b/app.config.js index 047def6..d97630b 100644 --- a/app.config.js +++ b/app.config.js @@ -75,11 +75,6 @@ module.exports = ({ config }) => { }, ], }, - web: { - bundler: "metro", - output: "server", - favicon: "./assets/images/favicon.png", - }, plugins: [ [ "expo-build-properties", diff --git a/app/(tabs)/(wallet)/(contacts)/addContactScreen.tsx b/app/(tabs)/(wallet)/(contacts)/addContactScreen.tsx index c84ed82..87a581f 100644 --- a/app/(tabs)/(wallet)/(contacts)/addContactScreen.tsx +++ b/app/(tabs)/(wallet)/(contacts)/addContactScreen.tsx @@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid'; import { router , useNavigation, useLocalSearchParams } from 'expo-router'; import { Theme } from '@/constants/Colors'; import { useToast } from '@/contexts/ToastContext'; +import { logger } from '@/utils/logger'; const AddContactScreen = () => { const { showMessage } = useToast(); @@ -20,7 +21,6 @@ const AddContactScreen = () => { const navigation = useNavigation(); const params = useLocalSearchParams(); - useEffect(() => { // This will capture the wallet address when returning from the QR scan const unsubscribe = navigation.addListener('focus', () => { @@ -49,13 +49,10 @@ const AddContactScreen = () => { ]; const handleScan = async () => { - // if (!isPermissionGranted) { // await requestPermission(); // return; // } - - // if (!isPermissionGranted) { // Alert.alert("Camera Permission Required", "Please grant camera permission to scan QR codes"); // } else { @@ -105,11 +102,11 @@ const AddContactScreen = () => { // Optional: Show success message showMessage("Contact added successfully", "info"); router.replace({pathname:'/(tabs)/(wallet)/(contacts)/contactDetails', params:{firstName:contactObj.firstName,lastName:contactObj.lastName,monogramUrl:contactObj.monogramUrl,transactions:contactObj.transactions,walletAddress:walletAddress}}) - console.log(contactObj); + logger.debug('CONTACT_SAVE_OBJECT', { contactObj }); } catch (error) { - console.log("Error saving contact:", error); + logger.error('CONTACT_SAVE_FAILED', { error }); showMessage("Failed to add contact", "error"); } finally { setIsLoading(false); @@ -177,7 +174,7 @@ const AddContactScreen = () => { console.log('Button pressed!')} + onPress={() => logger.debug('BUTTON_PRESSED')} > Cancel diff --git a/app/(tabs)/(wallet)/(contacts)/contactDetails.tsx b/app/(tabs)/(wallet)/(contacts)/contactDetails.tsx index d9d5123..2d7f1e7 100644 --- a/app/(tabs)/(wallet)/(contacts)/contactDetails.tsx +++ b/app/(tabs)/(wallet)/(contacts)/contactDetails.tsx @@ -5,6 +5,7 @@ import { ThemedView } from '@/components/ThemedView'; import { ThemedText } from '@/components/ThemedText'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Theme } from '@/constants/Colors'; +import { logger } from '@/utils/logger'; interface Transaction { id: string; @@ -29,7 +30,7 @@ const ContactDetails = () => { const contact = contactJson ? JSON.parse(contactJson) : null; if (!contact) { - console.error(`Contact with ID ${contact_id} not found`); + logger.error('CONTACT_NOT_FOUND', { contact_id }); setTransactions([]); // Set empty array if contact not found return; } @@ -38,9 +39,9 @@ const ContactDetails = () => { const contactTransactions: Transaction[] = contact.transactions || []; setTransactions(contactTransactions); - console.log("Fetched transactions:", contactTransactions); + logger.debug('CONTACT_TRANSACTIONS_FETCHED', { contactTransactions }); } catch (error) { - console.error("Error fetching transactions:", error); + logger.error('CONTACT_TRANSACTIONS_FETCH_FAILED', { error }); setTransactions([]); // Set empty array in case of error } }; diff --git a/app/(tabs)/(wallet)/(contacts)/editContact.tsx b/app/(tabs)/(wallet)/(contacts)/editContact.tsx index df74e2a..7d24644 100644 --- a/app/(tabs)/(wallet)/(contacts)/editContact.tsx +++ b/app/(tabs)/(wallet)/(contacts)/editContact.tsx @@ -10,6 +10,7 @@ import { router , useNavigation, useLocalSearchParams } from 'expo-router'; import ColorPaletteModal from '@/components/ui/modals/colorPalleteModal'; import { Theme } from '@/constants/Colors'; import { useToast } from '@/contexts/ToastContext'; +import { logger } from '@/utils/logger'; const EditContact = () => { const { showMessage } = useToast(); @@ -50,7 +51,6 @@ const EditContact = () => { }, [navigation, params]); useEffect(()=>{ - console.log() },[]) const handleScan = async () => { // if (!isPermissionGranted) { @@ -110,11 +110,11 @@ const EditContact = () => { id:params.id }, }); - console.log("Updated contact:", editedContactObj); + logger.debug('CONTACT_UPDATED', { editedContactObj }); // Reset form fields } catch (error) { - console.log("Error updating contact:", error); + logger.error('CONTACT_UPDATE_FAILED', { error }); showMessage("Failed to update contact", "error"); } finally { setIsLoading(false); @@ -194,7 +194,7 @@ const EditContact = () => { console.log('Button pressed!')} + onPress={() => logger.debug('BUTTON_PRESSED')} > Cancel diff --git a/app/(tabs)/(wallet)/(contacts)/index.tsx b/app/(tabs)/(wallet)/(contacts)/index.tsx index 22ca6a6..a45f11d 100644 --- a/app/(tabs)/(wallet)/(contacts)/index.tsx +++ b/app/(tabs)/(wallet)/(contacts)/index.tsx @@ -6,6 +6,7 @@ import { router , useFocusEffect } from 'expo-router' import _ from "lodash"; import { Theme } from '@/constants/Colors'; import AsyncStorage from '@react-native-async-storage/async-storage' +import { logger } from '@/utils/logger'; interface Contact { id: string; @@ -18,13 +19,8 @@ interface Contact { transactions: any[]; // Replace `any` with a more specific type if possible } - const ContactsScreen = () => { - - - const [contacts, setContacts] = useState([]); - const getAllContacts = async () => { try { // Get the array of contact IDs @@ -38,16 +34,14 @@ const ContactsScreen = () => { return contactJson ? JSON.parse(contactJson) : null; }) ); - // Filter out any null values and update state const validContacts = contactsArray?.filter(contact => contact !== null); setContacts(validContacts); } catch (error) { - console.error('Error fetching contacts:', error); + logger.error('CONTACTS_FETCH_FAILED', { error }); setContacts([]); // Set empty array in case of error } }; - useFocusEffect( useCallback(() => { getAllContacts(); @@ -75,9 +69,7 @@ const ContactsScreen = () => { - - ))} @@ -85,5 +77,4 @@ const ContactsScreen = () => { ) } - export default ContactsScreen; diff --git a/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx b/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx index 25d8b54..e2e9ece 100644 --- a/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx +++ b/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx @@ -5,6 +5,7 @@ import { useState, useEffect, useMemo } from 'react'; import { StyleSheet, View, Linking, Pressable, StatusBar } from 'react-native'; import { Theme } from '@/constants/Colors'; import { useTheme } from '@/contexts/ThemeContext'; +import { logger } from '@/utils/logger'; const createStyles = () => StyleSheet.create({ container: { @@ -111,7 +112,7 @@ export default function QrCodeScreen() { if (scanned) return; setScanned(true); - console.log('Scanned wallet address:', data); + logger.debug('WALLET_ADDRESS_SCANNED', { data }); if(isEdit === "true"){ router.replace({ diff --git a/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx b/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx index 175f80e..60f4b5b 100644 --- a/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx +++ b/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx @@ -12,6 +12,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { useToast } from '@/contexts/ToastContext' import { Theme } from '@/constants/Colors' import { useTheme } from '@/contexts/ThemeContext' +import { logger } from '@/utils/logger'; interface Token { id: string; @@ -106,12 +107,12 @@ const SendToContact = () => { // Save back to AsyncStorage await AsyncStorage.setItem(`contact_${contactId}`, JSON.stringify(updatedContact)); - console.log("Transaction added successfully:", newTransaction); + logger.debug('TRANSACTION_ADDED', { newTransaction }); router.push({pathname:"/(tabs)/(wallet)/TransactionDetails", params: { transaction: JSON.stringify(newTransaction) }}) //@ts-expect-error non-reachable code for now, should be fixed when enabled showToast(newTransaction.amount,newTransaction.tokenAmount,'Sent',params?.firstName,params.monogramUrl) } catch (error) { - console.error("Error adding transaction:", error); + logger.error('TRANSACTION_ADD_FAILED', { error }); showMessage("Failed to send transaction", "error"); } finally { setIsLoading(false); @@ -142,7 +143,7 @@ const SendToContact = () => { setTokens(mappedTokens); setToken(mappedTokens[0]); } catch (error) { - console.error('Error fetching tokens:', error); + logger.error('TOKENS_FETCH_FAILED', { error }); } }; diff --git a/app/(tabs)/(wallet)/index.tsx b/app/(tabs)/(wallet)/index.tsx index e49f820..776a367 100644 --- a/app/(tabs)/(wallet)/index.tsx +++ b/app/(tabs)/(wallet)/index.tsx @@ -8,6 +8,7 @@ import { Theme } from '@/constants/Colors'; import Wallet from '@/components/home/wallet'; import { useToast } from '@/contexts/ToastContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '@/utils/logger'; const tokens = [ { id: '1', name: 'USDC', symbol: 'USDC', balance: '0.5', value: '$85.23 USD', icon: require("../../../assets/images/wallet/usdc.png") }, @@ -51,7 +52,7 @@ const getAllContacts = async () => { const validContacts = contactsArray?.filter(contact => contact !== null); setContacts(validContacts); } catch (error) { - console.error('Error fetching contacts:', error); + logger.error('CONTACTS_FETCH_FAILED', { error }); setContacts([]); // Set empty array in case of error } }; diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index b0b8e00..e448414 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -22,8 +22,11 @@ function InstallationHeader() { if (from === "orders") { router.navigate("/(tabs)/orders"); } else { - router.push("/(tabs)/(shop)"); - router.navigate("/(tabs)"); + // navigate("/(tabs)") operates on the already-mounted Tabs + // navigator, which just re-focuses whichever tab was last active + // (e.g. Shop) instead of switching to Home. Resetting the root + // stack to "/" remounts (tabs) fresh, landing on its initial tab. + router.replace("/"); } }} /> diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx index c0d1d11..42ca262 100644 --- a/app/(tabs)/orders.tsx +++ b/app/(tabs)/orders.tsx @@ -13,26 +13,53 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; -import _get from "lodash/get"; -import { openBrowserAsync } from "expo-web-browser"; import { Theme } from "@/constants/Colors"; import { useTheme } from "@/contexts/ThemeContext"; import { ThemedText } from "@/components/ThemedText"; import { ThemedView } from "@/components/ThemedView"; import { useThemeColor } from "@/hooks/useThemeColor"; -import { useKokio } from "@/hooks/useKokio"; -import type { StoredPurchasedESIM, StoredTransactionData } from "@/providers/kokioProvider"; -import { getAllEsims, getEsim } from "@/utils/bff/esim"; -import type { ESimDocument } from "@/utils/bff/esim"; +import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; +import type { OrderListItem } from "@/utils/bff/order"; import { labelForStatus, colorForStatus } from "@/utils/orderStatus"; import ESIMItem from "@/components/ESIMItem"; -import { ESIM_EXTRA_DETAILS } from "@/constants/checkout.constants"; +import type { Esim } from "@/components/ESIMItem"; +import { useEsims, useOrders } from "@/hooks/useDeviceEsims"; -type EnrichedOrder = StoredPurchasedESIM & { - liveEsim?: ESimDocument; +// ─── Types ──────────────────────────────────────────────────────────────────── + +// An OrderListItem enriched with its matched ESimDocument, joined by esimId. +type EnrichedOrder = OrderListItem & { + esim?: ESimDocument; }; -// ── Styles ──────────────────────────────────────────────────────────────────── +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// Key used for FlatList and expand/collapse tracking. +const getOrderKey = (item: EnrichedOrder): string => + item.idempotencyKey ?? item.orderId ?? ""; + +// Builds the minimal Esim display shape from an ESimDocument for ESIMItem. +// Uses the most-recent PlanHistoryEntry for plan metadata. +function toDisplayItem(doc: ESimDocument): Esim { + const entries: PlanHistoryEntry[] = doc.planHistory ?? []; + const latest = entries[entries.length - 1] as PlanHistoryEntry | undefined; + return { + catalogueId: '', + actualSellingPrice: 0, + isUnlimited: latest?.isUnlimited ?? false, + serviceRegionCode: undefined, + serviceRegionFlag: latest?.serviceRegionFlag ?? null, + serviceRegionName: latest?.serviceRegionName ?? null, + coverageType: latest?.coverageType ?? 'LOCAL', + data: latest?.data ?? null, + sms: latest?.sms ?? null, + voice: latest?.voice ?? null, + validity: latest?.validity ?? null, + info: null, + }; +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── const createStyles = () => StyleSheet.create({ @@ -78,29 +105,6 @@ const createStyles = () => marginBottom: 8, padding: 14, }, - extraDetailsContainer: { - gap: 10, - marginBottom: 14, - }, - extraDetailRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - extraDetailLabel: { - flexDirection: "row", - alignItems: "center", - gap: 6, - }, - extraDetailLabelText: { - fontSize: 13, - }, - extraDetailValue: { - fontSize: 13, - fontWeight: "500", - maxWidth: "55%", - textAlign: "right", - }, actionRow: { flexDirection: "row", gap: 8, @@ -116,61 +120,33 @@ const createStyles = () => }, installBtnText: { color: "white", - fontSize: 13, + fontSize: 14, fontWeight: "600", }, detailsBtnText: { - fontSize: 13, - fontWeight: "600", - }, - supportBar: { - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 14, - borderTopWidth: StyleSheet.hairlineWidth, - }, - supportBarLabel: { - fontSize: 12, - marginBottom: 8, - textAlign: "center", - }, - supportBtns: { - flexDirection: "row", - gap: 10, - }, - supportBtn: { - flex: 1, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 6, - paddingVertical: 9, - borderRadius: 10, - }, - supportBtnText: { - fontSize: 13, - fontWeight: "600", + fontSize: 14, + fontWeight: "500", }, }); -// ── Purchase Details modal stylesheet ───────────────────────────────────────── +// ─── Purchase Details Modal styles ──────────────────────────────────────────── const pdStyles = StyleSheet.create({ overlay: { flex: 1, justifyContent: "flex-end", - backgroundColor: "rgba(0,0,0,0.5)", + backgroundColor: Theme.colors.overlayMedium, }, sheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 20, - paddingBottom: 32, - maxHeight: "75%", + paddingBottom: 40, + maxHeight: "80%", }, sheetHeader: { alignItems: "center", - paddingTop: 10, + paddingTop: 12, paddingBottom: 4, }, pillHandle: { @@ -183,12 +159,12 @@ const pdStyles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", alignItems: "center", - paddingVertical: 12, - marginBottom: 4, + marginBottom: 16, + paddingTop: 8, }, title: { - fontSize: 18, - fontWeight: "700", + fontSize: 17, + fontWeight: "600", }, copyRow: { flexDirection: "row", @@ -239,7 +215,7 @@ const pdStyles = StyleSheet.create({ }, }); -// ── CopyRow ─────────────────────────────────────────────────────────────────── +// ─── CopyRow ────────────────────────────────────────────────────────────────── const CopyRow = ({ label, value }: { label: string; value: string }) => { const [copied, setCopied] = useState(false); @@ -252,7 +228,10 @@ const CopyRow = ({ label, value }: { label: string; value: string }) => { {label} - + {value} @@ -265,21 +244,19 @@ const CopyRow = ({ label, value }: { label: string; value: string }) => { ); }; -// ── PurchaseDetailsModal ────────────────────────────────────────────────────── +// ─── PurchaseDetailsModal ───────────────────────────────────────────────────── const PurchaseDetailsModal = ({ visible, onClose, - transactionData, - liveEsim, + order, invoiceUrl, lpa, supportRef, }: { visible: boolean; onClose: () => void; - transactionData: StoredTransactionData; - liveEsim?: ESimDocument; + order: EnrichedOrder; invoiceUrl: string | null; lpa: string | null; supportRef: string | null; @@ -292,7 +269,11 @@ const PurchaseDetailsModal = ({ statusBarTranslucent > - + @@ -302,52 +283,62 @@ const PurchaseDetailsModal = ({ Purchase Details - + - - {transactionData.iccid ? : null} + + {order.iccid ? : null} {supportRef ? : null} {lpa ? : null} - {transactionData.paymentMethod ? ( + {order.paymentMethod ? ( Payment Method - {transactionData.paymentMethod} + {order.paymentMethod} ) : null} - {transactionData.orderStatus ? ( + {order.orderStatus ? ( Status - {labelForStatus(transactionData.orderStatus)} + {labelForStatus(order.orderStatus)} ) : null} {invoiceUrl ? ( - Linking.openURL(invoiceUrl)} style={pdStyles.infoRow}> + Linking.openURL(invoiceUrl)} + style={pdStyles.infoRow} + > Invoice - + View invoice → ) : null} - {liveEsim?.planHistory?.length ? ( + {order.esim?.planHistory?.length ? ( Plan History - {liveEsim.planHistory.map((entry, i) => ( + {order.esim.planHistory.map((entry, i) => ( {entry.planId} @@ -365,7 +356,7 @@ const PurchaseDetailsModal = ({ ); -// ── OrderCard ───────────────────────────────────────────────────────────────── +// ─── OrderCard ──────────────────────────────────────────────────────────────── const OrderCard = ({ order, @@ -380,47 +371,49 @@ const OrderCard = ({ }) => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); - const router = useRouter(); + // const router = useRouter(); const [showPurchaseDetails, setShowPurchaseDetails] = useState(false); - const { eSimItem, transactionData, liveEsim } = order; - const statusColor = colorForStatus(transactionData.orderStatus); + const statusColor = colorForStatus(order.orderStatus); + // LPA string: prefer the smdpAddress+matchingId pair from the live eSIM doc + // (authoritative); fall back to the qrcode stored on installationDetails. const lpa = - liveEsim?.smdpAddress && liveEsim?.matchingId - ? `LPA:1$${liveEsim.smdpAddress}$${liveEsim.matchingId}` - : transactionData.installationDetails?.qrcode ?? null; - - const invoiceUrl = transactionData.stripeInvoiceUrl ?? null; - const flagged = transactionData.flaggedForManualReview ?? false; - const supportRef = transactionData.correlationId ?? transactionData.orderId; - - const extraDetailRows = useMemo(() => { - return ESIM_EXTRA_DETAILS.filter((item) => { - if (["IP_ROUTING", "ADDITIONAL_INFORMATION", "countryWiseNetworkCoverages"].includes(item.key)) - return false; - const raw = _get(eSimItem, item.key); - if (raw === null || raw === undefined) return false; - const formatted = item.formatter?.(raw); - return formatted && formatted !== "N/A"; - }); - }, [eSimItem]); + order.esim?.smdpAddress && order.esim?.matchingId + ? `LPA:1$${order.esim.smdpAddress}$${order.esim.matchingId}` + : order.esim?.installationDetails?.qrcode ?? null; - const coverageCount = eSimItem.countryWiseNetworkCoverages?.length ?? 0; + const invoiceUrl = order.stripeInvoiceUrl ?? null; + const flagged = order.flaggedForManualReview ?? false; + // idempotencyKey is the correlation id equivalent on OrderListItem. + const supportRef = order.idempotencyKey ?? order.orderId ?? null; + + // Display card: built from the linked ESimDocument when available. + // Falls back to a minimal placeholder when the eSIM doc hasn't been provisioned yet + const displayItem: Esim | null = order.esim ? toDisplayItem(order.esim) : null; return ( - - + + {displayItem ? ( + + ) : ( + // Fallback header for orders without a linked eSIM document + + + {order.planId ?? "Order"} + + + )} + - {transactionData.orderStatus ? ( - + {order.orderStatus ? ( + - {labelForStatus(transactionData.orderStatus)} + {labelForStatus(order.orderStatus)} ) : null} @@ -431,7 +424,9 @@ const OrderCard = ({ { backgroundColor: Theme.colors.goldenYellow + "22" }, ]} > - + Under Review @@ -448,65 +443,6 @@ const OrderCard = ({ {isExpanded && ( - {(extraDetailRows.length > 0 || coverageCount > 0) && ( - - {extraDetailRows.map((item, idx) => { - const raw = _get(eSimItem, item.key); - const formatted = item.formatter?.(raw); - return ( - - - - - {item.label} - - - - {formatted} - - - ); - })} - {coverageCount > 0 && ( - - - - - Coverage - - - - router.push({ - pathname: "/(tabs)/(shop)/coverage", - params: { - data: JSON.stringify(eSimItem.countryWiseNetworkCoverages), - from: "orders", - }, - }) - } - style={{ flexDirection: "row", alignItems: "center", gap: 2 }} - hitSlop={8} - > - - {coverageCount} {coverageCount === 1 ? "country" : "countries"} - - - - - )} - - )} - {lpa && onInstall ? ( setShowPurchaseDetails(false)} - transactionData={transactionData} - liveEsim={liveEsim} + order={order} invoiceUrl={invoiceUrl} lpa={lpa} supportRef={supportRef} @@ -543,156 +478,92 @@ const OrderCard = ({ ); }; -// ── OrdersScreen ────────────────────────────────────────────────────────────── - -const getOrderKey = (item: EnrichedOrder): string => - item.transactionData.correlationId ?? - item.transactionData.orderId ?? - item.transactionData.iccid ?? - ""; +// ─── OrdersScreen ───────────────────────────────────────────────────────────── export default function OrdersScreen() { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); - const { kokio } = useKokio(); const router = useRouter(); const bg = useThemeColor({}, "background"); const { expandOrderId } = useLocalSearchParams<{ expandOrderId?: string }>(); - const orders = kokio.purchasedESIMs; - const [enrichedOrders, setEnrichedOrders] = useState(orders); + + const { esims, isLoading: esimsLoading } = useEsims(); + const { orders, isLoading: ordersLoading } = useOrders(); + + const isLoading = esimsLoading || ordersLoading; + + // Join orders with their matching ESimDocument by esimId. + const enrichedOrders = useMemo(() => { + const esimMap = new Map(esims.map((e) => [e.esimId, e])); + return orders.map((order) => ({ + ...order, + esim: order.esimId ? esimMap.get(order.esimId) : undefined, + })); + }, [orders, esims]); + const [expandedId, setExpandedId] = useState(null); - // Collapse all when leaving the Orders tab + // Collapse all cards when leaving the Orders tab. useFocusEffect( useCallback(() => { return () => setExpandedId(null); - }, []) + }, []), ); - // Auto-expand the order referenced by the URL param (e.g. from a notification) + // Auto-expand the card referenced by the URL param. + // Matches on idempotencyKey, orderId, or esimId useEffect(() => { - if (!expandOrderId || !enrichedOrders.length) return; + if (!expandOrderId || enrichedOrders.length === 0) return; const matched = enrichedOrders.find( (o) => - o.transactionData.correlationId === expandOrderId || - o.transactionData.orderId === expandOrderId + o.idempotencyKey === expandOrderId || + o.orderId === expandOrderId || + o.esimId === expandOrderId, ); if (matched) setExpandedId(getOrderKey(matched)); }, [expandOrderId, enrichedOrders]); - useEffect(() => { - setEnrichedOrders(orders); - }, [orders]); - - useEffect(() => { - let cancelled = false; - const fetchLive = async () => { - try { - const liveEsims = await getAllEsims(); - const liveMap = new Map(liveEsims.map(e => [e.esimId, e])); - - orders.forEach(async (order, i) => { - const { transactionData } = order; - - // Resolve live eSIM doc — try the map first, fall back to direct fetch - let live = transactionData.esimId ? liveMap.get(transactionData.esimId) : undefined; - if (!live && transactionData.esimId) { - live = await getEsim(transactionData.esimId).catch(() => undefined); - } - if (!live) return; // nothing new to add - - const enriched: EnrichedOrder = { - ...order, - liveEsim: live, - transactionData: { - ...transactionData, - iccid: live.iccid ?? transactionData.iccid, - orderStatus: live.activationStatus ?? transactionData.orderStatus, - planId: live.planId ?? transactionData.planId, - installationDetails: live.installationDetails ?? transactionData.installationDetails, - }, - }; - - if (!cancelled) { - setEnrichedOrders((prev) => { - const next = [...prev]; - next[i] = enriched; - return next; - }); - } - }); - } catch { - // live fetch failed — stored data already shown - } - }; - fetchLive(); - return () => { - cancelled = true; - }; - }, [orders]); + const handleInstall = useCallback((lpa: string) => { + // Parse LPA string: LPA:1$$ + const parts = lpa.split("$"); + const qrcode = lpa; + const appleInstallationUrl = parts[1] && parts[2] + ? `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${lpa}` + : ""; + router.navigate({ + pathname: "/(tabs)/installation", + params: { qrcode, appleInstallationUrl, iccid: "", orderId: "" }, + }); + }, [router]); return ( - {enrichedOrders.length === 0 ? ( + {isLoading ? ( + Loading orders… + ) : enrichedOrders.length === 0 ? ( No orders yet. ) : ( - - item.transactionData.correlationId ?? - item.transactionData.orderId ?? - item.transactionData.iccid ?? - Math.random().toString() - } - renderItem={({ item }) => { - const key = getOrderKey(item); - return ( + keyExtractor={getOrderKey} + renderItem={({ item }) => ( setExpandedId((prev) => (prev === key ? null : key))} - onInstall={(lpa) => - router.push({ - pathname: "/(tabs)/installation", - params: { qrcode: lpa, from: "orders" }, - }) + onInstall={handleInstall} + isExpanded={expandedId === getOrderKey(item)} + onToggle={() => + setExpandedId((prev) => + prev === getOrderKey(item) ? null : getOrderKey(item), + ) } /> - );}} + )} showsVerticalScrollIndicator={false} - contentContainerStyle={{ paddingBottom: 8 }} + contentContainerStyle={{ paddingBottom: 16 }} /> )} - - - - Need help with your eSIM? - - - Linking.openURL("mailto:contact@kokio.app")} - style={[styles.supportBtn, { backgroundColor: Theme.colors.surface }]} - > - - Email Us - - openBrowserAsync("https://t.me/+Ru38DI2V69IyY2Y9")} - style={[styles.supportBtn, { backgroundColor: Theme.colors.surface }]} - > - - Telegram - - - ); } diff --git a/app/(tabs)/phone.tsx b/app/(tabs)/phone.tsx index 65400b8..686f4ec 100644 --- a/app/(tabs)/phone.tsx +++ b/app/(tabs)/phone.tsx @@ -6,6 +6,7 @@ import React, { useState, useCallback } from "react"; import { router , useFocusEffect } from "expo-router"; import _ from "lodash"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { logger } from "@/utils/logger"; interface Contact { id: string; @@ -43,7 +44,7 @@ const ContactsScreen = () => { ); setContacts(validContacts); } catch (error) { - console.error("Error fetching contacts:", error); + logger.error('CONTACTS_FETCH_FAILED', { error }); setContacts([]); // Set empty array in case of error } }; diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index d2f39b6..5d71490 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -5,7 +5,6 @@ import { TouchableOpacity, View, ScrollView, - // Switch, Text, Switch, Linking, @@ -20,6 +19,8 @@ import { Ionicons } from "@expo/vector-icons"; import { useKokio } from "@/hooks/useKokio"; import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { SafeAreaView } from "react-native-safe-area-context"; +import { logger } from "@/utils/logger"; +import { DeleteAccountModal } from '@/components/DeleteAccountModal'; const createStyles = () => StyleSheet.create({ container: { @@ -49,9 +50,11 @@ const createStyles = () => StyleSheet.create({ flex: 1, }, iconLeft: { + color: Theme.colors.icon, marginRight: 16, }, iconRight: { + color: Theme.colors.icon, marginLeft: 16, }, aboutContainer: { @@ -73,9 +76,11 @@ const createStyles = () => StyleSheet.create({ color: Theme.colors.text, fontSize: 24, fontWeight: "600", + paddingTop: 20, }, closeButton: { - padding: 4, + color: Theme.colors.icon, + paddingTop: 16, }, aboutContent: { flex: 1, @@ -124,11 +129,10 @@ const createStyles = () => StyleSheet.create({ const MENU_ITEM_ENABLED = { CONTACT: false, // moved from the bottom Phone tab — enable once contacts feature is ready - PRIVACY: false, // enable once privacy policy is ready }; // Disabled menu item styling -const DISABLED_OPACITY = 0.3; +const DISABLED_OPACITY = 0.4; const MenuItem = ({ title, @@ -186,7 +190,7 @@ const AboutContent = ({ onClose }: { onClose: () => void }) => { try { await openBrowserAsync(url); } catch (error) { - console.error("Error opening browser:", error); + logger.error('BROWSER_OPEN_FAILED', { error }); } }, []); @@ -195,7 +199,7 @@ const AboutContent = ({ onClose }: { onClose: () => void }) => { About - + void }) => { Contact Support - + @@ -256,7 +260,7 @@ const ContactContent = ({ onClose }: { onClose: () => void }) => { Email Us - contact@kokio.app + contact@kokio.app void }) => { Telegram - + @@ -275,12 +279,13 @@ const ContactContent = ({ onClose }: { onClose: () => void }) => { }; export default function MenuScreen() { - const { logout } = useAuthRelay(); + const { logout, deleteAccount } = useAuthRelay(); const { clearKokioUser } = useKokio(); const { isDark, toggleTheme } = useTheme(); const [showAbout, setShowAbout] = useState(false); const [showContact, setShowContact] = useState(false); + const [showDeleteAccount, setShowDeleteAccount] = useState(false); const bg = useThemeColor({}, "background"); const styles = useMemo(createStyles, [isDark]); @@ -297,7 +302,7 @@ export default function MenuScreen() { title: "Privacy Policy", iconLeft: "lock-closed-outline", iconRight: "chevron-forward-outline", - disabled: !MENU_ITEM_ENABLED.PRIVACY, + action: () => openBrowserAsync("https://kokio.app/privacy-policy"), }, { id: "5", @@ -320,6 +325,14 @@ export default function MenuScreen() { iconRight: "chevron-forward-outline", action: logout, }, + { + id: "9", + title: "Delete Account", + iconLeft: "trash-outline", + iconRight: "chevron-forward-outline", + destructive: true, + action: () => setShowDeleteAccount(true), + }, ...(__DEV__ ? [{ id: "7", title: "Logout and Clear Data", @@ -355,6 +368,14 @@ export default function MenuScreen() { keyExtractor={(item) => item.id} style={styles.list} /> + setShowDeleteAccount(false)} + onConfirm={async () => { + await deleteAccount(); + setShowDeleteAccount(false); + }} + /> { (null); + const [bootstrapDismissed, setBootstrapDismissed] = useState(false); const [loaded] = useFonts({ "Lexend-Light": require("../assets/fonts/Lexend-Light.ttf"), Lexend: require("../assets/fonts/Lexend-Regular.ttf"), @@ -46,7 +47,7 @@ export default function RootLayout() { "Lexend-Black": require("../assets/fonts/Lexend-Black.ttf"), }); - const { isLoading } = useBootstrap(); + const { isLoading, error: bootstrapError, refresh: refreshBootstrap } = useBootstrap(); // Use refs to avoid recreating the NetInfo listener on every pathname change const pathnameRef = useRef(pathname); @@ -66,15 +67,34 @@ export default function RootLayout() { // Initial connectivity check useEffect(() => { + let settled = false; + + // On some devices isInternetReachable can stay null indefinitely (the + // reachability probe never resolves). Without a bound here, _isNull(isConnected) + // keeps FullScreenLoader up forever. Give up after 6s and assume online — + // the listener below still corrects this and redirects to Offline if a + // later reading confirms we're actually offline. + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + setIsConnected(true); + } + }, 6000); + NetInfo.fetch().then((state) => { + if (settled) return; if (_isNull(state.isInternetReachable)) { // Network state is still being determined setIsConnected(null); } else { + settled = true; + clearTimeout(timeoutId); const online = !!state.isConnected && !!state.isInternetReachable; setIsConnected(online); } }); + + return () => clearTimeout(timeoutId); }, []); useEffect(() => { @@ -120,9 +140,15 @@ export default function RootLayout() { } }, [loaded, isLoading]); + const showLoader = !bootstrapDismissed && (!loaded || _isNull(isConnected) || isLoading || !!bootstrapError); + const inner = - !loaded || _isNull(isConnected) ? ( - + showLoader ? ( + setBootstrapDismissed(true) : undefined} + /> ) : ( @@ -134,6 +160,8 @@ export default function RootLayout() { + + diff --git a/app/coverage-modal.tsx b/app/coverage-modal.tsx new file mode 100644 index 0000000..ba150b7 --- /dev/null +++ b/app/coverage-modal.tsx @@ -0,0 +1,246 @@ +import React, { useMemo, useState } from "react"; +import { + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; + +import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import CountryFlag from "@/components/ui/CountryFlag"; +import SearchBar from "@/components/SearchInput"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type CountryNetworkEntry = { + countryCode?: string; + countryName?: string; + networks?: { name?: string; type?: string }[]; +}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const SEARCH_THRESHOLD = 3; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const createStyles = () => + StyleSheet.create({ + safeArea: { + flex: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Theme.colors.muted, + }, + headerTitle: { + fontSize: 17, + fontWeight: "600", + color: Theme.colors.text, + }, + closeButton: { + padding: 4, + }, + spacer: { + width: 32, + }, + searchWrapper: { + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 4, + }, + list: { + flex: 1, + }, + row: { + flexDirection: "row", + alignItems: "flex-start", + paddingVertical: 14, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Theme.colors.muted, + gap: 12, + }, + leftCol: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + countryName: { + flex: 1, + fontSize: 14, + fontWeight: "500", + color: Theme.colors.text, + }, + rightCol: { + flex: 1, + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-end", + alignItems: "center", + gap: 4, + }, + networkTag: { + flexDirection: "row", + alignItems: "center", + backgroundColor: Theme.colors.itemBackground, + borderRadius: 5, + paddingHorizontal: 7, + paddingVertical: 3, + gap: 3, + }, + networkName: { + fontSize: 12, + color: Theme.colors.mutedForeground, + }, + networkType: { + fontSize: 10, + fontWeight: "700", + color: Theme.colors.highlight, + }, + emptyText: { + textAlign: "center", + paddingVertical: 48, + fontSize: 14, + color: Theme.colors.mutedForeground, + }, + }); + +// ─── CoverageRow ────────────────────────────────────────────────────────────── + +const CoverageRow = ({ + entry, + styles, +}: { + entry: CountryNetworkEntry; + styles: ReturnType; +}) => { + const networks = entry.networks ?? []; + return ( + + + {entry.countryCode ? ( + + ) : ( + + )} + + {entry.countryName ?? entry.countryCode ?? "—"} + + + + {networks.length === 0 ? ( + + ) : ( + networks.map((net, i) => ( + + {net.name} + {net.type ? ( + {net.type} + ) : null} + + )) + )} + + + ); +}; + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +export default function CoverageModal() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); + const bg = useThemeColor({}, "background"); + const router = useRouter(); + + const { data: rawData } = useLocalSearchParams<{ data: string }>(); + const [query, setQuery] = useState(""); + + const allEntries = useMemo(() => { + try { + return JSON.parse(rawData ?? "[]"); + } catch { + return []; + } + }, [rawData]); + + const filtered = useMemo(() => { + if (!query.trim()) return allEntries; + const q = query.toLowerCase(); + return allEntries.filter( + (entry) => + entry.countryName?.toLowerCase().includes(q) || + entry.countryCode?.toLowerCase().includes(q) || + entry.networks?.some((n) => n.name?.toLowerCase().includes(q)), + ); + }, [allEntries, query]); + + const showSearch = allEntries.length > SEARCH_THRESHOLD; + + return ( + + {/* Header — inline since this is a root-stack modal with no layout header */} + + {/* Spacer keeps title centred */} + + Network Coverage + router.back()} + accessibilityRole="button" + accessibilityLabel="Close coverage" + hitSlop={8} + > + + + + + {showSearch && ( + + setQuery("")} + /> + + )} + + i.toString()} + renderItem={({ item }) => ( + + )} + style={styles.list} + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + ListEmptyComponent={ + + {query ? `No results for "${query}"` : "No coverage data available"} + + } + contentContainerStyle={{ paddingBottom: 32 }} + /> + + ); +} diff --git a/app/esim-detail.tsx b/app/esim-detail.tsx new file mode 100644 index 0000000..9f18ef9 --- /dev/null +++ b/app/esim-detail.tsx @@ -0,0 +1,650 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { + ActivityIndicator, + Image, + Linking, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import * as Clipboard from "expo-clipboard"; +import QRCode from "react-native-qrcode-svg"; + +import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; +import { useThemeColor } from "@/hooks/useThemeColor"; +import { useEsims } from "@/hooks/useDeviceEsims"; +import { useEsimUsage } from "@/hooks/useEsimUsage"; +import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; +import { logger } from "@/utils/logger"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// Derive the LPA string from an ESimDocument's smdpAddress + matchingId. +function buildLpa(doc: ESimDocument): string | null { + if (doc.smdpAddress && doc.matchingId) { + return `LPA:1$${doc.smdpAddress}$${doc.matchingId}`; + } + return doc.installationDetails?.qrcode ?? null; +} + +// Apple eSIM provisioning deep-link — iOS only. +function buildAppleUrl(lpa: string): string { + return `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${lpa}`; +} + +// ─── Status chip config ─────────────────────────────────────────────────────── + +type ActivationStatus = ESimDocument["activationStatus"]; + +const ACTIVATION_LABEL: Record = { + RELEASED: "Ready to Install", + INSTALLED: "Active", + UNAVAILABLE: "Unavailable", + DEACTIVATED: "Deactivated", +}; + +const ACTIVATION_COLOR: Record = { + RELEASED: Theme.colors.info, + INSTALLED: Theme.colors.success, + UNAVAILABLE: Theme.colors.warning, + DEACTIVATED: Theme.colors.destructive, +}; + +type BundleStatus = PlanHistoryEntry["bundleStatus"]; + +const BUNDLE_LABEL: Record = { + QUEUED: "Queued", + ACTIVE: "Active", + FINISHED: "Finished", + EXPIRED: "Expired", + UNKNOWN: "Unknown", +}; + +const BUNDLE_COLOR: Record = { + QUEUED: Theme.colors.info, + ACTIVE: Theme.colors.success, + FINISHED: Theme.colors.warning, + EXPIRED: Theme.colors.destructive, + UNKNOWN: Theme.colors.mutedForeground, +}; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const createStyles = () => + StyleSheet.create({ + safeArea: { flex: 1 }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Theme.colors.muted, + }, + headerLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 }, + headerFlag: { width: 32, height: 22, borderRadius: 3 }, + headerRegion: { + fontSize: 17, + fontWeight: "600", + color: Theme.colors.text, + flexShrink: 1, + }, + chip: { + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 3, + }, + chipText: { fontSize: 11, fontWeight: "700", textTransform: "uppercase" }, + closeButton: { padding: 4 }, + scroll: { flex: 1 }, + scrollContent: { padding: 16, gap: 12, paddingBottom: 32 }, + + // Section card + section: { + backgroundColor: Theme.colors.surface, + borderRadius: 14, + padding: 14, + gap: 10, + }, + sectionTitle: { + fontSize: 12, + fontWeight: "700", + letterSpacing: 0.5, + textTransform: "uppercase", + color: Theme.colors.mutedForeground, + marginBottom: 2, + }, + + // Usage bar + usageBarTrack: { + height: 8, + borderRadius: 4, + backgroundColor: Theme.colors.muted, + overflow: "hidden", + }, + usageBarFill: { + height: 8, + borderRadius: 4, + }, + usageRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + usageLabel: { fontSize: 13, color: Theme.colors.mutedForeground }, + usageValue: { fontSize: 13, fontWeight: "600", color: Theme.colors.text }, + usageExpiry: { fontSize: 12, color: Theme.colors.mutedForeground, marginTop: 2 }, + + // Degraded state + degradedBox: { + flexDirection: "row", + alignItems: "center", + gap: 8, + backgroundColor: "rgba(255,149,0,0.1)", + borderRadius: 8, + padding: 10, + }, + degradedText: { flex: 1, fontSize: 13, color: Theme.colors.warning }, + + // Copy row + copyRow: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Theme.colors.muted, + }, + copyLabel: { + fontSize: 11, + fontWeight: "600", + letterSpacing: 0.3, + textTransform: "uppercase", + color: Theme.colors.mutedForeground, + marginBottom: 2, + }, + copyValue: { + fontSize: 13, + color: Theme.colors.text, + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + }, + + // QR + qrContainer: { alignItems: "center", paddingVertical: 8 }, + + // Apple install button — iOS only + appleBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: Theme.colors.primary, + borderRadius: 12, + paddingVertical: 12, + }, + appleBtnText: { fontSize: 15, fontWeight: "600", color: Theme.colors.primaryForeground }, + + // Plan history entry + historyEntry: { + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Theme.colors.muted, + gap: 4, + }, + historyEntryTop: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + historyPlanId: { fontSize: 13, fontWeight: "500", color: Theme.colors.text, flexShrink: 1 }, + historyDate: { fontSize: 12, color: Theme.colors.mutedForeground }, + historyAllowance:{ fontSize: 12, color: Theme.colors.mutedForeground, marginTop: 2 }, + + // Row — icon + label + iconRow: { flexDirection: "row", alignItems: "center", gap: 8 }, + iconRowText: { fontSize: 13, color: Theme.colors.text }, + + // Buttons + primaryBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: Theme.colors.primary, + borderRadius: 14, + paddingVertical: 14, + marginTop: 4, + }, + primaryBtnText: { fontSize: 16, fontWeight: "600", color: Theme.colors.primaryForeground }, + outlineBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + borderWidth: 1, + borderColor: Theme.colors.muted, + borderRadius: 14, + paddingVertical: 14, + }, + outlineBtnText: { fontSize: 16, fontWeight: "500", color: Theme.colors.text }, + + // Not found + notFound: { flex: 1, alignItems: "center", justifyContent: "center", gap: 8 }, + notFoundText: { fontSize: 15, color: Theme.colors.mutedForeground }, + }); + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +const Chip = ({ label, color }: { label: string; color: string }) => { + const styles = useMemo(createStyles, []); + return ( + + {label} + + ); +}; + +const CopyRow = ({ + label, + value, + last = false, +}: { + label: string; + value: string; + last?: boolean; +}) => { + const styles = useMemo(createStyles, []); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await Clipboard.setStringAsync(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [value]); + + return ( + + + {label} + + {value} + + + + + ); +}; + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +export default function EsimDetailScreen() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); + const bg = useThemeColor({}, "background"); + const router = useRouter(); + + const { esimId } = useLocalSearchParams<{ esimId: string }>(); + + // Read ESimDocument from the React Query cache populated by useEsims(). + // No additional network call (card press implies the eSIM is in the active list). + const { esims } = useEsims(); + const doc = useMemo( + () => esims.find((e) => e.esimId === esimId), + [esims, esimId], + ); + + const { usage, isLoading: usageLoading, isError: isFetchError, usageUnavailable, refetch: refetchUsage } = + useEsimUsage(esimId); + + const latest = useMemo(() => { + const history = doc?.planHistory ?? []; + return history[history.length - 1]; + }, [doc]); + + const lpa = doc ? buildLpa(doc) : null; + const appleUrl = lpa ? buildAppleUrl(lpa) : null; + + const handleAppleInstall = useCallback(async () => { + if (!appleUrl) return; + try { + await Linking.openURL(appleUrl); + } catch (err) { + logger.error('ESIM_APPLE_INSTALL_FAILED', { err }); + } + }, [appleUrl]); + + const handleViewOrder = useCallback(() => { + router.back(); + // Small delay so the modal dismiss animation completes before navigation. + setTimeout(() => { + router.navigate({ + pathname: "/(tabs)/orders", + params: { expandOrderId: esimId }, + }); + }, 300); + }, [router, esimId]); + + // ── Usage bar colour ────────────────────────────────────────────────────── + + const usageBarColor = useMemo(() => { + if (!usage?.remaining || !usage?.total) return Theme.colors.primary; + const ratio = usage.remaining / usage.total; + if (ratio > 0.4) return Theme.colors.success; + if (ratio > 0.15) return Theme.colors.warning; + return Theme.colors.destructive; + }, [usage]); + + const usageFillPct = useMemo(() => { + if (!usage?.remaining || !usage?.total) return 0; + return Math.min(100, (usage.remaining / usage.total) * 100); + }, [usage]); + + // ── Guard: document not found ───────────────────────────────────────────── + + if (!doc) { + return ( + + + + eSIM not found + router.back()}> + Go back + + + + ); + } + + const statusColor = ACTIVATION_COLOR[doc.activationStatus]; + const statusLabel = ACTIVATION_LABEL[doc.activationStatus]; + + return ( + + + {/* ── Header ───────────────────────────────────────────────────────── */} + + + {latest?.serviceRegionFlag ? ( + + ) : null} + + {latest?.serviceRegionName ?? doc.planId} + + + + + router.back()} + accessibilityRole="button" + accessibilityLabel="Close eSIM detail" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + + + {/* ── Live usage ───────────────────────────────────────────────────── */} + + Data Remaining + + {usageLoading ? ( + + ) : isFetchError ? ( + // Network/auth error — the BFF call itself failed + + + Could not fetch live usage. + + Retry + + + ) : usageUnavailable ? ( + // 200 response but vendor returned an error for this eSIM + + + + Live usage data is temporarily unavailable. + + + Retry + + + ) : usage?.isUnlimited ? ( + + Data + Unlimited + + ) : ( + <> + + Data + + {usage?.remaining != null ? `${usage.remaining.toFixed(2)} GB` : "—"} + {usage?.total != null ? ` / ${usage.total} GB` : ""} + + + {/* Data bar — only when we have both values */} + {usage?.remaining != null && usage?.total != null && ( + + + + )} + + )} + + {/* Voice / SMS — only when the plan includes them */} + {!usageLoading && !isFetchError && !usageUnavailable && ( + <> + {usage?.voice != null && ( + + Voice + {usage.voice} mins + + )} + {usage?.sms != null && ( + + SMS + {usage.sms} + + )} + {usage?.expiresAt ? ( + + Expires {new Date(usage.expiresAt).toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + })} + + ) : null} + + )} + + {/* Fallback: show purchased allowances from the usage response when + vendor usage figures are unavailable. Sourced from usage.total + (active bundle total, reflecting topup stacking) and + usage.expiresAt (live expiry). Falls back to PlanHistoryEntry + only when the usage response itself is absent (isFetchError). */} + {(usageUnavailable || isFetchError) && ( + + Purchased Allowances + + {/* Prefer usage.total / usage.isUnlimited when the 200 arrived + (usageUnavailable); fall back to PlanHistoryEntry when the + fetch failed entirely (isFetchError && no usage object). */} + {(usage?.isUnlimited ?? latest?.isUnlimited) ? ( + Unlimited data + ) : ( + <> + {/* Data — prefer usage.total (accounts for topup stacking) */} + {(usage?.total ?? latest?.data) != null && ( + + Data + + {(usage?.total ?? latest?.data)} GB + + + )} + {/* Voice / SMS — usage response carries remaining only; + fall back to PlanHistoryEntry snapshot for the total. */} + {(usage?.voice ?? latest?.voice) != null && ( + + Voice + + {(usage?.voice ?? latest?.voice)} mins + + + )} + {(usage?.sms ?? latest?.sms) != null && ( + + SMS + + {(usage?.sms ?? latest?.sms)} + + + )} + + )} + + {/* Expiry — prefer live expiresAt from usage response */} + {(usage?.expiresAt ?? null) ? ( + + Expires {new Date(usage!.expiresAt!).toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + })} + + ) : latest?.validity != null ? ( + + Validity + {latest.validity} days + + ) : null} + + )} + + + {/* ── Installation ─────────────────────────────────────────────────── */} + + Installation + + {lpa ? ( + <> + + + + + + {Platform.OS === "ios" && appleUrl && ( + + + Install on this iPhone + + )} + + ) : ( + <> + + + QR code not yet available. Check back after the eSIM is fully provisioned. + + + )} + + + {/* ── Plan history ─────────────────────────────────────────────────── */} + {doc.planHistory && doc.planHistory.length > 0 && ( + + Plan History + {[...doc.planHistory].reverse().map((entry, i) => ( + + + + {entry.planId} + + + + + {new Date(entry.purchaseDate).toLocaleDateString(undefined, { + day: "numeric", month: "short", year: "numeric", + })} + {entry.validity ? ` · ${entry.validity} days` : ""} + + + {entry.isUnlimited + ? "Unlimited data" + : [ + entry.data != null && `${entry.data} GB`, + entry.voice != null && `${entry.voice} mins`, + entry.sms != null && `${entry.sms} SMS`, + ] + .filter(Boolean) + .join(" · ") || "—"} + + + ))} + + )} + + {/* ── Coverage ─────────────────────────────────────────────────────── */} + {/* + Coverage data (countryWiseNetworkCoverages) is catalogue-owned and is + not snapshotted onto PlanHistoryEntry or ESimDocument by the BFF. + This section is hidden until the BFF snapshots serviceRegionCode on + PlanHistoryEntry, enabling a catalogue cross-reference at render time + without a brittle region-name lookup. + */} + + {/* ── Actions ──────────────────────────────────────────────────────── */} + + + View Order + + + + + ); +} diff --git a/app/wc-connect.tsx b/app/wc-connect.tsx index 30c3701..a60eef3 100644 --- a/app/wc-connect.tsx +++ b/app/wc-connect.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useLocalSearchParams, useRouter } from "expo-router"; import { getWcSignClient } from "@/utils/walletconnect/signClient"; +import { logger } from "@/utils/logger"; // Handles deep-links of the form kokio://wc-connect?uri=wc%3ATOPIC%402%3F... // Passes the decoded WC pairing URI to the sign client, which fires @@ -17,7 +18,7 @@ export default function WcConnectScreen() { getWcSignClient() .then((client) => client.pair({ uri: decodeURIComponent(uri) })) .catch((err) => { - if (__DEV__) console.error("[WC] pair failed:", err); + logger.error('WC_PAIR_FAILED', { err }); router.replace("/"); }); // router is a stable singleton reference from expo-router diff --git a/app/wc-session.tsx b/app/wc-session.tsx index a1d1944..d9877bd 100644 --- a/app/wc-session.tsx +++ b/app/wc-session.tsx @@ -17,6 +17,7 @@ import { pendingProposal, setPendingProposal, } from "@/utils/walletconnect/signClient"; +import { logger } from "@/utils/logger"; const createStyles = () => StyleSheet.create({ container: { @@ -136,7 +137,7 @@ export default function WcSessionScreen() { setPendingProposal(null); router.replace("/"); } catch (err) { - if (__DEV__) console.error("[WC] approve failed:", err); + logger.error('WC_APPROVE_FAILED', { err }); } finally { setLoading(false); } @@ -151,7 +152,7 @@ export default function WcSessionScreen() { reason: { code: 4001, message: "User rejected" }, }); } catch (err) { - if (__DEV__) console.error("[WC] reject failed:", err); + logger.error('WC_REJECT_FAILED', { err }); } finally { setPendingProposal(null); setLoading(false); diff --git a/appKeys.ts b/appKeys.ts index cee6987..514f897 100644 --- a/appKeys.ts +++ b/appKeys.ts @@ -1,4 +1,5 @@ import Constants from "expo-constants"; +import { logger } from "@/utils/logger"; export interface AppExtraConfig { authServerBaseUrl?: string; @@ -23,7 +24,7 @@ const extra = | AppExtraConfig | undefined); -console.log("[KOKIO CONFIG DEBUG]", { + logger.debug("[KOKIO CONFIG DEBUG]", { extraKeys: Object.keys(extra ?? {}), apiBaseUrl: extra?.apiBaseUrl, authServerBaseUrl: extra?.authServerBaseUrl, @@ -47,35 +48,11 @@ export const Config = { EXTERNAL_WALLET_CALLBACK: extra?.externalWalletCallback, validateSecrets: () => { - if (!extra?.authServerBaseUrl) { - console.error( - "Critical Error: AUTH_SERVER_BASE_URL is missing. Check your EAS Secrets configuration." - ); - } - - if (!extra?.redirectUri) { - console.error( - "Critical Error: REDIRECT_URI is missing. Check your EAS Secrets configuration." - ); - } - - if (!extra?.apiBaseUrl) { - console.error( - "Critical Error: API_BASE_URL is missing. Check your EAS Secrets configuration." - ); - } - - if (!extra?.stripePublishableKey) { - console.warn( - "Warning: STRIPE_PUBLISHABLE_KEY is not set. Stripe payments (PAY-008) will not work." - ); - } - - if (!extra?.walletConnectProjectId) { - console.warn( - "Warning: WALLETCONNECT_PROJECT_ID is not set. WalletConnect sessions (PAY-011) will not work." - ); - } + if (!extra?.authServerBaseUrl) logger.error('CONFIG_MISSING_AUTH_SERVER_BASE_URL'); + if (!extra?.redirectUri) logger.error('CONFIG_MISSING_REDIRECT_URI'); + if (!extra?.apiBaseUrl) logger.error('CONFIG_MISSING_API_BASE_URL'); + if (!extra?.stripePublishableKey) logger.warn('CONFIG_MISSING_STRIPE_PUBLISHABLE_KEY'); + if (!extra?.walletConnectProjectId) logger.warn('CONFIG_MISSING_WALLETCONNECT_PROJECT_ID'); }, }; diff --git a/assets/images/africa.png b/assets/images/africa.png index 8242c5f..bc98425 100644 Binary files a/assets/images/africa.png and b/assets/images/africa.png differ diff --git a/assets/images/asia.png b/assets/images/asia.png index 7ac4941..31d6499 100644 Binary files a/assets/images/asia.png and b/assets/images/asia.png differ diff --git a/assets/images/caribbean.png b/assets/images/caribbean.png new file mode 100644 index 0000000..4da3259 Binary files /dev/null and b/assets/images/caribbean.png differ diff --git a/assets/images/europe.png b/assets/images/europe.png index 3f8b86b..5259274 100644 Binary files a/assets/images/europe.png and b/assets/images/europe.png differ diff --git a/assets/images/middle-east.png b/assets/images/middle-east.png new file mode 100644 index 0000000..a097fc7 Binary files /dev/null and b/assets/images/middle-east.png differ diff --git a/assets/images/north-america.png b/assets/images/north-america.png index eab8fa9..2fba5c0 100644 Binary files a/assets/images/north-america.png and b/assets/images/north-america.png differ diff --git a/assets/images/oceania.png b/assets/images/oceania.png new file mode 100644 index 0000000..8352398 Binary files /dev/null and b/assets/images/oceania.png differ diff --git a/assets/images/south-america.png b/assets/images/south-america.png index bc18181..2742b34 100644 Binary files a/assets/images/south-america.png and b/assets/images/south-america.png differ diff --git a/components/AuthencticationModal.web.tsx b/components/AuthencticationModal.web.tsx deleted file mode 100644 index b867a2b..0000000 --- a/components/AuthencticationModal.web.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { - ActivityIndicator, - Image, - Modal, - Pressable, - StyleSheet, - Text, - View, -} from "react-native"; -import { useAuthRelay } from "@/hooks/useAuthRelayer"; -import { ThemedText } from "./ThemedText"; -import { useKokio } from "@/hooks/useKokio"; -import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; - -type AuthMode = "choice" | "authenticating" | "error"; - -const styles = StyleSheet.create({ - backdrop: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.6)", - alignItems: "center", - justifyContent: "center", - }, - card: { - width: 360, - borderRadius: 25, - padding: 24, - alignItems: "center", - backgroundColor: Theme.colors.modalBackground, - }, - kokioImage: { - height: 60, - marginTop: 10, - resizeMode: "contain", - }, - authRequiredText: { - fontSize: 24, - fontWeight: "300", - fontFamily: "Lexend-Light", - marginTop: 32, - color: Theme.colors.text, - }, - authSubtext: { - fontSize: 13, - marginTop: 12, - fontWeight: "300", - fontFamily: "Lexend-Light", - textAlign: "center", - color: Theme.colors.foreground, - }, - loadingContainer: { - alignItems: "center", - justifyContent: "center", - marginTop: 32, - }, - loadingText: { - fontSize: 13, - fontWeight: "300", - fontFamily: "Lexend-Light", - marginTop: 12, - color: Theme.colors.foreground, - }, - errorText: { - fontSize: 13, - color: Theme.colors.destructive, - fontFamily: "Lexend-Light", - textAlign: "center", - marginTop: 12, - paddingHorizontal: 24, - }, - buttonRow: { - width: "100%", - marginTop: 32, - flexDirection: "row", - justifyContent: "center", - gap: 12, - }, - loginButtonRow: { - width: "100%", - marginTop: 32, - alignItems: "center", - }, - loginButton: { - width: "55%", - minWidth: 180, - height: 52, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", - backgroundColor: Theme.colors.highlight, - }, - primaryButton: { - flex: 1, - maxWidth: 160, - height: 52, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", - backgroundColor: Theme.colors.highlight, - }, - primaryButtonText: { - fontSize: 16, - fontWeight: "600", - fontFamily: "Lexend-Light", - color: "#000000", - }, - secondaryButton: { - flex: 1, - maxWidth: 160, - height: 52, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", - borderWidth: 1, - borderColor: Theme.colors.foreground, - }, - secondaryButtonText: { - fontSize: 16, - fontWeight: "300", - fontFamily: "Lexend-Light", - color: Theme.colors.foreground, - }, - cancelText: { - fontSize: 16, - fontWeight: "300", - fontFamily: "Lexend-Light", - color: Theme.colors.link, - }, -}); - -export function AuthenticationModal() { - const { isDark: _isDark } = useTheme(); - const [mode, setMode] = useState("choice"); - const [visible, setVisible] = useState(true); - - const { state, loginWithPasskey, signUpWithPasskey, recoverWithPasskey, clearError } = - useAuthRelay(); - const { kokio, setupKokioRegistration, setupKokioRecovery, clearKokioUser } = - useKokio(); - - // Distinguishes a fresh browser / never-registered device (show New vs. - // Existing choice) from a device that already completed passkey setup - // (show a single Log In button). SecureStore is unavailable on web, so - // this relies solely on the in-memory/persisted kokio context state. - const isReturningUser = !!kokio.deviceWalletAddress; - - useEffect(() => { - if (state.authenticated) { - setVisible(false); - } else { - // This component stays mounted for the app's lifetime — only `visible` - // toggles — so `mode` from a prior attempt (e.g. left at - // "authenticating" after a successful login) would otherwise leak into - // the next time the modal reopens (e.g. on logout). - clearError(); - setMode("choice"); - setVisible(true); - } - // clearError intentionally omitted: it's recreated every provider render - // and including it would re-trigger this effect on unrelated re-renders. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.authenticated]); - - useEffect(() => { - if (state.error) setMode("error"); - }, [state.error]); - - const handleNewUser = useCallback(async () => { - clearError(); - setMode("authenticating"); - try { - const data = await signUpWithPasskey({}); - if (data) { - await setupKokioRegistration( - data.deviceWalletAddress, - data.deviceUniqueIdentifier, - data.credentialId, - data.publicKeyX, - data.publicKeyY, - data.rawSalt ?? "" - ); - setVisible(false); - } else { - setMode("error"); - } - } catch (e) { - console.error("[auth/web] handleNewUser error", e); - setMode("error"); - } - }, [signUpWithPasskey, setupKokioRegistration, clearError]); - - const handleExistingUser = useCallback(async () => { - clearError(); - setMode("authenticating"); - try { - let effectiveAddress = kokio.deviceWalletAddress; - // SecureStore is unavailable on web; rely on in-memory state only - if (__DEV__) - console.log( - "[auth/web] handleExistingUser — path:", - effectiveAddress ? "login" : "recover" - ); - - if (effectiveAddress) { - const result = await loginWithPasskey(); - if (result === "success") { - setVisible(false); - } else if (result === "no-credential") { - await clearKokioUser(); - clearError(); - const recovered = await recoverWithPasskey(); - if (recovered) { - await setupKokioRecovery( - recovered.deviceWalletAddress, - recovered.credentialId - ); - setVisible(false); - } else { - setMode("error"); - } - } else { - setMode("error"); - } - } else { - const recovered = await recoverWithPasskey(); - if (recovered) { - await setupKokioRecovery( - recovered.deviceWalletAddress, - recovered.credentialId - ); - setVisible(false); - } else { - setMode("error"); - } - } - } catch (e) { - console.error("[auth/web] handleExistingUser error", e); - setMode("error"); - } - }, [loginWithPasskey, recoverWithPasskey, kokio, setupKokioRecovery, clearKokioUser, clearError]); - - return ( - - - - - - Authentication Required - - - {mode === "authenticating" - ? "Verifying your identity…" - : isReturningUser - ? "Log in to continue" - : "Choose how to get started"} - - - {mode === "authenticating" ? ( - - - Authenticating... - - ) : isReturningUser ? ( - - - Log In - - - ) : ( - - - New User - - - Existing User - - - )} - - {!!state.error && mode === "error" && ( - {state.error} - )} - - { - clearError(); - setMode("choice"); - setVisible(false); - }} - style={{ alignSelf: "flex-start", marginTop: 32, marginBottom: 8 }} - > - Cancel - - - - - ); -} diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index 1bd6a2d..a295054 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -13,6 +13,7 @@ import BottomSheet, { BottomSheetBackdropProps, BottomSheetView, } from "@gorhom/bottom-sheet"; +import { isAccountDeletedCached, subscribeAccountDeleted } from '@/utils/auth/accountDeleted'; import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { ThemedText } from "./ThemedText"; import { useKokio } from "@/hooks/useKokio"; @@ -20,6 +21,7 @@ import { BlurView } from "expo-blur"; import { Easing } from "react-native-reanimated"; import { Theme } from "@/constants/Colors"; import { useTheme } from "@/contexts/ThemeContext"; +import { logger } from '@/utils/logger'; type AuthMode = "choice" | "authenticating" | "error"; @@ -35,6 +37,7 @@ const createStyles = () => fontWeight: "300", fontFamily: "Lexend-Light", marginTop: 32, + paddingTop: 12, }, authSubtext: { fontSize: 13, @@ -43,6 +46,15 @@ const createStyles = () => fontFamily: "Lexend-Light", textAlign: "center", }, + deletedBody: { + fontSize: 13, + marginTop: 12, + fontWeight: "300", + fontFamily: "Lexend-Light", + textAlign: "center", + paddingHorizontal: 12, + lineHeight: 19, + }, loadingContainer: { alignItems: "center", justifyContent: "center", @@ -129,12 +141,19 @@ export function AuthenticationModal() { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); const [mode, setMode] = useState("choice"); + const [accountDeleted, setAccountDeleted] = useState(isAccountDeletedCached()); + const [localError, setLocalError] = useState(""); const sheetRef = useRef(null); const { state, loginWithPasskey, signUpWithPasskey, recoverWithPasskey, clearError } = useAuthRelay(); const { kokio, setupKokioRegistration, setupKokioRecovery, clearKokioUser } = useKokio(); + + const resetErrors = useCallback(() => { + clearError(); + setLocalError(""); + }, [clearError]); // Distinguishes a fresh install / never-registered device (show New vs. // Existing choice) from a device that already completed passkey setup @@ -151,6 +170,8 @@ export function AuthenticationModal() { // "Log In" button before flipping to the New/Existing choice. const hasResolvedOnce = useRef(!!kokio.deviceWalletAddress); + useEffect(() => subscribeAccountDeleted(setAccountDeleted), []); + useEffect(() => { if (kokio.deviceWalletAddress) { hasResolvedOnce.current = true; @@ -193,18 +214,13 @@ export function AuthenticationModal() { ); const handleNewUser = useCallback(async () => { - clearError(); + resetErrors(); setMode("authenticating"); let succeeded = false; try { const data = await signUpWithPasskey({}); - if (__DEV__) - console.log( - "[auth] signUpWithPasskey result:", - data ? { deviceWalletAddress: data.deviceWalletAddress } : null - ); + logger.debug('AUTH_SIGNUP_RESULT', data ? { deviceWalletAddress: data.deviceWalletAddress } : null); if (data) { - succeeded = true; await setupKokioRegistration( data.deviceWalletAddress, data.deviceUniqueIdentifier, @@ -213,67 +229,78 @@ export function AuthenticationModal() { data.publicKeyY, data.rawSalt ?? "" ); + succeeded = true; sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); } } catch (e) { - console.error("[auth] handleNewUser error", e); + logger.error('AUTH_SIGNUP_FAILED', { err: e }); } finally { if (!succeeded) setMode("error"); } - }, [signUpWithPasskey, setupKokioRegistration, clearError]); + }, [signUpWithPasskey, setupKokioRegistration, resetErrors]); const handleExistingUser = useCallback(async () => { - clearError(); + resetErrors(); setMode("authenticating"); let succeeded = false; try { const effectiveAddress = kokio.deviceWalletAddress || (await SecureStore.getItemAsync("deviceWalletAddress")); - if (__DEV__) - console.log( - "[auth] handleExistingUser — path:", - effectiveAddress ? "login" : "recover" - ); + logger.debug('AUTH_EXISTING_PATH', { path: effectiveAddress ? 'login' : 'recover' }); if (effectiveAddress) { const result = await loginWithPasskey(); - if (__DEV__) console.log("[auth] loginWithPasskey result:", result); + logger.debug('AUTH_LOGIN_RESULT', { result }); if (result === "success") { succeeded = true; sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); } else if (result === "no-credential") { // Passkey deleted — fall back to recovery await clearKokioUser(); - clearError(); + resetErrors(); const recovered = await recoverWithPasskey(); if (recovered) { - succeeded = true; await setupKokioRecovery( recovered.deviceWalletAddress, recovered.credentialId ); + succeeded = true; sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); } } } else { const recovered = await recoverWithPasskey(); - if (__DEV__) - console.log( - "[auth] recoverWithPasskey result:", - recovered ? { credentialId: recovered.credentialId } : null - ); + logger.debug('AUTH_RECOVER_RESULT', { recovered }); if (recovered) { - succeeded = true; await setupKokioRecovery( recovered.deviceWalletAddress, recovered.credentialId ); + succeeded = true; sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); + } else { + const recovered = await recoverWithPasskey(); + logger.debug('AUTH_RECOVER_RESULT', { recovered }); + if (recovered) { + await setupKokioRecovery( + recovered.deviceWalletAddress, + recovered.credentialId + ); + succeeded = true; + sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); + } else { + // No discoverable Kokio passkey on this device. Distinct from a + // failure — this is simply a device that has never registered, or + // whose passkey was deleted from the password manager. + setLocalError( + "No Kokio passkey found on this device. Tap New User to create an account." + ); + } } } } catch (e) { - console.error("[auth] handleExistingUser error", e); + logger.error('AUTH_LOGIN_FAILED', { err: e }); } finally { if (!succeeded) setMode("error"); } @@ -283,7 +310,7 @@ export function AuthenticationModal() { kokio, setupKokioRecovery, clearKokioUser, - clearError, + resetErrors, ]); useEffect(() => { @@ -294,11 +321,11 @@ export function AuthenticationModal() { // expanded/closed, never unmounted — so `mode` from a prior attempt // (e.g. left at "authenticating" after a successful login) would // otherwise leak into the next time the modal reopens (e.g. on logout). - clearError(); + resetErrors(); setMode("choice"); sheetRef.current?.expand({ duration: 250, easing: Easing.in(Easing.quad) }); } - // clearError intentionally omitted: it's recreated every provider render + // resetErrors intentionally omitted: it's recreated every provider render // and including it would re-trigger this effect (and re-animate the // sheet) on unrelated re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -323,7 +350,9 @@ export function AuthenticationModal() { )} ), - [styles, mode] + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps + [mode] ); return ( @@ -343,43 +372,75 @@ export function AuthenticationModal() { style={styles.kokioImage} /> - Authentication Required - - - {mode === "authenticating" - ? "Verifying your identity…" - : isReturningUser - ? "Log in to continue" - : "Choose how to get started"} + {accountDeleted ? "Account Deleted" : "Authentication Required"} - {mode === "authenticating" || isReturningUser === null ? ( - loadingContent - ) : isReturningUser ? ( - - - Log In - - + {accountDeleted ? ( + <> + + This account has been deleted and cannot be restored. If your Kokio passkey + is still on this device, remove it from your password manager — it no longer + grants access to anything. + + + To use Kokio again, create a new account. This generates a new passkey and a + new wallet. + + + {mode === "authenticating" ? ( + loadingContent + ) : ( + + {/* handleNewUser — not signUpWithPasskey directly: it supplies the + {} argument and runs setupKokioRegistration, and its success path + clears the deleted flag via clearAccountDeleted(). */} + + Create New Account + + + )} + ) : ( - - - New User - - - Existing User - - + <> + + {mode === "authenticating" + ? "Verifying your identity…" + : isReturningUser === null + ? "Checking this device…" + : isReturningUser + ? "Log in to continue" + : "Choose how to get started"} + + + {mode === "authenticating" || isReturningUser === null ? ( + loadingContent + ) : isReturningUser ? ( + + + Log In + + + ) : ( + + + New User + + + Existing User + + + )} + )} - {!!state.error && mode === "error" && ( - {state.error} + {mode === "error" && !!(localError || state.error) && ( + {localError || state.error} )} { - clearError(); + resetErrors(); setMode("choice"); sheetRef.current?.close({ duration: 250, diff --git a/components/AuthenticationModal.web.tsx b/components/AuthenticationModal.web.tsx deleted file mode 100644 index 4763b76..0000000 --- a/components/AuthenticationModal.web.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function AuthenticationModal() { - return null; -} diff --git a/components/DeleteAccountModal.tsx b/components/DeleteAccountModal.tsx new file mode 100644 index 0000000..1316903 --- /dev/null +++ b/components/DeleteAccountModal.tsx @@ -0,0 +1,233 @@ +/** + * DeleteAccountModal — type-to-confirm gate for DELETE /v1/account. + * + * The three consequences: + * 1. Irreversible. No recovery path exists for anyone, including the operator. + * 2. Paid eSIMs KEEP WORKING until expiry. + * 3. On-chain records are immutable and cannot be deleted by anyone. + */ +import React, { useState, useCallback, useMemo } from 'react'; +import { + Modal, + View, + TextInput, + Pressable, + ActivityIndicator, + StyleSheet, +} from 'react-native'; +import { BlurView } from 'expo-blur'; +import { ThemedText } from '@/components/ThemedText'; +import { Theme } from '@/constants/Colors'; +import { useTheme } from '@/contexts/ThemeContext'; +import { StepUpCancelledError } from '@/utils/auth/errors'; +import { logger } from '@/utils/logger'; + +const CONFIRM_WORD = 'DELETE'; + +interface Props { + visible: boolean; + onCancel: () => void; + onConfirm: () => Promise; +} + +const createStyles = (isDark: boolean) => + StyleSheet.create({ + scrim: { + flex: 1, + justifyContent: 'center', + padding: 20, + backgroundColor: isDark ? 'rgba(0,0,0,0.55)' : 'rgba(0,0,0,0.25)', + }, + card: { + backgroundColor: Theme.colors.modalBackground, + borderRadius: 25, + padding: 22, + }, + title: { + fontSize: 22, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.text, + marginBottom: 12, + }, + body: { + fontSize: 14, + lineHeight: 20, + fontFamily: 'Lexend-Light', + fontWeight: '300', + color: Theme.colors.text, + marginBottom: 14, + }, + bullets: { marginBottom: 18 }, + bullet: { + fontSize: 13, + lineHeight: 19, + fontFamily: 'Lexend-Light', + fontWeight: '300', + color: Theme.colors.foreground, + marginBottom: 8, + }, + prompt: { + fontSize: 13, + fontFamily: 'Lexend-Light', + color: Theme.colors.foreground, + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderColor: Theme.colors.foreground, + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 12, + color: Theme.colors.text, + fontFamily: 'Lexend-Light', + fontSize: 16, + letterSpacing: 2, + }, + error: { + color: Theme.colors.destructive, + fontFamily: 'Lexend-Light', + fontSize: 13, + marginTop: 10, + }, + actions: { flexDirection: 'row', marginTop: 24, gap: 12 }, + btn: { + flex: 1, + height: 52, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + btnGhost: { + borderWidth: 1, + borderColor: Theme.colors.foreground, + }, + btnGhostText: { + fontSize: 16, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.foreground, + }, + btnDanger: { backgroundColor: Theme.colors.destructive }, + btnDangerText: { + fontSize: 16, + fontWeight: '600', + fontFamily: 'Lexend-Light', + color: '#FFFFFF', + }, + btnDisabled: { opacity: 0.45 }, + }); + +export const DeleteAccountModal: React.FC = ({ visible, onCancel, onConfirm }) => { + const { isDark } = useTheme(); + const styles = useMemo(() => createStyles(isDark), [isDark]); + + const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const armed = input.trim().toUpperCase() === CONFIRM_WORD && !busy; + + const handleConfirm = useCallback(async () => { + if (!armed) return; + setBusy(true); + setError(''); + try { + await onConfirm(); + } catch (err) { + // User dismissed the passkey prompt — not an error state, just abort. + if (err instanceof StepUpCancelledError) { + setBusy(false); + return; + } + logger.error('ACCOUNT_DELETE_FAILED', { err }); + setError('Deletion failed. Your account has not been changed. Please try again.'); + setBusy(false); + } + }, [armed, onConfirm]); + + const handleCancel = useCallback(() => { + if (busy) return; + setInput(''); + setError(''); + onCancel(); + }, [busy, onCancel]); + + return ( + + + + + Delete account + + + This permanently deletes your account. It cannot be undone — not by you, and + not by us. There is no recovery. + + + + + • Your eSIMs keep working. Any plan you have already paid for stays active on + your device until it expires. Deleting your account does not cancel or refund it. + + + • You lose access to your order history, eSIM details, and wallet in this app. + + + • On-chain records are permanent. Transactions already published to the + blockchain cannot be deleted by anyone. + + + • You will need to remove your passkey yourself. We will show you how in the + next step. + + + + Type {CONFIRM_WORD} to confirm. + + + + {!!error && {error}} + + + + Cancel + + + + {busy ? ( + + ) : ( + Delete account + )} + + + + + + ); +}; diff --git a/components/ESIMItem.tsx b/components/ESIMItem.tsx index 3cda26a..c914656 100644 --- a/components/ESIMItem.tsx +++ b/components/ESIMItem.tsx @@ -11,7 +11,8 @@ export interface Esim { catalogueId: string; actualSellingPrice: number; isUnlimited: boolean; - serviceRegionCode: string; + // Optional: not present on display items built from ESimDocument/PlanHistoryEntry. + serviceRegionCode?: string; serviceRegionFlag?: string | null; serviceRegionName?: string | null; coverageType: string; @@ -23,6 +24,7 @@ export interface Esim { isTopupAvailable?: boolean; isAutoStart?: boolean; isKycRequired?: boolean; + info?: string | null; countryWiseNetworkCoverages?: { countryCode?: string; countryName?: string; @@ -60,20 +62,21 @@ const ESIMItem = ({ () => ( <> - {/* */} - {/* TODO: Use flag from API response and fallback to this if not present */} - {item?.coverageType === "LOCAL" && item?.serviceRegionCode && ( - - )} + {item?.coverageType === "LOCAL" && + (item?.serviceRegionCode || item?.serviceRegionFlag) && ( + + )} - {item.serviceRegionName} + + {item.serviceRegionName} + - {/* replaced "Buy" with "View" */} View @@ -127,6 +131,8 @@ const ESIMItem = ({ style={[styles.esimItemContainer, containerStyle]} onPress={onPress} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`${item?.serviceRegionName || "eSIM"} plan`} > {content} diff --git a/components/ExternalLink.tsx b/components/ExternalLink.tsx index 720af62..de8cd5c 100644 --- a/components/ExternalLink.tsx +++ b/components/ExternalLink.tsx @@ -1,23 +1,17 @@ import { Link } from 'expo-router'; import { openBrowserAsync } from 'expo-web-browser'; import { type ComponentProps } from 'react'; -import { Platform } from 'react-native'; type Props = Omit, 'href'> & { href: string }; export function ExternalLink({ href, ...rest }: Props) { return ( { - if (Platform.OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href); - } + event.preventDefault(); + await openBrowserAsync(href); }} /> ); diff --git a/components/Header.tsx b/components/Header.tsx index 515fb3a..2aa23bd 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,4 +1,4 @@ -import { View } from "react-native"; +import { Pressable, View } from "react-native"; import { useNavigation, router } from "expo-router"; import Ionicons from "@expo/vector-icons/Ionicons"; @@ -56,12 +56,18 @@ const Header = ({ {/* left — back button or empty spacer */} {hasBack && ( - + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + accessibilityRole="button" + accessibilityLabel="Go back" + > + + )} diff --git a/components/PasskeyRemovalModal.tsx b/components/PasskeyRemovalModal.tsx new file mode 100644 index 0000000..f50f16f --- /dev/null +++ b/components/PasskeyRemovalModal.tsx @@ -0,0 +1,183 @@ +/** + * PasskeyRemovalModal — shown after ACCOUNT_DELETED. + * + * Why this exists: the BFF spec states the Auth Server is intentionally NOT informed of account deletion. + * The passkey therefore remains registered and will keep authenticating successfully. + * The user can "log in" forever and every authenticated call will return ACCOUNT_DELETED (404). + * Removing the credential is a step only the user can perform, in their platform password manager. + */ +import React, { useMemo } from 'react'; +import { Modal, View, Pressable, Platform, StyleSheet, Linking } from 'react-native'; +import { BlurView } from 'expo-blur'; +import { ThemedText } from '@/components/ThemedText'; +import { Theme } from '@/constants/Colors'; +import { useTheme } from '@/contexts/ThemeContext'; +import { logger } from '@/utils/logger'; + +interface Props { + visible: boolean; + onDismiss: () => void; +} + +const STEPS = Platform.select({ + ios: [ + 'Open the Settings (or Passwords) app.', + 'Tap Passwords/Passkeys, then authenticate.', + 'Find the entry for kokio.app and delete it.', + ], + android: [ + 'Open Google Password Manager (or your password manager app).', + 'Find the passkey for kokio.app.', + 'Delete the passkey.', + ], + default: ['Open your password manager and delete the passkey for kokio.app.'], +}) as string[]; + +// Built inside useMemo(…, [isDark]) — Theme.colors.* must resolve at call time, +// not at module load, or the palette freezes on whichever theme was active first. +const createStyles = (isDark: boolean) => + StyleSheet.create({ + scrim: { + flex: 1, + justifyContent: 'center', + padding: 20, + backgroundColor: isDark ? 'rgba(0,0,0,0.55)' : 'rgba(0,0,0,0.25)', + }, + card: { + backgroundColor: Theme.colors.modalBackground, + borderRadius: 25, + padding: 22, + }, + title: { + fontSize: 22, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.text, + marginBottom: 12, + }, + body: { + fontSize: 14, + lineHeight: 20, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.text, + marginBottom: 12, + }, + steps: { marginVertical: 8 }, + step: { + fontSize: 13, + lineHeight: 22, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.foreground, + }, + note: { + fontSize: 12, + lineHeight: 18, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.foreground, + marginTop: 12, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 24, + gap: 12, + }, + btn: { + height: 52, + minWidth: 120, + paddingHorizontal: 22, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + btnGhost: { + borderWidth: 1, + borderColor: Theme.colors.foreground, + }, + btnGhostText: { + fontSize: 16, + fontWeight: '300', + fontFamily: 'Lexend-Light', + color: Theme.colors.foreground, + }, + btnPrimary: { backgroundColor: Theme.colors.highlight }, + btnPrimaryText: { + fontSize: 16, + fontWeight: '600', + fontFamily: 'Lexend-Light', + color: '#000000', + }, + }); + +export const PasskeyRemovalModal: React.FC = ({ visible, onDismiss }) => { + const { isDark } = useTheme(); + const styles = useMemo(() => createStyles(isDark), [isDark]); + + const openSettings = () => { + Linking.openSettings().catch((err) => + logger.error('PASSKEY_REMOVAL_OPEN_SETTINGS_FAILED', { err }), + ); + }; + + return ( + + + + + Your account is deleted + + + Your account and its credentials have been removed from our servers. To completely delete everything + from your device, uninstall the Kokio app and delete the associated passkey. + + + + Until you remove it, your device will still hold a passkey for Kokio. It no + longer grants access to anything — the account behind it is gone — but it will + keep appearing in your password manager. + + + + {STEPS.map((step, i) => ( + + {i + 1}. {step} + + ))} + + + + Any eSIM you already paid for KEEPS working until it expires. To use Kokio + again, create a new account — this generates a new passkey and a new wallet. + The deleted account cannot be restored. + + + + {Platform.OS === 'ios' && ( + + Open Settings + + )} + + Done + + + + + + ); +}; diff --git a/components/SearchInput.tsx b/components/SearchInput.tsx index 45c9c06..d0334b7 100644 --- a/components/SearchInput.tsx +++ b/components/SearchInput.tsx @@ -60,9 +60,16 @@ const SearchBar = ({ value={searchText} onChangeText={handleTextChange} placeholderTextColor={foreground} + accessibilityLabel={placeholder} /> {searchText.length > 0 && ( - + )} diff --git a/components/WalletScreens.tsx b/components/WalletScreens.tsx deleted file mode 100644 index a5c79d5..0000000 --- a/components/WalletScreens.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react'; -import { FlatList, StyleSheet, View, Image } from 'react-native'; -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { TouchableOpacity } from 'react-native-gesture-handler'; -import { Colors } from '@/constants/Colors'; - -const AllTokensScreen = (tokens: any[]) => { - const renderToken = ({ item }: { item: { icon: string, name: string, balance: string, id: string } }) => ( - - - {item.name} - {item.balance} - - ); - - return ( - - item.id} - /> - - ); -}; - -const AllTransactionsScreen = (transactions: any[]) => { - const renderTransaction = ({ item }: { item: { id: string, name: string, date: string, amount: string, image: string } }) => ( - - - - {item.name} - {item.date} - - {item.amount} - - ); - - return ( - - item.id} - /> - - ); -}; - -const AllContactsScreen = (contacts: any[]) => { - const renderContact = ({ item }: { item: { id: string, image: string, name: string } }) => ( - {/* Handle contact press */}}> - - {item.name} - - ); - - return ( - - item.id} - numColumns={3} - /> - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 16, - }, - tokenItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: Colors.light.inactive, - }, - tokenIcon: { - width: 40, - height: 40, - borderRadius: 20, - marginRight: 12, - }, - tokenName: { - flex: 1, - }, - tokenBalance: { - fontWeight: 'bold', - }, - transactionItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: Colors.light.inactive, - }, - transactionImage: { - width: 48, - height: 48, - borderRadius: 24, - marginRight: 12, - }, - transactionDetails: { - flex: 1, - }, - transactionName: { - fontWeight: 'bold', - }, - transactionDate: { - fontSize: 12, - color: Colors.light.inactive, - }, - transactionAmount: { - fontWeight: 'bold', - }, - contactItem: { - alignItems: 'center', - width: '33.33%', - marginBottom: 16, - }, - contactImage: { - width: 80, - height: 80, - borderRadius: 40, - marginBottom: 8, - }, - contactName: { - textAlign: 'center', - }, -}); - -export { AllTokensScreen, AllTransactionsScreen, AllContactsScreen }; \ No newline at end of file diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index bb34809..fc0578a 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -1,6 +1,6 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, View, Text, Dimensions, Platform, Pressable } from "react-native"; +import { StyleSheet, View, Text, Dimensions, Platform, Pressable, ScrollView } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useNavigation, router } from "expo-router"; import _get from "lodash/get"; @@ -8,6 +8,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { useSharedValue, useAnimatedStyle, + useDerivedValue, withTiming, interpolate, Easing, @@ -20,7 +21,8 @@ import CountryFlag from "@/components/ui/CountryFlag"; import DetailItem from "../ui/DetailItem"; -const HEADER_MIN_HEIGHT = Platform.OS === "android" ? 150 : 200; +const PILL_ROW_HEIGHT = 40; +const HEADER_MIN_HEIGHT = 150 + PILL_ROW_HEIGHT; const SCREEN_HEIGHT = Dimensions.get("window").height; const MAX_ALLOWED_HEIGHT = SCREEN_HEIGHT * 0.6; const DIVIDER_WIDTH = Dimensions.get("window").width - 32; @@ -28,21 +30,34 @@ const DIVIDER_WIDTH = Dimensions.get("window").width - 32; const ExpandableContent = ({ eSimItem = {}, onNetworkPress, + onContentSizeChange, }: { eSimItem?: any; onNetworkPress: () => void; + onContentSizeChange?: (w: number, h: number) => void; }) => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); const isMultiCountry = eSimItem?.coverageType !== "LOCAL"; return ( - + {ESIM_EXTRA_DETAILS.map((item, index) => { + const value = _get(eSimItem, item.key); + + if (item.hideWhenNullish && (value === null || value === undefined)) { + return null; + } + const isNetworkRow = item.key === "countryWiseNetworkCoverages"; if (isNetworkRow && isMultiCountry) { - const coverage: any[] = _get(eSimItem, item.key) || []; + const coverage: any[] = value || []; return ( ); })} - + ); }; @@ -134,16 +150,14 @@ const createStyles = () => StyleSheet.create({ expandIndicatorRow: { alignItems: "center", justifyContent: "center", - marginTop: Platform.OS === "android" ? 8 : 10, - marginBottom: Platform.OS === "android" ? 4 : 6, + paddingVertical: 12, }, pillHandle: { backgroundColor: Theme.colors.handle, borderRadius: 10, - height: 10, + height: 20, alignItems: "center", justifyContent: "center", - overflow: "hidden", }, arrowCenter: { alignItems: "center", @@ -184,10 +198,19 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { return eSimDetails; }, [eSimDetails]); - const [contentHeight, setContentHeight] = useState(0); + // useSharedValues keeps worklets and styles in sync without React re-renders + const contentHeight = useSharedValue(0); const animatedHeight = useSharedValue(computedHeaderHeight); const isExpanded = useSharedValue(false); + // Derived value instantly updates the max height when contentHeight finishes measuring + const headerMaxHeight = useDerivedValue(() => { + return Math.min( + contentHeight.value + computedHeaderHeight + 40, // 40 for pill + margins + MAX_ALLOWED_HEIGHT + ); + }); + const networkCoverage = useMemo( () => eSimItem?.countryWiseNetworkCoverages ?? [], [eSimItem] @@ -202,54 +225,65 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const navigation = useNavigation(); - const headerMaxHeight = Math.min( - contentHeight + computedHeaderHeight + 40, // 40 for pill + margins - MAX_ALLOWED_HEIGHT - ); - + const toggleExpanded = () => { + "worklet"; + if (isExpanded.value) { + isExpanded.value = false; + animatedHeight.value = withTiming(computedHeaderHeight, { + duration: 250, + easing: Easing.out(Easing.ease), + }); + } else { + isExpanded.value = true; + animatedHeight.value = withTiming(headerMaxHeight.value, { + duration: 250, + easing: Easing.out(Easing.ease), + }); + } + }; + + const tapGesture = Gesture.Tap().onEnd(() => { + "worklet"; + toggleExpanded(); + }); + const panGesture = Gesture.Pan().onEnd((event) => { "worklet"; const dy = event.translationY; if (Math.abs(dy) > 20) { - const shouldExpand = dy > 0 && !isExpanded.value; - const shouldCollapse = dy < 0 && isExpanded.value; - - if (shouldExpand) { - isExpanded.value = true; - animatedHeight.value = withTiming(headerMaxHeight, { - duration: 250, - easing: Easing.out(Easing.ease), - }); - } else if (shouldCollapse) { - isExpanded.value = false; - animatedHeight.value = withTiming(computedHeaderHeight, { - duration: 250, - easing: Easing.out(Easing.ease), - }); + const shouldExpand = dy > 0 && !isExpanded.value; + const shouldCollapse = dy < 0 && isExpanded.value; + if (shouldExpand || shouldCollapse) { + toggleExpanded(); } } }); - const onContentLayout = (event: any) => - setContentHeight(_get(event, "nativeEvent.layout.height")); + const combinedGesture = Gesture.Simultaneous(tapGesture, panGesture); - const handleBack = () => { + const handleBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); } - }; + }, [navigation]); const countryAndFlagWithGoBack = useMemo( () => ( - + hitSlop={12} + accessibilityRole="button" + accessibilityLabel="Go back" + > + + { )} ), - // styles have their own memo watching for changes based on theme + // All missing dependencies are of style attributes which are in their on useMemo() call // eslint-disable-next-line react-hooks/exhaustive-deps [handleBack, eSimItem] ); @@ -283,8 +317,8 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { /> { /> ), - // styles have their own memo watching for changes based on theme + // All missing dependencies are of style attributes which are in their on useMemo() call // eslint-disable-next-line react-hooks/exhaustive-deps [eSimItem] ); @@ -307,11 +341,10 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { height: animatedHeight.value, })); - // Only animate width — height is fixed on the pill const animatedPillWidth = useAnimatedStyle(() => ({ width: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [48, DIVIDER_WIDTH] ), })); @@ -319,7 +352,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const animatedContentStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [0, 1] ), })); @@ -327,7 +360,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const animatedArrowDownStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [1, 0] ), })); @@ -335,13 +368,13 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const animatedArrowUpStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [0, 1] ), })); return ( - + { animatedHeaderStyle, ]} > - {/* Country + flag + back */} { {detailItems} - {/* Pill drag handle */} @@ -376,19 +407,18 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { - {/* Expandable details */} - - - + { + contentHeight.value = height; + }} + /> ); }; - export default React.memo(CheckoutHeader); diff --git a/components/home/active-esim-scroll.tsx b/components/home/active-esim-scroll.tsx index 8dc1d8d..28fdae8 100644 --- a/components/home/active-esim-scroll.tsx +++ b/components/home/active-esim-scroll.tsx @@ -2,101 +2,133 @@ import React, { useCallback, useMemo } from "react"; import { View, Text, FlatList, StyleSheet, Dimensions } from "react-native"; import { router } from "expo-router"; import _isEmpty from "lodash/isEmpty"; -import _get from "lodash/get"; import { Theme } from "@/constants/Colors"; import { useTheme } from "@/contexts/ThemeContext"; -import { StoredPurchasedESIM } from "@/providers/kokioProvider"; +import { useEsims } from "@/hooks/useDeviceEsims"; +import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; +import type { Esim } from "@/components/ESIMItem"; +import ESIMItem from "@/components/ESIMItem"; -import ESIMItem from "../ESIMItem"; +// ─── Constants ──────────────────────────────────────────────────────────────── -// Only show eSIMs that have been provisioned or are active — exclude payment/processing/failed states -const PROVISIONED_STATUSES = new Set([ - "ACTIVE", - "CREATED", - "SUSPENDED", - "ESIM_PROVISIONED", - "ESIM_PROVISIONED_PENDING_CHAIN", - "COMPLETED", -]); +// eSIMs visible to the user in the active scroll: provisioned (not yet installed) and installed (in use). +// UNAVAILABLE and DEACTIVATED are omitted. +const ACTIVE_STATUSES: Set = new Set(['RELEASED', 'INSTALLED']); const SCREEN_WIDTH = Dimensions.get("window").width; -const ITEM_WIDTH = SCREEN_WIDTH * 0.9; -const SPACING = 8; - -const createStyles = () => StyleSheet.create({ - emptyCard: { - marginHorizontal: SPACING, - marginTop: 8, - backgroundColor: Theme.colors.card, - borderRadius: 18, - paddingVertical: 12, - paddingHorizontal: 16, - alignItems: "center", - justifyContent: "center", - gap: 8, - }, - emptyIcon: { - fontSize: 24, - }, - emptyTitle: { - fontSize: 16, - fontWeight: "700", - color: Theme.colors.text, - }, - emptySubtitle: { - fontSize: 14, - color: Theme.colors.foreground, - textAlign: "center", - }, - container: { - marginVertical: 12, - }, - title: { - fontSize: 16, - color: Theme.colors.text, - paddingLeft: 20, - }, - listContainer: { - paddingHorizontal: SPACING, - }, - itemWrapper: { - width: ITEM_WIDTH, - }, -}); - -const ActiveESIMsScroll = ({ - purchasedESIMs, -}: { - purchasedESIMs: StoredPurchasedESIM[]; -}) => { +const ITEM_WIDTH = SCREEN_WIDTH * 0.9; +const SPACING = 8; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const createStyles = () => + StyleSheet.create({ + container: { + marginVertical: 12, + }, + title: { + fontSize: 16, + color: Theme.colors.text, + paddingLeft: 20, + }, + listContainer: { + paddingHorizontal: SPACING, + }, + itemWrapper: { + width: ITEM_WIDTH, + }, + emptyCard: { + marginHorizontal: SPACING, + marginTop: 8, + backgroundColor: Theme.colors.card, + borderRadius: 18, + paddingVertical: 12, + paddingHorizontal: 16, + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + emptyIcon: { + fontSize: 24, + }, + emptyTitle: { + fontSize: 16, + fontWeight: "700", + color: Theme.colors.text, + }, + emptySubtitle: { + fontSize: 14, + color: Theme.colors.foreground, + textAlign: "center", + }, + }); + +// ─── ESimDocument -> ESim display adapter ────────────────────────────────────── +/** + * Maps the server eSIM document to the minimal shape ESIMItem renders. + * Uses the most-recent PlanHistoryEntry for plan metadata (data, validity, region name/flag). + * The server snapshots these at fulfilment time so they are self-contained. + * serviceRegionCode is not on ESimDocument; the flag renders via serviceRegionFlag when available. + * ESIMItem accepts an absent serviceRegionCode since the guard was updated to accept flagUrl alone. + */ + +function toDisplayItem(doc: ESimDocument): Esim { + const entries: PlanHistoryEntry[] = doc.planHistory ?? []; + const latest = entries[entries.length - 1] as PlanHistoryEntry | undefined; + + return { + catalogueId: '', // not needed — display-only, no buy button + actualSellingPrice: 0, // not needed — display-only + isUnlimited: latest?.isUnlimited ?? false, + serviceRegionCode: undefined, // not on ESimDocument; flag renders via URL + serviceRegionFlag: latest?.serviceRegionFlag ?? null, + serviceRegionName: latest?.serviceRegionName ?? null, + coverageType: latest?.coverageType ?? 'LOCAL', + data: latest?.data ?? null, + sms: latest?.sms ?? null, + voice: latest?.voice ?? null, + validity: latest?.validity ?? null, + info: null, + }; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +// No props — self-fetching via useEsims(). +const ActiveESIMsScroll = () => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); - const activeESIMs = useMemo( - () => purchasedESIMs.filter((e) => PROVISIONED_STATUSES.has(e.transactionData?.orderStatus ?? "")), - [purchasedESIMs] + const { esims, isLoading } = useEsims(); + + const activeEsims = useMemo( + () => esims.filter((e) => ACTIVE_STATUSES.has(e.activationStatus)), + [esims], ); - const handleESIMPress = useCallback((purchasedESIM: StoredPurchasedESIM) => { + // Navigate to the Orders tab, expanding the card for this eSIM. + // Uses esimId as the expand key — orders.tsx matches on esimId. + const handleESIMPress = useCallback((doc: ESimDocument) => { return () => { - const expandId = - _get(purchasedESIM, "transactionData.correlationId", "") || - _get(purchasedESIM, "transactionData.orderId", ""); - router.navigate({ - pathname: "/(tabs)/orders", - params: { expandOrderId: expandId }, + router.push({ + pathname: "/esim-detail", + params: { esimId: doc.esimId }, }); }; }, []); - if (_isEmpty(activeESIMs)) { + // Show the same empty-state card while loading and when no active eSIMs exist. + // Silent on error — the user can check the Orders tab for the authoritative list. + if (isLoading || _isEmpty(activeEsims)) { return ( eSIMs 📶 - No active eSIMs + + No active eSIMs + Your purchased eSIMs will appear here @@ -109,21 +141,17 @@ const ActiveESIMsScroll = ({ eSIMs ( )} - keyExtractor={(item, index) => - item?.transactionData?.orderId || - item?.transactionData?.correlationId || - String(index) - } + keyExtractor={(item) => item.esimId} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.listContainer} diff --git a/components/home/active.tsx b/components/home/active.tsx deleted file mode 100644 index 9b9634f..0000000 --- a/components/home/active.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; -import { ThemedView } from "../ThemedView"; -import { ThemedText } from "../ThemedText"; - -const Active = () => { - return ( - - Active eSIMs - {/* */} - - ); -}; - -export { Active }; diff --git a/components/home/hero.tsx b/components/home/hero.tsx index 9f2ae78..f2ef8f6 100644 --- a/components/home/hero.tsx +++ b/components/home/hero.tsx @@ -97,6 +97,8 @@ const Hero = () => { backgroundColor: isDark ? Theme.colors.text : Theme.colors.shopCta, }]} onPress={handleShopCTAClick} + accessibilityRole="button" + accessibilityLabel="Shop for eSIM plans" > - // ) : ( - // <> - // - // Proceed to shop and continue. - // - // - // )} ) : ( - + Tap to create your device wallet diff --git a/components/tabBar/TabBar.tsx b/components/tabBar/TabBar.tsx index becc4f3..08ecc83 100644 --- a/components/tabBar/TabBar.tsx +++ b/components/tabBar/TabBar.tsx @@ -68,7 +68,8 @@ const styles = StyleSheet.create({ tabStyle: { flex: 1, paddingVertical: 6, - minHeight: 30, + minHeight: 44, + justifyContent: "center", }, tabBarText: { textAlign: "center", diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx deleted file mode 100644 index 830dc1b..0000000 --- a/components/ui/Button.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Theme } from "@/constants/Colors"; -import React, { useMemo } from "react"; -import { View, Text, Pressable, StyleSheet } from "react-native"; -import { useTheme } from "@/contexts/ThemeContext"; - -type PrimaryButtonProps = { - children: React.ReactNode; -}; - -const createStyles = () => StyleSheet.create({ - buttonOuterContainer: { - borderRadius: 40, - margin: 4, - overflow: "hidden", - }, - buttonInnerContainer: { - backgroundColor: Theme.colors.primary, - paddingVertical: 11, - paddingHorizontal: 40, - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - gap: 4, - }, - buttonText: { - color: Theme.colors.cardForeground, - fontSize: 16, - fontWeight: 300, - textAlign: "center", - }, -}); - -function Button({ children }: PrimaryButtonProps) { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); - function pressHandler() { - console.log("Button pressed"); - } - return ( - - - {children} - - - ); -} - - -export default Button; diff --git a/components/ui/Buttons/BackButton.tsx b/components/ui/Buttons/BackButton.tsx deleted file mode 100644 index fcc341f..0000000 --- a/components/ui/Buttons/BackButton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { Pressable } from 'react-native'; -import Ionicons from '@expo/vector-icons/Ionicons'; -import { useRouter } from 'expo-router'; - -const BackButton = () => { - const router = useRouter(); - - return ( - router.back()}> - - - ); -}; - - - - -export default BackButton; diff --git a/components/ui/Checkbox.tsx b/components/ui/Checkbox.tsx index 8e85938..31d4e94 100644 --- a/components/ui/Checkbox.tsx +++ b/components/ui/Checkbox.tsx @@ -7,22 +7,33 @@ import { useThemeColor } from "@/hooks/useThemeColor"; interface CheckboxProps { checked: boolean; onChange: (newValue: boolean) => void; + disabled?: boolean; } -const Checkbox = ({ checked, onChange }: CheckboxProps) => { - const bg = useThemeColor({}, "card"); +// Always white in both themes by design — unlike most surfaces, this isn't +// meant to follow the "card" token, which is intentionally yellow in dark mode. +const CHECKBOX_BACKGROUND = "#FFFFFF"; + +const Checkbox = ({ checked, onChange, disabled = false }: CheckboxProps) => { const border = useThemeColor({}, "mutedForeground"); const handleCheckboxChange = useCallback(() => { - console.log(onChange, !checked); + if (disabled) return; onChange(!checked); - }, [onChange, checked]); + }, [onChange, checked, disabled]); return ( {checked && ( @@ -41,8 +52,8 @@ const styles = StyleSheet.create({ borderRadius: 4, borderWidth: 2, }, - checkboxPressed: { - opacity: 0.8, // Adds a feedback effect on press + checkboxDisabled: { + opacity: 0.4, }, }); diff --git a/components/ui/CheckoutSuccessModal.tsx b/components/ui/CheckoutSuccessModal.tsx index 0fceb7c..7229c6b 100644 --- a/components/ui/CheckoutSuccessModal.tsx +++ b/components/ui/CheckoutSuccessModal.tsx @@ -24,7 +24,13 @@ interface CheckoutSuccessModalProps { visible: boolean; loading?: boolean; onClose?: () => void; - onInstallESIM: () => void; + variant?: "install" | "topup"; + onInstallESIM?: () => void; + onDone?: () => void; + /** Label of the plan just purchased, e.g. "United Arab Emirates · 7 Days · 1GB" — topup variant only. */ + topupFromLabel?: string; + /** Label of the existing eSIM the top-up was applied to — topup variant only. */ + topupToLabel?: string; } const createStyles = () => StyleSheet.create({ @@ -106,7 +112,11 @@ const CheckoutSuccessModal: React.FC = ({ visible, loading = false, onClose = () => {}, + variant = "install", onInstallESIM, + onDone, + topupFromLabel, + topupToLabel, }) => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); @@ -147,7 +157,7 @@ const CheckoutSuccessModal: React.FC = ({ ), - // styles have their own memo watching for changes based on theme + // All missing dependencies are of style attributes which are in their on useMemo() call // eslint-disable-next-line react-hooks/exhaustive-deps [] ); @@ -164,33 +174,46 @@ const CheckoutSuccessModal: React.FC = ({ - Transaction Successful + {variant === "topup" ? "Top-up Successful" : "Transaction Successful"} - - It's now time to install your newly purchased eSIM. - - - - If you are not abroad yet, no worries, the eSIM will only activate - once connected to your destination network. - + {variant === "topup" ? ( + + {`Top-up of ${topupFromLabel ?? "your new plan"} is applied to ${topupToLabel ?? "your eSIM"}.`} + + ) : ( + <> + + It's now time to install your newly purchased eSIM. + + + + If you are not abroad yet, no worries, the eSIM will only activate + once connected to your destination network. + + + )} ), - // styles have their own memo watching for changes based on theme + // All missing dependencies are of style attributes which are in their on useMemo() call // eslint-disable-next-line react-hooks/exhaustive-deps - [animatedIconStyle, textColor] + [animatedIconStyle, textColor, variant, topupFromLabel, topupToLabel] ); - const installButton = useMemo( + const actionButton = useMemo( () => ( - - Install eSIM + + + {variant === "topup" ? "Done" : "Install eSIM"} + ), - // styles have their own memo watching for changes based on theme + // All missing dependencies are of style attributes which are in their on useMemo() call // eslint-disable-next-line react-hooks/exhaustive-deps - [onInstallESIM] + [onInstallESIM, onDone, variant] ); return ( @@ -210,7 +233,7 @@ const CheckoutSuccessModal: React.FC = ({ - {!loading && installButton} + {!loading && actionButton} diff --git a/components/ui/FullScreenLoader.tsx b/components/ui/FullScreenLoader.tsx index a9d1deb..1900bf7 100644 --- a/components/ui/FullScreenLoader.tsx +++ b/components/ui/FullScreenLoader.tsx @@ -1,15 +1,23 @@ import React from "react"; -import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { ActivityIndicator, StyleSheet, View, TouchableOpacity } from "react-native"; import { Theme } from "@/constants/Colors"; +import { ThemedText } from "@/components/ThemedText"; interface FullScreenLoaderProps { color?: string; containerStyle?: object; + /** When set, renders an error state (with Retry/Continue actions) instead of the spinner. */ + error?: unknown; + onRetry?: () => void; + onContinue?: () => void; } const FullScreenLoader: React.FC = ({ color, containerStyle, + error, + onRetry, + onContinue, }) => { return ( = ({ containerStyle, ]} > - + {error ? ( + + + Couldn't start the app + + + Check your connection and try again. + + {onRetry && ( + + + Retry + + + )} + {onContinue && ( + + + Continue Anyway + + + )} + + ) : ( + + )} ); }; @@ -30,6 +66,40 @@ const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", }, + errorContainer: { + alignItems: "center", + paddingHorizontal: 32, + }, + errorTitle: { + fontSize: 20, + textAlign: "center", + marginBottom: 8, + }, + errorDescription: { + fontSize: 14, + textAlign: "center", + lineHeight: 20, + marginBottom: 24, + }, + button: { + borderRadius: 30, + paddingVertical: 12, + paddingHorizontal: 40, + alignItems: "center", + marginBottom: 12, + }, + buttonText: { + fontSize: 16, + fontWeight: "600", + }, + continueButton: { + paddingVertical: 8, + alignItems: "center", + }, + continueButtonText: { + fontSize: 14, + fontWeight: "500", + }, }); export default FullScreenLoader; diff --git a/components/ui/OrderFailureModal.tsx b/components/ui/OrderFailureModal.tsx index 7ec4a68..ff99c85 100644 --- a/components/ui/OrderFailureModal.tsx +++ b/components/ui/OrderFailureModal.tsx @@ -131,7 +131,7 @@ const OrderFailureModal: React.FC = ({ const handleGoToOrders = () => { onDismiss(); - router.navigate('/(tabs)/settings'); + router.navigate('/(tabs)/orders'); }; const reasonText = manualReviewReason ? (REASON_LABELS[manualReviewReason] ?? manualReviewReason) : null; diff --git a/components/ui/WalletSetupModal.tsx b/components/ui/WalletSetupModal.tsx index 983aceb..88b76b1 100644 --- a/components/ui/WalletSetupModal.tsx +++ b/components/ui/WalletSetupModal.tsx @@ -21,6 +21,7 @@ import { BASE_SEPOLIA_TESTNET } from "@/constants/general.constants"; import { useKokio } from "@/hooks/useKokio"; import { useToast } from "@/contexts/ToastContext"; import { AuthError } from "@/utils/auth/errors"; +import { logger } from '@/utils/logger'; interface WalletSetupModalProps { visible: boolean; @@ -236,7 +237,7 @@ const WalletSetupModal: React.FC = ({ try { await openBrowserAsync(url); } catch (error) { - console.error("Error opening browser:", error); + logger.error('BROWSER_OPEN_FAILED', { error }); } } }, [walletAddress]); @@ -247,7 +248,7 @@ const WalletSetupModal: React.FC = ({ const { deviceWalletAddress, deviceUID, userPasskey, rawSalt, sdk } = kokio; - console.log('[wallet] handleContinue state:', { + logger.debug('WALLET_CONTINUE_STATE', { deviceWalletAddress: !!deviceWalletAddress, deviceUID: !!deviceUID, hasX: !!userPasskey?.x, @@ -257,7 +258,7 @@ const WalletSetupModal: React.FC = ({ }); if (!deviceWalletAddress || !userPasskey?.x || !userPasskey?.y || !rawSalt || !sdk) { - console.warn('[wallet] guard failed — missing:', { + logger.warn('WALLET_SETUP_GUARD_FAILED — Missing', { deviceWalletAddress, x: userPasskey?.x, y: userPasskey?.y, @@ -277,7 +278,7 @@ const WalletSetupModal: React.FC = ({ const ownerKey: [Hex, Hex] = [userPasskey.x, userPasskey.y]; const salt = BigInt(rawSalt); - console.log('[wallet] getSmartWallet inputs:', { + logger.debug('GET_SMART_WALLET_INPUTS', { deviceUID, ownerKeyX: userPasskey.x, ownerKeyY: userPasskey.y, @@ -296,7 +297,7 @@ const WalletSetupModal: React.FC = ({ const deviceWalletClient = await sdk.smartAccount.getSmartWalletClient(deviceWallet); const sdkAddress = deviceWalletClient.account?.address; - console.log('[wallet] getSmartWallet result:', { + logger.debug('GET_SMART_WALLET_RESULT', { sdkAddress, serverAddress: deviceWalletAddress, match: sdkAddress?.toLowerCase() === deviceWalletAddress.toLowerCase(), @@ -339,7 +340,7 @@ const WalletSetupModal: React.FC = ({ await setupKokioUserWallet(deviceUID, deviceWallet); setShowRecovery(true); } catch (err: unknown) { - console.error('[wallet] deployment error:', err); + logger.error('WALLET_DEPLOYMENT_FAILED', { err }); const message = err instanceof AuthError ? err.userMessage : err instanceof Error @@ -450,9 +451,12 @@ const WalletSetupModal: React.FC = ({ }, [onClose]); const onSaveEOA = useCallback(async () => { - // TODO: save recovery EOA via Kokio API once endpoint is available + // No BFF endpoint exists yet to persist a recovery EOA (see docs/tasks.md + // for the backend ask) — don't let the user believe it was saved when it + // wasn't; tell them plainly instead of silently closing as if it succeeded. if (!eoaAddress) return; - }, [eoaAddress]); + showMessage("Recovery address saving isn't available yet — it wasn't saved. This will be added in a future update.", 'info'); + }, [eoaAddress, showMessage]); const handleDone = useCallback(() => { onSaveEOA(); diff --git a/constants/checkout.constants.ts b/constants/checkout.constants.ts index 953530b..72fbc36 100644 --- a/constants/checkout.constants.ts +++ b/constants/checkout.constants.ts @@ -32,6 +32,7 @@ type EsimExtraDetail = { formatter: (value: any) => string; isFlexColumn?: boolean; dataContainerStyles?: object; + hideWhenNullish?: boolean; }; export const ESIM_EXTRA_DETAILS: EsimExtraDetail[] = [ @@ -93,10 +94,11 @@ export const ESIM_EXTRA_DETAILS: EsimExtraDetail[] = [ // NOTE: Not currently consumed { iconName: "information-outline", - key: "ADDITIONAL_INFORMATION", + key: "info", label: "Additional Information", - formatter: () => "N/A", + formatter: (value: string) => value, isFlexColumn: true, dataContainerStyles: { marginLeft: 22 }, + hideWhenNullish: true, }, ]; diff --git a/constants/general.constants.ts b/constants/general.constants.ts index 2311ee6..7a9097c 100644 --- a/constants/general.constants.ts +++ b/constants/general.constants.ts @@ -72,13 +72,13 @@ export const REGION_CONFIG: Record = { imagePath: require("@/assets/images/europe.png"), }, [REGION.OCEANIA]: { - imagePath: require("@/assets/images/australia.png"), + imagePath: require("@/assets/images/oceania.png"), }, [REGION.MIDDLE_EAST]: { - imagePath: require("@/assets/images/australia.png"), + imagePath: require("@/assets/images/middle-east.png"), }, [REGION.CARIBBEAN_ISLANDS]: { - imagePath: require("@/assets/images/australia.png"), + imagePath: require("@/assets/images/caribbean.png"), }, }; diff --git a/constants/route.constants.ts b/constants/route.constants.ts index 2af8af4..7331599 100644 --- a/constants/route.constants.ts +++ b/constants/route.constants.ts @@ -1,27 +1,29 @@ export const ROUTE_NAMES = { - HOME: "index", - SHOP: "(shop)", - CHECKOUT: "checkout/[id]", - COVERAGE: "coverage", - WALLET: "(wallet)", - PHONE: "phone", - ORDERS: "orders", - SETTINGS: "settings", - BY_COUNTRY: "country/[id]", - BY_REGION: "region/[id]", - TOKENS: "tokens", - TRANSACTIONS: "transactions", - TRANSACTIONDETAILS: "transactionDetails", - INSTALLATION: "installation", - OFFLINE: "Offline", - CONTACTS: "(contacts)", - CONTACTS_SCREEN: "contactsScreen", - ADD_CONTACTS_SCREEN: "addContactScreen", - QR_CODE_SCREEN: "qrCodeScreen", - CONTACT_DETAILS: "contactDetails", - EDIT_CONTACT: "editContact", - SEND_TO_CONTACT: "sendToContact", - CONTACT_TRANSACTIONS: "contactTransactions", + HOME: "index", + SHOP: "(shop)", + CHECKOUT: "checkout/[id]", + COVERAGE: "coverage", + COVERAGE_MODAL: "coverage-modal", + ESIM_DETAIL: "esim-detail", + WALLET: "(wallet)", + PHONE: "phone", + ORDERS: "orders", + SETTINGS: "settings", + BY_COUNTRY: "country/[id]", + BY_REGION: "region/[id]", + TOKENS: "tokens", + TRANSACTIONS: "transactions", + TRANSACTIONDETAILS: "transactionDetails", + INSTALLATION: "installation", + OFFLINE: "Offline", + CONTACTS: "(contacts)", + CONTACTS_SCREEN: "contactsScreen", + ADD_CONTACTS_SCREEN: "addContactScreen", + QR_CODE_SCREEN: "qrCodeScreen", + CONTACT_DETAILS: "contactDetails", + EDIT_CONTACT: "editContact", + SEND_TO_CONTACT: "sendToContact", + CONTACT_TRANSACTIONS: "contactTransactions", } as const; export type RouteName = (typeof ROUTE_NAMES)[keyof typeof ROUTE_NAMES]; diff --git a/contexts/ToastContext.tsx b/contexts/ToastContext.tsx index 3e0508c..b305380 100644 --- a/contexts/ToastContext.tsx +++ b/contexts/ToastContext.tsx @@ -25,16 +25,22 @@ const ToastContext = createContext({ showMessage: () => {}, }); +// Errors need more time to read than a routine info confirmation. +const MESSAGE_DISPLAY_MS: Record = { + error: 5000, + info: 3000, +}; + function MessageToast({ message, variant, onHide }: { message: string; variant: MessageVariant; onHide: () => void }) { const opacity = React.useRef(new Animated.Value(0)).current; useEffect(() => { Animated.sequence([ Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }), - Animated.delay(3000), + Animated.delay(MESSAGE_DISPLAY_MS[variant]), Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true }), ]).start(onHide); - }, [onHide, opacity]); + }, [onHide, opacity, variant]); const bg = variant === 'error' ? Theme.colors.destructive : Theme.colors.muted; @@ -61,10 +67,13 @@ const styles = StyleSheet.create({ }, }); +type QueuedMessage = { message: string; variant: MessageVariant; key: number }; + export function ToastProvider({ children }: { children: React.ReactNode }) { const [visible, setVisible] = useState(false); const [toastData, setToastData] = useState({ amount: '', ethAmount: '', type: '' }); - const [msgToast, setMsgToast] = useState<{ message: string; variant: MessageVariant; key: number } | null>(null); + const [msgQueue, setMsgQueue] = useState([]); + const msgToast = msgQueue[0] ?? null; const showToast = (amount: string, ethAmount: string, type: string) => { setToastData({ amount, ethAmount, type }); @@ -74,9 +83,11 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { const hideToast = () => setVisible(false); const showMessage = (message: string, variant: MessageVariant = 'error') => { - setMsgToast({ message, variant, key: Date.now() }); + setMsgQueue((queue) => [...queue, { message, variant, key: Date.now() }]); }; + const dequeueMessage = () => setMsgQueue((queue) => queue.slice(1)); + return ( {children} @@ -98,7 +109,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { key={msgToast.key} message={msgToast.message} variant={msgToast.variant} - onHide={() => setMsgToast(null)} + onHide={dequeueMessage} /> )} diff --git a/helpers/esimOrder.ts b/helpers/esimOrder.ts index 5ad23a9..28bb5a3 100644 --- a/helpers/esimOrder.ts +++ b/helpers/esimOrder.ts @@ -3,7 +3,6 @@ import type { CreateOrderRequest } from "@/utils/bff/order"; type EsimOrderPayloadParams = { eSimItem: Esim; - deviceWalletId: string | undefined; discountCode: string; applyAsTopup: boolean; compatibleTopUpEsimId: string | undefined; diff --git a/hooks/__tests__/useEsimCompatibility.test.ts b/hooks/__tests__/useEsimCompatibility.test.ts new file mode 100644 index 0000000..ca59d85 --- /dev/null +++ b/hooks/__tests__/useEsimCompatibility.test.ts @@ -0,0 +1,93 @@ +/** + * useEsimCompatibility tests + * + * The hook itself is a thin react-query wrapper with no test-hook harness + * (e.g. @testing-library/react-hooks) set up in this repo, so coverage here + * targets partitionCompatibilityResults — the exported pure function that + * does the actual compatibleEsims/vendorMismatches/checkErrors filtering the + * hook returns. Per the CompatibilityResult spec, compatible/vendorMismatch/ + * checkError are mutually exclusive per-result flags. + */ + +import { partitionCompatibilityResults } from '../useEsimCompatibility'; +import type { CompatibilityResult } from '@/utils/bff/esim'; + +function result(overrides: Partial = {}): CompatibilityResult { + return { + esimId: '0xdef456abc123def456abc123def456abc123def4', + iccid: '8944110068000000001', + vendor: 'VENDOR1', + compatible: false, + vendorMismatch: false, + checkError: false, + ...overrides, + }; +} + +describe('partitionCompatibilityResults', () => { + it('returns empty slices when called with no results', () => { + expect(partitionCompatibilityResults()).toEqual({ + compatibleEsims: [], + vendorMismatches: [], + checkErrors: [], + }); + }); + + it('returns empty slices for an empty results array', () => { + expect(partitionCompatibilityResults([])).toEqual({ + compatibleEsims: [], + vendorMismatches: [], + checkErrors: [], + }); + }); + + it('puts a compatible result into compatibleEsims only', () => { + const compatible = result({ esimId: 'esim-1', compatible: true }); + const { compatibleEsims, vendorMismatches, checkErrors } = partitionCompatibilityResults([compatible]); + expect(compatibleEsims).toEqual([compatible]); + expect(vendorMismatches).toEqual([]); + expect(checkErrors).toEqual([]); + }); + + it('puts a vendor-mismatched result into vendorMismatches only', () => { + const mismatched = result({ esimId: 'esim-2', vendorMismatch: true }); + const { compatibleEsims, vendorMismatches, checkErrors } = partitionCompatibilityResults([mismatched]); + expect(compatibleEsims).toEqual([]); + expect(vendorMismatches).toEqual([mismatched]); + expect(checkErrors).toEqual([]); + }); + + it('puts a failed-check result into checkErrors only', () => { + const errored = result({ esimId: 'esim-3', checkError: true }); + const { compatibleEsims, vendorMismatches, checkErrors } = partitionCompatibilityResults([errored]); + expect(compatibleEsims).toEqual([]); + expect(vendorMismatches).toEqual([]); + expect(checkErrors).toEqual([errored]); + }); + + it('correctly partitions a mixed batch of results', () => { + const compatible = result({ esimId: 'esim-1', compatible: true }); + const mismatched = result({ esimId: 'esim-2', vendorMismatch: true }); + const errored = result({ esimId: 'esim-3', checkError: true }); + const incompatible = result({ esimId: 'esim-4' }); + + const { compatibleEsims, vendorMismatches, checkErrors } = partitionCompatibilityResults([ + compatible, + mismatched, + errored, + incompatible, + ]); + + expect(compatibleEsims).toEqual([compatible]); + expect(vendorMismatches).toEqual([mismatched]); + expect(checkErrors).toEqual([errored]); + }); + + it('a plain incompatible result (all flags false) lands in none of the slices', () => { + const incompatible = result({ esimId: 'esim-4' }); + const { compatibleEsims, vendorMismatches, checkErrors } = partitionCompatibilityResults([incompatible]); + expect(compatibleEsims).toEqual([]); + expect(vendorMismatches).toEqual([]); + expect(checkErrors).toEqual([]); + }); +}); diff --git a/hooks/useAppState.ts b/hooks/useAppState.ts index b581438..60c7495 100644 --- a/hooks/useAppState.ts +++ b/hooks/useAppState.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useRef } from "react"; import { AppState } from "react-native"; +import { logger } from '@/utils/logger'; export function useAppState(reauth?: boolean) { const appState = useRef(AppState.currentState); @@ -11,17 +12,17 @@ export function useAppState(reauth?: boolean) { appState.current.match(/inactive|background/) && nextAppState === "active" ) { - console.log("Kokio App has come to the foreground!"); + logger.debug('APPSTATE_FOREGROUND'); } else { if (reauth) { // reauthenticate(); - console.log("App has gone to the background!"); + logger.debug('APPSTATE_BACKGROUND'); } } appState.current = nextAppState; setAppStateVisible(appState.current); - console.log("AppState", appState.current); + logger.debug('APPSTATE_CHANGE', { state: appState.current }); }); return () => { diff --git a/hooks/useBffHealth.ts b/hooks/useBffHealth.ts index 73e45bc..123431e 100644 --- a/hooks/useBffHealth.ts +++ b/hooks/useBffHealth.ts @@ -1,18 +1,6 @@ -import { useState, useEffect } from 'react'; -import { AppState, type AppStateStatus } from 'react-native'; import { useQuery } from '@tanstack/react-query'; import { checkBffHealth } from '@/utils/bff/health'; - -function useIsAppActive(): boolean { - const [isActive, setIsActive] = useState(AppState.currentState === 'active'); - useEffect(() => { - const sub = AppState.addEventListener('change', (state: AppStateStatus) => { - setIsActive(state === 'active'); - }); - return () => sub.remove(); - }, []); - return isActive; -} +import { useIsAppActive } from '@/hooks/useIsAppActive'; export function useBffHealth() { const isActive = useIsAppActive(); diff --git a/hooks/useBootstrap.ts b/hooks/useBootstrap.ts index 6924842..caf6059 100644 --- a/hooks/useBootstrap.ts +++ b/hooks/useBootstrap.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { getServiceRegions } from "@/utils/bff/catalogue"; import { checkBffHealth } from "@/utils/bff/health"; import AppBootstrap from "@/utils/appBootstrap"; +import { logger } from '@/utils/logger'; export default function useBootstrap() { const [isLoading, setIsLoading] = useState(false); @@ -12,12 +13,12 @@ export default function useBootstrap() { setError(null); try { const healthy = await checkBffHealth(); - console.log("Health status", healthy); + logger.debug('BFF_HEALTH_STATUS', healthy); if (!healthy) { - console.error("Non 200 status"); + logger.warn('BFF_HEALTH_NON_200'); } } catch (err) { - console.error("Failed to query BFF", err); + logger.error('BFF_HEALTH_QUERY_FAILED', { err }); setError(err); } finally { setIsLoading(false); @@ -32,7 +33,7 @@ export default function useBootstrap() { const { countries, regions } = await getServiceRegions(); new AppBootstrap({ countries, regions }); } catch (err) { - console.error("Failed to fetch bootstrap data:", err); + logger.error('BOOTSTRAP_FETCH_FAILED', { err }); setError(err); } finally { setIsLoading(false); @@ -41,7 +42,7 @@ export default function useBootstrap() { // Fetch bootstrap data on mount useEffect(() => { - fetchHealthData(); // TODO : add UI component to display errors to user + fetchHealthData(); fetchBootstrapData(); }, []); diff --git a/hooks/useColorScheme.web.ts b/hooks/useColorScheme.web.ts deleted file mode 100644 index 4a20a3c..0000000 --- a/hooks/useColorScheme.web.ts +++ /dev/null @@ -1,8 +0,0 @@ -// NOTE: The default React Native styling doesn't support server rendering. -// Server rendered styles should not change between the first render of the HTML -// and the first render on the client. Typically, web developers will use CSS media queries -// to render different styles on the client and server, these aren't directly supported in React Native -// but can be achieved using a styling library like Nativewind. -export function useColorScheme() { - return "dark"; -} diff --git a/hooks/useCreateOrder.ts b/hooks/useCreateOrder.ts index 82304b0..04c86e2 100644 --- a/hooks/useCreateOrder.ts +++ b/hooks/useCreateOrder.ts @@ -1,65 +1,152 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as SecureStore from 'expo-secure-store'; -import { createCryptoOrder, pollOrderStatus } from '@/utils/bff/order'; -import type { CreateOrderRequest, OrderStatusResponse } from '@/utils/bff/order'; -import { useKokio } from '@/hooks/useKokio'; +import { submitOrder, pollOrderStatus, OrderNotFoundError } from '@/utils/bff/order'; +import type { CreateOrderRequest, OrderStatusResponse, PollUpdate } from '@/utils/bff/order'; +import { useStripePaymentSheet } from '@/hooks/useStripePaymentSheet'; import type { Esim } from '@/components/ESIMItem'; export type CreateOrderVariables = { request: CreateOrderRequest; eSimItem: Esim; + idempotencyKey?: string; }; -export type CreateTopupOrderVariables = { - request: Omit & { isNewESim: false; esimId: string }; - eSimItem: Esim; +// Fired once order creation succeeds, before any payment step. +export type CreateOrderOptions = { + onOrderCreated?: (correlationId: string) => void | Promise; + onPollUpdate?: (update: PollUpdate) => void; }; +export type CreateOrderResult = + | { kind: 'terminal'; order: OrderStatusResponse; correlationId: string } + | { + kind: 'awaiting_crypto_payment'; + correlationId: string; + orderId: string; + moonpayChargeId: string; + moonpayPaymentPageUrl: string; + }; + // SecureStore key for the most recently purchased eSIM wallet address. // Read by topup flows to pre-populate the eSimId for compatibility checks. export const ESIM_ID_KEY = 'esimId'; -async function createAndPoll(request: CreateOrderRequest): Promise<{ order: OrderStatusResponse; correlationId: string | null }> { - const { data: orderInit, correlationId } = await createCryptoOrder(request); - const order = correlationId - ? await pollOrderStatus(correlationId, 15, 2000) - : await pollOrderStatus(orderInit.orderId, 15, 2000); - return { order, correlationId }; +/** + * Thrown when order creation itself fails, or when polling after payment times out. + * Carries whatever correlationId is known (the client-generated idempotency key, + * else the BFF envelope's correlationId) so the caller can record the order as FAILED. + */ +export class OrderCreationError extends Error { + correlationId: string | null; + constructor(message: string, correlationId: string | null) { + super(message); + this.name = 'OrderCreationError'; + this.correlationId = correlationId; + } } -export function useCreateOrder() { - const queryClient = useQueryClient(); - const { kokio, savePurchasedESIM } = useKokio(); +// The user backed out of the Stripe sheet without submitting. +export class StripeCancelledError extends Error { + constructor() { + super('Payment cancelled'); + this.name = 'StripeCancelledError'; + } +} - return useMutation({ - mutationFn: ({ request }) => createAndPoll(request).then(r => r.order), +// initPaymentSheet/confirmPaymentSheetPayment returned an error. Shown to the user, +// but does not record the order as FAILED, the order may still resolve via webhook/poll. +export class StripeSheetError extends Error { + constructor(message: string) { + super(message); + this.name = 'StripeSheetError'; + } +} - onSuccess: async (data, { eSimItem }) => { - if (data.esimId) { - await SecureStore.setItemAsync(ESIM_ID_KEY, data.esimId); - } - if (kokio.deviceUID) { - await savePurchasedESIM(kokio.deviceUID, eSimItem, data); - } - await queryClient.invalidateQueries({ queryKey: ['orders'] }); - }, +/** + * Poll wrapper shared by both the FIAT and COUPON paths below. + * Normalizes whatever pollOrderStatus throws (OrderNotFoundError, the generic timeout, + * or a passthrough error) into an OrderCreationError carrying correlationId, + * so the caller's FAILED-recording logic doesn't need a special case per error type. + */ +async function pollToTerminal( + correlationId: string, + onUpdate?: (update: PollUpdate) => void, +): Promise { + return pollOrderStatus(correlationId, { onUpdate }).catch((err) => { + const message = err instanceof OrderNotFoundError ? 'Order not found' : 'Order confirmation timed out'; + throw new OrderCreationError(message, correlationId); }); } -export function useCreateTopupOrder() { +export function useCreateOrder(options: CreateOrderOptions = {}) { const queryClient = useQueryClient(); - const { kokio, savePurchasedESIM } = useKokio(); + const { initPaymentSheet, presentPaymentSheet, confirmPaymentSheetPayment } = + useStripePaymentSheet(); + + return useMutation({ + mutationFn: async ({ request }) => { + const { data, correlationId } = await submitOrder(request).catch((err) => { + const bffErr = err as { correlationId?: string | null }; + throw new OrderCreationError( + (err as Error)?.message ?? 'Order creation failed', + bffErr?.correlationId ?? null, + ); + }); - return useMutation({ - mutationFn: ({ request }) => createAndPoll(request as CreateOrderRequest).then(r => r.order), + await options.onOrderCreated?.(correlationId); - onSuccess: async (data, { eSimItem }) => { - if (data.esimId) { - await SecureStore.setItemAsync(ESIM_ID_KEY, data.esimId); + // FIAT — clientSecret present. + if (data.clientSecret) { + const { error: initError } = await initPaymentSheet({ + merchantDisplayName: 'Kokio', + paymentIntentClientSecret: data.clientSecret, + customFlow: true, + applePay: { merchantCountryCode: 'US' }, + googlePay: { merchantCountryCode: 'US', testEnv: __DEV__ }, + style: 'alwaysDark', + }); + if (initError) throw new StripeSheetError(initError.message); + + const { error: presentError } = await presentPaymentSheet(); + if (presentError) throw new StripeCancelledError(); + + const { error: confirmError } = await confirmPaymentSheetPayment(); + if (confirmError) throw new StripeSheetError(confirmError.message); + + const order = await pollToTerminal(correlationId, options.onPollUpdate); + + return { kind: 'terminal', order, correlationId }; } - if (kokio.deviceUID) { - await savePurchasedESIM(kokio.deviceUID, eSimItem, data); + + /** + * CRYPTO — moonpayPaymentPageUrl present. + * Which presentation mechanism to use (SDK drawer vs. hosted-page browser) + * is a caller-side UI decision. Return the session, when caller finishes, then poll. + */ + if (data.moonpayChargeId && data.moonpayPaymentPageUrl) { + return { + kind: 'awaiting_crypto_payment', + correlationId, + orderId: data.orderId, + moonpayChargeId: data.moonpayChargeId, + moonpayPaymentPageUrl: data.moonpayPaymentPageUrl, + }; + } + + /** + * COUPON (full coverage) — neither field present, + * $0 invoice already auto-paid server-side. Poll immediately. + * TODO: Partial coupon flow to be extended from here. + */ + const order = await pollToTerminal(correlationId, options.onPollUpdate); + return { kind: 'terminal', order, correlationId }; + }, + + onSuccess: async (result) => { + if (result.kind !== 'terminal') return; + if (result.order.esimId) { + await SecureStore.setItemAsync(ESIM_ID_KEY, result.order.esimId); } await queryClient.invalidateQueries({ queryKey: ['orders'] }); }, diff --git a/hooks/useDeviceEsims.ts b/hooks/useDeviceEsims.ts new file mode 100644 index 0000000..bee25d9 --- /dev/null +++ b/hooks/useDeviceEsims.ts @@ -0,0 +1,112 @@ +/** + * Server-truth eSIM and order state. + * Authoritative React Query hooks for device eSIM documents and terminal order history. + * + * Cache policy: + * - staleTime 60 s — short enough to surface fresh activation-status on tab + * focus without hammering the BFF on every render. + * - gcTime 5 min — long enough to survive tab switches while keeping memory bounded. + * AsyncStorage persister (wired in providers/index.tsx) handles cold-boot restore. + * - enabled - gated on isActive AND isAuthenticated. + * - refetchOnMount true — always revalidate when the hook mounts. + * - refetchInterval false — App-return triggers the isActive gate flip which React Query's + * enabled logic translates into a refetch. + * + * Persistence: Both query keys are in the PERSISTED_KEYS allowlist in providers/index.tsx. + * All other query keys (catalogue, health, coupon, compatibility) remain in-memory only. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getAllEsims, type ESimDocument } from '@/utils/bff/esim'; +import { getOrderList, type OrderListItem } from '@/utils/bff/order'; +import { useIsAppActive } from '@/hooks/useIsAppActive'; +import { useAuthRelay } from '@/hooks/useAuthRelayer'; + +// ─── Query key constants ─────────────────────────────────────────────────────── + +export const DEVICE_ESIMS_KEY = 'device-esims' as const; +export const DEVICE_ORDERS_KEY = 'device-orders' as const; + +// ─── Shared cache settings ──────────────────────────────────────────────────── + +const STALE_TIME = 60_000; // 1 minute +const GC_TIME = 5 * 60_000; // 5 minutes + +// ─── useEsims ───────────────────────────────────────────────────────────────── + +export interface UseEsimsResult { + esims: ESimDocument[]; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} + +/** + * Returns all eSIM documents associated with the authenticated device. + * Source: GET /esim (no params — server returns all activation statuses). + * Persisted to AsyncStorage via the PersistQueryClientProvider in providers/index.tsx. + * The last-known list is available immediately on cold boot. + */ +export function useEsims(): UseEsimsResult { + const isActive = useIsAppActive(); + const { state: authState } = useAuthRelay(); + + const query = useQuery({ + queryKey: [DEVICE_ESIMS_KEY], + queryFn: getAllEsims, + staleTime: STALE_TIME, + gcTime: GC_TIME, + enabled: isActive && authState.authenticated, + refetchOnMount: true, + refetchInterval: false, + // On a network failure serve whatever is in cache. + retry: 1, + }); + + return { + esims: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + }; +} + +// ─── useOrders ──────────────────────────────────────────────────────────────── + +export interface UseOrdersResult { + orders: OrderListItem[]; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} + +/** + * Returns terminal orders for the authenticated device, most-recent-first. + * Source: GET /order/list (page 1, pageSize 25). + * Persisted to AsyncStorage via the PersistQueryClientProvider in providers/index.tsx. + */ +export function useOrders(): UseOrdersResult { + const isActive = useIsAppActive(); + const { state: authState } = useAuthRelay(); + + const query = useQuery({ + queryKey: [DEVICE_ORDERS_KEY], + queryFn: async () => { + const response = await getOrderList(1, 25); + return response.orders; + }, + staleTime: STALE_TIME, + gcTime: GC_TIME, + enabled: isActive && authState.authenticated, + refetchOnMount: true, + refetchInterval: false, + retry: 1, + }); + + return { + orders: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + }; +} diff --git a/hooks/useEsimCompatibility.ts b/hooks/useEsimCompatibility.ts index c24ece2..6de791d 100644 --- a/hooks/useEsimCompatibility.ts +++ b/hooks/useEsimCompatibility.ts @@ -1,6 +1,6 @@ import { useQuery, type UseQueryOptions } from '@tanstack/react-query'; import { checkEsimCompatibility } from '@/utils/bff/esim'; -import type { CompatibilityResponse, CheckCompatibilityParams } from '@/utils/bff/esim'; +import type { CompatibilityResponse, CompatibilityResult, CheckCompatibilityParams } from '@/utils/bff/esim'; export type UseEsimCompatibilityParams = { planId?: string; @@ -12,6 +12,16 @@ type ExtraOptions = Omit< 'queryKey' | 'queryFn' >; +// Pulled out as a pure function so the compatible/vendorMismatch/checkError +// partitioning can be unit-tested without standing up react-query. +export function partitionCompatibilityResults(results: CompatibilityResult[] = []) { + return { + compatibleEsims: results.filter(r => r.compatible), + vendorMismatches: results.filter(r => r.vendorMismatch), + checkErrors: results.filter(r => r.checkError), + }; +} + export function useEsimCompatibility( params: UseEsimCompatibilityParams, options?: ExtraOptions, @@ -19,14 +29,11 @@ export function useEsimCompatibility( const query = useQuery({ queryKey: ['esim-compatibility', params.planId, params.esimId], queryFn: () => checkEsimCompatibility({ planId: params.planId } as CheckCompatibilityParams, params.esimId), - enabled: !!params.planId, + enabled: (options?.enabled ?? true) && !!params.planId, ...options, }); - // Pre-filtered slices callers most commonly need - const compatibleEsims = query.data?.results.filter(r => r.compatible) ?? []; - const vendorMismatches = query.data?.results.filter(r => r.vendorMismatch) ?? []; - const checkErrors = query.data?.results.filter(r => r.checkError) ?? []; + const { compatibleEsims, vendorMismatches, checkErrors } = partitionCompatibilityResults(query.data?.results); return { ...query, compatibleEsims, vendorMismatches, checkErrors }; } diff --git a/hooks/useEsimUsage.ts b/hooks/useEsimUsage.ts new file mode 100644 index 0000000..eca1ebc --- /dev/null +++ b/hooks/useEsimUsage.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { getEsimUsage, type ESimUsage } from '@/utils/bff/esim'; +import { useIsAppActive } from '@/hooks/useIsAppActive'; +import { useAuthRelay } from '@/hooks/useAuthRelayer'; + +// staleTime matches the server-side vendor cache duration (15 min per the spec). +const STALE_TIME = 15 * 60_000; + +export interface UseEsimUsageResult { + usage: ESimUsage | undefined; + isLoading: boolean; + isError: boolean; + usageUnavailable: boolean; + refetch: () => void; +} + +/** + * Returns live remaining usage for a single eSIM. + * + * `usageUnavailable` is set when the response arrived but `usageError` is non-null. + * This is distinct from a network/auth error (`isError`), which indicates the BFF call itself failed. + * The hook is disabled until `esimId` is provided, the app is foregrounded, + * and the user has an authenticated session. + */ +export function useEsimUsage(esimId: string | undefined): UseEsimUsageResult { + const isActive = useIsAppActive(); + const { state: authState } = useAuthRelay(); + + const query = useQuery({ + queryKey: ['esim-usage', esimId], + queryFn: () => getEsimUsage(esimId!), + staleTime: STALE_TIME, + enabled: !!esimId && isActive && authState.authenticated, + refetchOnMount: true, + refetchInterval: false, + retry: 1, + }); + + return { + usage: query.data, + isLoading: query.isLoading, + isError: query.isError, + usageUnavailable: !!query.data?.usageError, + refetch: query.refetch, + }; +} diff --git a/hooks/useIsAppActive.ts b/hooks/useIsAppActive.ts new file mode 100644 index 0000000..97752e7 --- /dev/null +++ b/hooks/useIsAppActive.ts @@ -0,0 +1,21 @@ +/** + * Shared hook: returns true when the app is in the foreground (AppState === 'active'). + * Extracted from useBffHealth so multiple queries can use the same + * pattern without duplicating the AppState subscription. + */ + +import { useEffect, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +export function useIsAppActive(): boolean { + const [isActive, setIsActive] = useState(AppState.currentState === 'active'); + + useEffect(() => { + const sub = AppState.addEventListener('change', (state: AppStateStatus) => { + setIsActive(state === 'active'); + }); + return () => sub.remove(); + }, []); + + return isActive; +} diff --git a/hooks/useStripePaymentSheet.web.ts b/hooks/useStripePaymentSheet.web.ts deleted file mode 100644 index ff16e1c..0000000 --- a/hooks/useStripePaymentSheet.web.ts +++ /dev/null @@ -1,29 +0,0 @@ -type PaymentSheetResult = { - error?: { - code?: string; - message?: string; - localizedMessage?: string; - }; -}; - -export function useStripePaymentSheet() { - return { - initPaymentSheet: async (): Promise => ({ - error: { - code: "WEB_UNSUPPORTED", - message: "Stripe PaymentSheet is not available on web.", - localizedMessage: "Stripe PaymentSheet is not available on web.", - }, - }), - - presentPaymentSheet: async (): Promise => ({ - error: { - code: "WEB_UNSUPPORTED", - message: "Stripe PaymentSheet is not available on web.", - localizedMessage: "Stripe PaymentSheet is not available on web.", - }, - }), - - loading: false, - }; -} diff --git a/package-lock.json b/package-lock.json index cde9395..37f7e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kokio", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kokio", - "version": "1.2.0", + "version": "1.2.1", "hasInstallScript": true, "dependencies": { "@aa-sdk/core": "4.88.4", @@ -21,7 +21,9 @@ "@react-navigation/native": "7.2.4", "@simplewebauthn/server": "13.3.0", "@stripe/stripe-react-native": "0.63.0", - "@tanstack/react-query": "5.100.11", + "@tanstack/query-async-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", "@walletconnect/react-native-compat": "2.23.9", "@walletconnect/sign-client": "2.23.9", "axios": "1.16.1", @@ -47,14 +49,13 @@ "expo-updates": "55.0.24", "expo-web-browser": "55.0.16", "jose": "6.2.3", - "kokio-sdk": "0.1.3", + "kokio-sdk": "0.1.4", "lodash": "4.18.1", "lucide-react-native": "1.16.0", "nativewind": "4.2.4", "node-libs-react-native": "1.2.1", "qs": "6.15.2", "react": "19.2.0", - "react-dom": "19.2.0", "react-native": "0.83.6", "react-native-country-flag": "2.0.2", "react-native-currency-input": "1.1.1", @@ -74,7 +75,6 @@ "react-native-share": "12.3.1", "react-native-svg": "15.15.3", "react-native-view-shot": "4.0.3", - "react-native-web": "0.21.2", "react-native-webview": "13.16.0", "react-native-worklets": "0.7.4", "stream-browserify": "3.0.0", @@ -126,34 +126,6 @@ "viem": "^2.45.0" } }, - "node_modules/@account-kit/infra": { - "version": "4.88.4", - "resolved": "https://registry.npmjs.org/@account-kit/infra/-/infra-4.88.4.tgz", - "integrity": "sha512-fkkUvjryBdRx3x4qbgzQ3ZUdhxqtKfDlwoPBWG6goWhx0pUzcm0vzl1hbm2MEYO3rNlCOmunXwBo+ym+S4zwnQ==", - "license": "MIT", - "dependencies": { - "@aa-sdk/core": "^4.88.4", - "@account-kit/logging": "^4.88.4", - "eventemitter3": "^5.0.1", - "zod": "^3.22.4" - }, - "optionalDependencies": { - "alchemy-sdk": "^3.0.0" - }, - "peerDependencies": { - "viem": "^2.45.0" - } - }, - "node_modules/@account-kit/logging": { - "version": "4.88.4", - "resolved": "https://registry.npmjs.org/@account-kit/logging/-/logging-4.88.4.tgz", - "integrity": "sha512-uuMkKKZpgQt2qhNHsP/bGUHseYgk2vI+8qiXCnZTkPIIjv4NI+1bjotPYt6Ixd0as2emax9qwlkPwhpWOkwW+g==", - "license": "MIT", - "dependencies": { - "@segment/analytics-next": "1.74.0", - "uuid": "^11.0.2" - } - }, "node_modules/@adraffy/ens-normalize": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", @@ -1744,832 +1716,134 @@ } }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@ethersproject/abi": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", - "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-provider": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", - "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0" - } - }, - "node_modules/@ethersproject/abstract-signer": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", - "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0" - } - }, - "node_modules/@ethersproject/address": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", - "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/rlp": "^5.8.0" - } - }, - "node_modules/@ethersproject/base64": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", - "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0" - } - }, - "node_modules/@ethersproject/basex": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.8.0.tgz", - "integrity": "sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/properties": "^5.8.0" - } - }, - "node_modules/@ethersproject/bignumber": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", - "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "bn.js": "^5.2.1" - } - }, - "node_modules/@ethersproject/bytes": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", - "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/constants": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", - "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0" - } - }, - "node_modules/@ethersproject/contracts": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/contracts/-/contracts-5.8.0.tgz", - "integrity": "sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abi": "^5.8.0", - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/transactions": "^5.8.0" - } - }, - "node_modules/@ethersproject/hash": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", - "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } - }, - "node_modules/@ethersproject/hdnode": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.8.0.tgz", - "integrity": "sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/pbkdf2": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/wordlists": "^5.8.0" - } - }, - "node_modules/@ethersproject/json-wallets": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.8.0.tgz", - "integrity": "sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/hdnode": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/pbkdf2": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "aes-js": "3.0.0", - "scrypt-js": "3.0.1" - } - }, - "node_modules/@ethersproject/keccak256": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", - "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "js-sha3": "0.8.0" - } - }, - "node_modules/@ethersproject/logger": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", - "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/@ethersproject/networks": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", - "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/pbkdf2": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.8.0.tgz", - "integrity": "sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/sha2": "^5.8.0" - } - }, - "node_modules/@ethersproject/properties": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", - "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/providers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", - "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/base64": "^5.8.0", - "@ethersproject/basex": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/networks": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/sha2": "^5.8.0", - "@ethersproject/strings": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/web": "^5.8.0", - "bech32": "1.1.4", - "ws": "8.18.0" - } - }, - "node_modules/@ethersproject/random": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz", - "integrity": "sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/rlp": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", - "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0" - } - }, - "node_modules/@ethersproject/sha2": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.8.0.tgz", - "integrity": "sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "hash.js": "1.1.7" - } - }, - "node_modules/@ethersproject/shims": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/shims/-/shims-5.8.0.tgz", - "integrity": "sha512-6yHbI6DHVaP1KjU0n9gKm8zrzU11EXwNG3VLetyIDBr2YRGpa0nrjk1QnZtLGiOqc+6iSiIoAzfAHf6iPdUunw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT" - }, - "node_modules/@ethersproject/signing-key": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", - "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "bn.js": "^5.2.1", - "elliptic": "6.6.1", - "hash.js": "1.1.7" + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@ethersproject/strings": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", - "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@ethersproject/transactions": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", - "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/rlp": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0" + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@ethersproject/units": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/units/-/units-5.8.0.tgz", - "integrity": "sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "optional": true, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@ethersproject/wallet": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.8.0.tgz", - "integrity": "sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@ethersproject/abstract-provider": "^5.8.0", - "@ethersproject/abstract-signer": "^5.8.0", - "@ethersproject/address": "^5.8.0", - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/hdnode": "^5.8.0", - "@ethersproject/json-wallets": "^5.8.0", - "@ethersproject/keccak256": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/random": "^5.8.0", - "@ethersproject/signing-key": "^5.8.0", - "@ethersproject/transactions": "^5.8.0", - "@ethersproject/wordlists": "^5.8.0" - } - }, - "node_modules/@ethersproject/web": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", - "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", - "funding": [ - { - "type": "individual", - "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, "license": "MIT", - "optional": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@ethersproject/base64": "^5.8.0", - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@ethersproject/wordlists": { + "node_modules/@ethersproject/shims": { "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.8.0.tgz", - "integrity": "sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==", + "resolved": "https://registry.npmjs.org/@ethersproject/shims/-/shims-5.8.0.tgz", + "integrity": "sha512-6yHbI6DHVaP1KjU0n9gKm8zrzU11EXwNG3VLetyIDBr2YRGpa0nrjk1QnZtLGiOqc+6iSiIoAzfAHf6iPdUunw==", "funding": [ { "type": "individual", @@ -2580,15 +1854,7 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/hash": "^5.8.0", - "@ethersproject/logger": "^5.8.0", - "@ethersproject/properties": "^5.8.0", - "@ethersproject/strings": "^5.8.0" - } + "license": "MIT" }, "node_modules/@expo-google-fonts/material-symbols": { "version": "0.4.38", @@ -2716,9 +1982,9 @@ } }, "node_modules/@expo/cli/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2882,9 +2148,9 @@ } }, "node_modules/@expo/config-plugins/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2987,9 +2253,9 @@ } }, "node_modules/@expo/config/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -3173,9 +2439,9 @@ } }, "node_modules/@expo/fingerprint/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -3412,9 +2678,9 @@ } }, "node_modules/@expo/metro-config/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4294,27 +3560,6 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, - "node_modules/@lukeed/csprng": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", - "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@lukeed/uuid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", - "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", - "license": "MIT", - "dependencies": { - "@lukeed/csprng": "^1.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@msgpack/msgpack": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", @@ -4611,19 +3856,19 @@ } }, "node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", "license": "MIT" }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz", - "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", + "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, @@ -4658,9 +3903,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -4673,20 +3918,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz", - "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", + "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", + "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", @@ -4724,12 +3969,12 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz", - "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", + "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", + "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", @@ -4766,9 +4011,9 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz", - "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", + "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", @@ -4833,9 +4078,9 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", + "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" @@ -4879,20 +4124,22 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz", - "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz", + "integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4928,18 +4175,18 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz", - "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.17.tgz", + "integrity": "sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-context": "1.1.4", + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -5009,6 +4256,21 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", @@ -5343,17 +4605,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.18.4", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.4.tgz", - "integrity": "sha512-L7D1tlPnIsXlp45iZCpAIRRKlZSuqM9WyUNFbooRTlcaaLDSlOr0X6cKbGmJmCvSOoqT6bAsQjCKfb5WRqhtQA==", + "version": "7.18.8", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.8.tgz", + "integrity": "sha512-7KCsBtwCRwQQSTEw9SLglZCEkxWc+EqgdRKyUOgII+6+xEnemOyg8aDlnLIM2yk9ip6uE+Oxvm0jdpQU2vsu2w==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.27", + "@react-navigation/elements": "^2.9.30", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.3.5", + "@react-navigation/native": "^7.3.8", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -5361,9 +4623,9 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.3.tgz", - "integrity": "sha512-HUoGmT9IdpKpbAl9AZJmXFbid+jQWQWjzww9vV9uBSd+8fa1hTwM3UBsZBHrpMru0s1ikwknoAIwV827fqGlhA==", + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.5.tgz", + "integrity": "sha512-3hpV7uR41LBW+GHDoLhztZCb/i5ySRJISZ/rez4d7DCHSZo6ej4gNxYclaS6LRguoLiKG7SOCNa6O390AQklZQ==", "license": "MIT", "dependencies": { "@react-navigation/routers": "^7.6.0", @@ -5380,9 +4642,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.9.27", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.27.tgz", - "integrity": "sha512-6bka/gWvP3+5Dw9Gf2ac83y/F0XEl2ktezc6Yp3gJBs22Rm9dluGKphe1B2Hj33XoCMRofxL8w3z3kmtlJiC0Q==", + "version": "2.9.30", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.30.tgz", + "integrity": "sha512-2isleieiRMmP4WNMV2Q1u3qP1M47ZqsJ2hJ/Og11FeKXK8YmUTHya7PW7ecsgAh2CXKTxAcbfJDFTSvq2D++Tw==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -5391,7 +4653,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.3.5", + "@react-navigation/native": "^7.3.8", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -5438,18 +4700,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.17.7.tgz", - "integrity": "sha512-kNM39MJu8qRiaSJdszJqQfvcBhbGHi4dXTvKiEmZe97EDUcDb3SyajG6T+08IZ6l5CFdlmCcM03VY3u/nCvWcw==", + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.17.10.tgz", + "integrity": "sha512-m1BWVEaOPX9k30DbmhsD7IlrUdl4J7Ogmo71rcNlbZQPMNB126av/nfwqB+qN7DIsiwTAnORnT+SwzD56qqD0Q==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.27", + "@react-navigation/elements": "^2.9.30", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.3.5", + "@react-navigation/native": "^7.3.8", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -5529,9 +4791,9 @@ } }, "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -5636,95 +4898,14 @@ "node_modules/@scure/bip39": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@segment/analytics-core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.8.0.tgz", - "integrity": "sha512-6CrccsYRY33I3mONN2ZW8SdBpbLtu1Ict3xR+n0FemYF5RB/jG7pW6jOvDXULR8kuYMzMmGOP4HvlyUmf3qLpg==", - "license": "MIT", - "dependencies": { - "@lukeed/uuid": "^2.0.0", - "@segment/analytics-generic-utils": "1.2.0", - "dset": "^3.1.4", - "tslib": "^2.4.1" - } - }, - "node_modules/@segment/analytics-generic-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@segment/analytics-generic-utils/-/analytics-generic-utils-1.2.0.tgz", - "integrity": "sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.1" - } - }, - "node_modules/@segment/analytics-next": { - "version": "1.74.0", - "resolved": "https://registry.npmjs.org/@segment/analytics-next/-/analytics-next-1.74.0.tgz", - "integrity": "sha512-dhSwm+kahwnsHZmhcInu6wTJZFCLtG1VDCw0uiQRuKL5SDRRNEMORvKErV6bycXHWLelaYQVIMRcHH2Y9lk48A==", - "license": "MIT", - "dependencies": { - "@lukeed/uuid": "^2.0.0", - "@segment/analytics-core": "1.8.0", - "@segment/analytics-generic-utils": "1.2.0", - "@segment/analytics.js-video-plugins": "^0.2.1", - "@segment/facade": "^3.4.9", - "dset": "^3.1.4", - "js-cookie": "3.0.1", - "node-fetch": "^2.6.7", - "tslib": "^2.4.1", - "unfetch": "^4.1.0" - } - }, - "node_modules/@segment/analytics.js-video-plugins": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@segment/analytics.js-video-plugins/-/analytics.js-video-plugins-0.2.1.tgz", - "integrity": "sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==", - "license": "ISC", - "dependencies": { - "unfetch": "^3.1.1" - } - }, - "node_modules/@segment/analytics.js-video-plugins/node_modules/unfetch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-3.1.2.tgz", - "integrity": "sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==", - "license": "MIT" - }, - "node_modules/@segment/facade": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/@segment/facade/-/facade-3.4.10.tgz", - "integrity": "sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@segment/isodate-traverse": "^1.1.1", - "inherits": "^2.0.4", - "new-date": "^1.0.3", - "obj-case": "0.2.1" - } - }, - "node_modules/@segment/isodate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@segment/isodate/-/isodate-1.0.3.tgz", - "integrity": "sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==", - "license": "SEE LICENSE IN LICENSE" - }, - "node_modules/@segment/isodate-traverse": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@segment/isodate-traverse/-/isodate-traverse-1.1.1.tgz", - "integrity": "sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==", - "license": "SEE LICENSE IN LICENSE", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", "dependencies": { - "@segment/isodate": "^1.0.3" + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@simplewebauthn/server": { @@ -5770,119 +4951,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@solana/buffer-layout": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", - "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "~6.0.3" - }, - "engines": { - "node": ">=5.10" - } - }, - "node_modules/@solana/codecs-core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", - "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/codecs-numbers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", - "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@solana/codecs-core": "2.3.0", - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/errors": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", - "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "chalk": "^5.4.1", - "commander": "^14.0.0" - }, - "bin": { - "errors": "bin/cli.mjs" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/errors/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@solana/errors/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/@solana/web3.js": { - "version": "1.98.4", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", - "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.25.0", - "@noble/curves": "^1.4.2", - "@noble/hashes": "^1.4.0", - "@solana/buffer-layout": "^4.0.1", - "@solana/codecs-numbers": "^2.1.0", - "agentkeepalive": "^4.5.0", - "bn.js": "^5.2.1", - "borsh": "^0.7.0", - "bs58": "^4.0.1", - "buffer": "6.0.3", - "fast-stable-stringify": "^1.0.0", - "jayson": "^4.1.1", - "node-fetch": "^2.7.0", - "rpc-websockets": "^9.0.2", - "superstruct": "^2.0.2" - } - }, "node_modules/@stripe/stripe-react-native": { "version": "0.63.0", "resolved": "https://registry.npmjs.org/@stripe/stripe-react-native/-/stripe-react-native-0.63.0.tgz", @@ -5903,39 +4971,73 @@ } } }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "optional": true, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.101.2.tgz", + "integrity": "sha512-jf7zvVKZ5q2j/xuhbTIBUpw5xt6cw/ioOFQquxK7fRY0AmMQP8A0JVMOgYoeQLA4PKdeT7RxsuLAF0Lft9oJ8Q==", + "license": "MIT", "dependencies": { - "tslib": "^2.8.0" + "@tanstack/query-core": "5.101.2", + "@tanstack/query-persist-client-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/query-core": { - "version": "5.100.11", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz", - "integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.101.2.tgz", + "integrity": "sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==", "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.100.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz", - "integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.101.2.tgz", + "integrity": "sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.11" + "@tanstack/query-persist-client-core": "5.101.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { + "@tanstack/react-query": "^5.101.2", "react": "^18 || ^19" } }, @@ -6001,16 +5103,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -6108,9 +5200,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -6174,23 +5266,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -6207,17 +5282,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6230,15 +5305,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -6246,16 +5321,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -6271,14 +5346,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -6293,14 +5368,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6311,9 +5386,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -6328,15 +5403,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6353,9 +5428,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -6367,16 +5442,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6447,16 +5522,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6471,13 +5546,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6502,9 +5577,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { @@ -7300,13 +6375,6 @@ "node": ">=0.4.0" } }, - "node_modules/aes-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", - "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT", - "optional": true - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -7319,19 +6387,6 @@ "node": ">= 6.0.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -7349,30 +6404,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/alchemy-sdk": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/alchemy-sdk/-/alchemy-sdk-3.6.5.tgz", - "integrity": "sha512-vikvJvExqPoifnOtnIPoANwS2C46Nv44XsEWJz8kd5hrnZrS320GmhKWGyKSgupd8cvudAWv1+76iSr0pjy8DA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/abi": "^5.7.0", - "@ethersproject/abstract-provider": "^5.7.0", - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/bytes": "^5.7.0", - "@ethersproject/contracts": "^5.7.0", - "@ethersproject/hash": "^5.7.0", - "@ethersproject/networks": "^5.7.0", - "@ethersproject/providers": "^5.7.0", - "@ethersproject/units": "^5.7.0", - "@ethersproject/wallet": "^5.7.0", - "@ethersproject/web": "^5.7.0", - "@solana/web3.js": "^1.87.6", - "axios": "^1.12.0", - "sturdy-websocket": "^0.2.1", - "websocket": "^1.0.34" - } - }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -7668,9 +6699,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/asn1js": { @@ -8013,16 +7044,6 @@ "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" }, - "node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -8053,9 +7074,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -8064,13 +7085,6 @@ "node": ">=6.0.0" } }, - "node_modules/bech32": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", - "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", - "license": "MIT", - "optional": true - }, "node_modules/better-opn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", @@ -8129,9 +7143,9 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz", - "integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", "license": "MIT" }, "node_modules/boolbase": { @@ -8140,18 +7154,6 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/borsh": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", - "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bn.js": "^5.2.0", - "bs58": "^4.0.0", - "text-encoding-utf-8": "^1.0.2" - } - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -8174,9 +7176,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8282,9 +7284,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "funding": [ { "type": "opencollective", @@ -8301,10 +7303,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -8314,16 +7316,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "optional": true, - "dependencies": { - "base-x": "^3.0.2" - } - }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -8369,20 +7361,6 @@ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", "license": "MIT" }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -8475,9 +7453,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "funding": [ { "type": "opencollective", @@ -8938,9 +7916,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/create-hash": { @@ -8992,15 +7970,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9024,15 +7993,6 @@ "uncrypto": "^0.1.3" } }, - "node_modules/css-in-js-utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", - "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", - "license": "MIT", - "dependencies": { - "hyphenate-style-name": "^1.0.3" - } - }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -9130,20 +8090,6 @@ "dev": true, "license": "MIT" }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "license": "ISC", - "optional": true, - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -9347,19 +8293,6 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, - "node_modules/delay": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", - "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9464,9 +8397,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/dijkstrajs": { @@ -9592,15 +8525,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dset": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", - "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9622,9 +8546,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.383", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz", - "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==", + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", "license": "ISC" }, "node_modules/elliptic": { @@ -9643,9 +8567,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/emittery": { @@ -9815,9 +8739,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", - "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -9913,66 +8837,6 @@ "benchmarks" ] }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "license": "ISC", - "optional": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "license": "MIT", - "optional": true, - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "license": "MIT", - "optional": true - }, - "node_modules/es6-promisify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", - "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "es6-promise": "^4.0.3" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "license": "ISC", - "optional": true, - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10197,9 +9061,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", - "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -10386,22 +9250,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "license": "ISC", - "optional": true, - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -10488,17 +9336,6 @@ "node": ">= 0.6" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "license": "MIT", - "optional": true, - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -11211,9 +10048,9 @@ } }, "node_modules/expo-updates/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11272,27 +10109,8 @@ "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0" - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "license": "ISC", - "optional": true, - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "optional": true, - "engines": { - "node": "> 0.1.90" - } + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" }, "node_modules/fast-base64-decode": { "version": "1.0.0", @@ -11349,13 +10167,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-stable-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", - "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", - "license": "MIT", - "optional": true - }, "node_modules/fast-text-encoding": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", @@ -11393,36 +10204,6 @@ "bser": "2.1.1" } }, - "node_modules/fbjs": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", - "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.5", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^1.0.35" - } - }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", - "license": "MIT" - }, - "node_modules/fbjs/node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "license": "MIT", - "dependencies": { - "asap": "~2.0.3" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -11891,9 +10672,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -12266,22 +11047,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/hyphenate-style-name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", - "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", - "license": "BSD-3-Clause" - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -12296,9 +11061,9 @@ } }, "node_modules/idb-keyval": { - "version": "6.2.6", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz", - "integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", "license": "Apache-2.0" }, "node_modules/ieee754": { @@ -12431,15 +11196,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/inline-style-prefixer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", - "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", - "license": "MIT", - "dependencies": { - "css-in-js-utils": "^3.1.0" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -12951,13 +11707,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT", - "optional": true - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -13028,16 +11777,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "license": "MIT", - "optional": true, - "peerDependencies": { - "ws": "*" - } - }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -13154,47 +11893,6 @@ "node": ">= 0.4" } }, - "node_modules/jayson": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", - "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/connect": "^3.4.33", - "@types/node": "^12.12.54", - "@types/ws": "^7.4.4", - "commander": "^2.20.3", - "delay": "^5.0.0", - "es6-promisify": "^5.0.0", - "eyes": "^0.1.8", - "isomorphic-ws": "^4.0.1", - "json-stringify-safe": "^5.0.1", - "stream-json": "^1.9.1", - "uuid": "^8.3.2", - "ws": "^7.5.10" - }, - "bin": { - "jayson": "bin/jayson.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jayson/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "license": "MIT", - "optional": true - }, - "node_modules/jayson/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "optional": true - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -14080,12 +12778,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", - "license": "MIT" - }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -14096,13 +12788,6 @@ "node": ">=0.10.0" } }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "license": "MIT", - "optional": true - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14243,13 +12928,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC", - "optional": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -14337,13 +13015,12 @@ } }, "node_modules/kokio-sdk": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/kokio-sdk/-/kokio-sdk-0.1.3.tgz", - "integrity": "sha512-k+JhOrVm8CCSEItux0SqG0yb2wmxkvLFAHzSU4cOjvAAgwe2zpx9Bv2pPRPHqhNrgOL0/hyABaCy37lVMOgS0w==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/kokio-sdk/-/kokio-sdk-0.1.4.tgz", + "integrity": "sha512-75ISYMIhoLR4j2cFNPT4+FRG5aTKZPczP/pf+lfqvfkinhkB19kc2il+nmXqKskM60ayo6QhN08A/IpIQmOvdQ==", "license": "MIT", "dependencies": { "@aa-sdk/core": "4.88.4", - "@account-kit/infra": "4.88.4", "@noble/curves": "2.2.0", "@peculiar/asn1-ecc": "2.8.0", "@peculiar/asn1-schema": "2.8.0", @@ -15426,9 +14103,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/mime": { @@ -15570,9 +14247,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -15636,22 +14313,6 @@ "node": ">= 0.6" } }, - "node_modules/new-date": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/new-date/-/new-date-1.0.3.tgz", - "integrity": "sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@segment/isodate": "1.0.3" - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "license": "ISC", - "optional": true - }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -15671,54 +14332,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -15728,18 +14347,6 @@ "node": ">= 6.13.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -15793,9 +14400,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "license": "MIT", "engines": { "node": ">=18" @@ -15887,12 +14494,6 @@ "node": ">=20.19.4" } }, - "node_modules/obj-case": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/obj-case/-/obj-case-0.2.1.tgz", - "integrity": "sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==", - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -16671,9 +15272,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -16938,9 +15539,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", "funding": [ { "type": "opencollective", @@ -17096,6 +15697,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -17265,9 +15867,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/punycode": { @@ -17595,18 +16197,6 @@ "ws": "^7" } }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -18359,38 +16949,6 @@ "react-native": "*" } }, - "node_modules/react-native-web": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", - "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.6", - "@react-native/normalize-colors": "^0.74.1", - "fbjs": "^3.0.4", - "inline-style-prefixer": "^7.0.1", - "memoize-one": "^6.0.0", - "nullthrows": "^1.1.1", - "postcss-value-parser": "^4.2.0", - "styleq": "^0.1.3" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { - "version": "0.74.89", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", - "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", - "license": "MIT" - }, - "node_modules/react-native-web/node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, "node_modules/react-native-webview": { "version": "13.16.0", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz", @@ -19131,40 +17689,6 @@ "node": ">= 0.8" } }, - "node_modules/rpc-websockets": { - "version": "9.3.9", - "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz", - "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==", - "license": "LGPL-3.0-only", - "optional": true, - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.2.2", - "buffer": "^6.0.3", - "eventemitter3": "^5.0.1", - "uuid": "^14.0.0", - "ws": "^8.5.0" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/kozjak" - }, - "optionalDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^6.0.0" - } - }, - "node_modules/rpc-websockets/node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -19307,13 +17831,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/scrypt-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", - "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", - "license": "MIT", - "optional": true - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -19554,9 +18071,9 @@ } }, "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -19935,13 +18452,6 @@ "node": ">= 0.10.0" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/stream-http": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", @@ -19955,16 +18465,6 @@ "xtend": "^4.0.0" } }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -20161,19 +18661,6 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, - "node_modules/sturdy-websocket": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/sturdy-websocket/-/sturdy-websocket-0.2.1.tgz", - "integrity": "sha512-NnzSOEKyv4I83qbuKw9ROtJrrT6Z/Xt7I0HiP/e6H6GnpeTDvzwGIGeJ8slai+VwODSHQDooW2CAilJwT9SpRg==", - "license": "MIT", - "optional": true - }, - "node_modules/styleq": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", - "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", - "license": "MIT" - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -20207,16 +18694,6 @@ "node": ">= 6" } }, - "node_modules/superstruct": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", - "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -20338,9 +18815,9 @@ } }, "node_modules/terser": { - "version": "5.48.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", - "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -20403,12 +18880,6 @@ "deprecated": "no longer maintained", "license": "(Unlicense OR Apache-2.0)" }, - "node_modules/text-encoding-utf-8": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", - "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==", - "optional": true - }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -20685,13 +19156,6 @@ "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==", "license": "MIT" }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "license": "ISC", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -20803,16 +19267,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -20827,32 +19281,6 @@ "node": ">=14.17" } }, - "node_modules/ua-parser-js": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", - "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -20899,12 +19327,6 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, - "node_modules/unfetch": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", - "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", - "license": "MIT" - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -21114,9 +19536,9 @@ } }, "node_modules/unstorage/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -21273,20 +19695,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", @@ -21540,55 +19948,6 @@ "node": ">=12" } }, - "node_modules/websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "optional": true - }, - "node_modules/websocket/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -21941,17 +20300,6 @@ "node": ">=10" } }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.32" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -22072,9 +20420,9 @@ } }, "node_modules/zxing-wasm/node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" diff --git a/package.json b/package.json index 4898888..a888472 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kokio", "main": "expo-router/entry", - "version": "1.2.1", + "version": "1.3.0", "engines": { "node": "24.x", "npm": ">=11.16.0" @@ -11,9 +11,9 @@ "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web", "test": "jest --watchAll", "lint": "npx eslint . --ext .ts,.tsx", + "lint:console": "node scripts/scan-console.mjs", "audit": "npx audit-ci --config audit-ci.jsonc", "gen:auth-types": "bash scripts/gen-auth-types.sh", "gen:bff-types": "bash scripts/gen-bff-types.sh", @@ -38,7 +38,9 @@ "@react-navigation/native": "7.2.4", "@simplewebauthn/server": "13.3.0", "@stripe/stripe-react-native": "0.63.0", - "@tanstack/react-query": "5.100.11", + "@tanstack/query-async-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", "@walletconnect/react-native-compat": "2.23.9", "@walletconnect/sign-client": "2.23.9", "axios": "1.16.1", @@ -64,14 +66,13 @@ "expo-updates": "55.0.24", "expo-web-browser": "55.0.16", "jose": "6.2.3", - "kokio-sdk": "0.1.3", + "kokio-sdk": "0.1.4", "lodash": "4.18.1", "lucide-react-native": "1.16.0", "nativewind": "4.2.4", "node-libs-react-native": "1.2.1", "qs": "6.15.2", "react": "19.2.0", - "react-dom": "19.2.0", "react-native": "0.83.6", "react-native-country-flag": "2.0.2", "react-native-currency-input": "1.1.1", @@ -91,7 +92,6 @@ "react-native-share": "12.3.1", "react-native-svg": "15.15.3", "react-native-view-shot": "4.0.3", - "react-native-web": "0.21.2", "react-native-webview": "13.16.0", "react-native-worklets": "0.7.4", "stream-browserify": "3.0.0", diff --git a/providers/StripeProvider.web.tsx b/providers/StripeProvider.web.tsx deleted file mode 100644 index 565c5c4..0000000 --- a/providers/StripeProvider.web.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { ReactNode, ReactElement } from "react"; - -type Props = { - children: ReactNode | ReactElement[]; -}; - -export function KokioStripeProvider({ children }: Props) { - return <>{children}; -} diff --git a/providers/authProvider.tsx b/providers/authProvider.tsx index 0f6a30f..de75e65 100644 --- a/providers/authProvider.tsx +++ b/providers/authProvider.tsx @@ -9,15 +9,25 @@ import type { RegisterResult } from "@/utils/auth/passkeyRegister"; import { loginWithKokioPasskey, discoverAndLoginWithPasskey, type DiscoverLoginResult } from "@/utils/auth/passkeyLogin"; import { performStepUp } from "@/utils/auth/stepUp"; import { AuthError, StepUpCancelledError } from "@/utils/auth/errors"; +import { deleteAccount as bffDeleteAccount } from '@/utils/bff/account'; +import { + markAccountDeleted, + clearAccountDeleted, + hydrateAccountDeleted, +} from '@/utils/auth/accountDeleted'; import { setStepUpHandler, resolveStepUp, rejectStepUp, clearBffNonceCache, + setAccountDeletedHandler, type StepUpHint, } from "@/services/httpService"; import { useAuthStore } from "@/stores/authStore"; import { clearUsedHashes } from "@/utils/orderTracking"; +import { purgeAccountLocalState } from '@/utils/auth/purgeAccountLocalState'; +import { PasskeyRemovalModal } from '@/components/PasskeyRemovalModal'; +import { logger } from '@/utils/logger'; // ─── Error formatting ───────────────────────────────────────────────────────── @@ -100,6 +110,7 @@ export interface AuthRelayProviderType { stepUpError: string; stepUp: () => Promise; dismissStepUp: () => void; + deleteAccount: () => Promise; } export const AuthRelayContext = createContext({ @@ -115,6 +126,7 @@ export const AuthRelayContext = createContext({ stepUpError: '', stepUp: async () => {}, dismissStepUp: () => {}, + deleteAccount: async () => {}, }); // ─── Provider ───────────────────────────────────────────────────────────────── @@ -130,6 +142,7 @@ export const AuthRelayProvider: React.FC = ({ const [stepUpVisible, setStepUpVisible] = useState(false); const [stepUpHint, setStepUpHint] = useState(null); const [stepUpError, setStepUpError] = useState(''); + const [passkeyRemovalVisible, setPasskeyRemovalVisible] = useState(false); const router = useRouter(); // Wire httpService step-up handler — fires whenever a BFF request returns @@ -154,6 +167,15 @@ export const AuthRelayProvider: React.FC = ({ return unsub; }, []); + // Fired by the httpService interceptor on 404 ACCOUNT_DELETED from ANY authed endpoint. + useEffect(() => { + void hydrateAccountDeleted(); + setAccountDeletedHandler(() => { + dispatch({ type: "REAUTHENTICATE" }); + setPasskeyRemovalVisible(true); + }); + }, []); + const signUpWithPasskey = async (user: { username?: string; email?: string; @@ -188,7 +210,7 @@ export const AuthRelayProvider: React.FC = ({ * The credential won't be locally indexed for ~1s. * Wait 1500ms so the first login attempt succeeds without dialog. */ - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise(resolve => setTimeout(resolve, 500)); /** * Registration creates the credential and derives the wallet, but does not establish a session. @@ -197,6 +219,7 @@ export const AuthRelayProvider: React.FC = ({ */ await loginWithKokioPasskey(registration.credentialId, registration.deviceWalletAddress); dispatch({ type: "PASSKEY" }); + await clearAccountDeleted(); return registration; } catch (err) { dispatch({ type: "ERROR", payload: formatError(err) }); @@ -250,7 +273,7 @@ export const AuthRelayProvider: React.FC = ({ dispatch({ type: "REAUTHENTICATE" }); }; - const logout = async () => { + const logout = useCallback(async () => { // Revoke the refresh token server-side (RFC 7009). // Server always returns 200; clear locally regardless of network errors. const tokens = useAuthStore.getState().tokens; @@ -270,7 +293,7 @@ export const AuthRelayProvider: React.FC = ({ await clearUsedHashes(); dispatch({ type: "REAUTHENTICATE" }); router.replace("/"); - }; + }, [router]); const clearError = () => { dispatch({ type: "CLEAR_ERROR" }); @@ -298,13 +321,24 @@ export const AuthRelayProvider: React.FC = ({ }, []); const dismissStepUp = useCallback(() => { - if (__DEV__) console.log('[stepup] stepup.cancelled'); + logger.debug('STEP_UP_CANCELLED'); rejectStepUp(new StepUpCancelledError()); setStepUpVisible(false); setStepUpHint(null); setStepUpError(''); }, []); + const deleteAccount = useCallback(async () => { + await bffDeleteAccount(); + + // Flag before teardown — logout() navigates. + await markAccountDeleted(); + await purgeAccountLocalState(); + await logout(); + + setPasskeyRemovalVisible(true); + }, [logout]); + return ( = ({ stepUpError, stepUp, dismissStepUp, + deleteAccount, }} > {children} + setPasskeyRemovalVisible(false)} + /> ); }; diff --git a/providers/authProvider.web.tsx b/providers/authProvider.web.tsx deleted file mode 100644 index ab38d64..0000000 --- a/providers/authProvider.web.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { ReactNode, createContext, useMemo, useState } from "react"; - -export interface AuthRelayProviderType { - state: { - authenticated: boolean; - loading: string | null; - error: string; - }; - signUpWithPasskey: (user: { - username?: string; - email?: string; - }) => Promise; - loginWithPasskey: () => Promise; - reauthenticate: () => void; - clearError: () => void; - logout: () => Promise; - stepUpVisible: boolean; - stepUpHint: null; - stepUpError: string; - stepUp: () => Promise; - dismissStepUp: () => void; -} - -const defaultValue: AuthRelayProviderType = { - state: { - authenticated: false, - loading: null, - error: "", - }, - signUpWithPasskey: async () => null, - loginWithPasskey: async () => false, - reauthenticate: () => {}, - clearError: () => {}, - logout: async () => {}, - stepUpVisible: false, - stepUpHint: null, - stepUpError: "", - stepUp: async () => {}, - dismissStepUp: () => {}, -}; - -export const AuthRelayContext = - createContext(defaultValue); - -export function AuthRelayProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState(defaultValue.state); - - const value = useMemo( - () => ({ - ...defaultValue, - state, - clearError: () => setState((prev) => ({ ...prev, error: "" })), - reauthenticate: () => - setState((prev) => ({ ...prev, authenticated: false })), - }), - [state] - ); - - return ( - - {children} - - ); -} diff --git a/providers/index.tsx b/providers/index.tsx index 51611ec..2b55b4a 100644 --- a/providers/index.tsx +++ b/providers/index.tsx @@ -1,21 +1,29 @@ -import React, { ReactNode } from "react"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; +import React, { ReactNode } from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { DarkTheme, DefaultTheme, ThemeProvider, -} from "@react-navigation/native"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +} from '@react-navigation/native'; +import { QueryClient } from '@tanstack/react-query'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; +import AsyncStorage from '@react-native-async-storage/async-storage'; -import { AuthRelayProvider } from "./authProvider"; -import { KokioProvider } from "./kokioProvider"; -import { KokioStripeProvider } from "./StripeProvider"; +import { AuthRelayProvider } from './authProvider'; +import { KokioProvider } from './kokioProvider'; +import { KokioStripeProvider } from './StripeProvider'; +import { ToastProvider } from '@/contexts/ToastContext'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY } from '@/hooks/useDeviceEsims'; -import { ToastProvider } from "@/contexts/ToastContext"; -import { useColorScheme } from "@/hooks/useColorScheme"; +// ─── Persisted query keys ────────────────────────────────────────────────────── +const PERSISTED_KEYS: Set = new Set([DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY]); -const queryClient = new QueryClient({ +// ─── QueryClient ────────────────────────────────────────────────────────────── + +export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, @@ -23,15 +31,32 @@ const queryClient = new QueryClient({ }, }); -// Used ReactNode as ReactElement will make call sites reject mutiple children +// ─── AsyncStorage persister ─────────────────────────────────────────────────── +// Single flat key in AsyncStorage. +// The dehydrateOptions filter below ensures only PERSISTED_KEYS queries are written. + +export const asyncStoragePersister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'kokio.rq.cache', +}); + export const Providers = ({ children }: { children: ReactNode }) => { const colorScheme = useColorScheme(); return ( - + - + + PERSISTED_KEYS.has(query.queryKey[0] as string), + }, + }} + > @@ -39,7 +64,7 @@ export const Providers = ({ children }: { children: ReactNode }) => { - + diff --git a/providers/index.web.tsx b/providers/index.web.tsx deleted file mode 100644 index ad47b86..0000000 --- a/providers/index.web.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { ReactElement } from "react"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import { - DarkTheme, - DefaultTheme, - ThemeProvider, -} from "@react-navigation/native"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { AuthRelayProvider } from "./authProvider"; -import { KokioProvider } from "./kokioProvider"; -import { KokioStripeProvider } from "./StripeProvider"; - -import { ToastProvider } from "@/contexts/ToastContext"; -import { useColorScheme } from "@/hooks/useColorScheme"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, -}); - -export const Providers = ({ children }: { children: ReactElement }) => { - const colorScheme = useColorScheme(); - - return ( - - - - - - - {children} - - - - - - - ); -}; diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index ac8a3c7..864c255 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -1,94 +1,41 @@ -import { ReactNode, createContext, useEffect, useReducer, useRef } from "react"; -import _pick from "lodash/pick"; +import React, { ReactNode, createContext, useEffect, useReducer, useRef } from "react"; import { router } from "expo-router"; import { Kokio } from "kokio-sdk"; import { PASSKEY_CONFIG } from "@/constants/passkey.constants"; -import { createWalletClient, http, type Hex } from "viem"; +import { createWalletClient, http, type Address, type Hex } from "viem"; import { baseSepolia, base } from "viem/chains"; import Constants from "expo-constants"; import { AppExtraConfig, Config } from "@/appKeys"; - import { SmartContractAccount } from "@aa-sdk/core"; - import * as SecureStore from "expo-secure-store"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Esim } from "@/components/ESIMItem"; -import { OrderStatusResponse } from "@/utils/bff/order"; -import { getAllEsims, type ESimDocument } from "@/utils/bff/esim"; -import { getOrderList, type OrderListItem } from "@/utils/bff/order"; +import { getAccount } from "@/utils/bff/account"; import { getWcSignClient, setPendingProposal, } from "@/utils/walletconnect/signClient"; +import { logger } from "@/utils/logger"; +import { checkWalletDeployed } from "@/utils/wallet/checkWalletDeployed"; +import { subscribeAccountDeleted } from '@/utils/auth/accountDeleted'; +import { isAccountDeletedError } from "@/utils/bff/errors"; + const extra = Constants.expoConfig?.extra as AppExtraConfig; -export interface StoredTransactionData { - orderId: string; - correlationId?: string; - iccid?: string; - planId?: string; - orderStatus?: string; - paymentMethod?: string; - vendor?: string; - esimId?: string; - isNewESim?: boolean; - stripeInvoiceUrl?: string | null; - flaggedForManualReview?: boolean; - installationDetails: { - qrcode: string; - appleInstallationUrl: string; - }; -} +// ─── Types ──────────────────────────────────────────────────────────────────── -export interface StoredPurchasedESIM { - eSimItem: Esim; - transactionData: StoredTransactionData; +export interface UserPasskey { + credentialId: string; + x: Hex; + y: Hex; } -const reduceESimDataForStorage = ( - eSimItem: Esim, - transactionData: OrderStatusResponse, - correlationId?: string | null, -): StoredPurchasedESIM => { - const reducedESimItem = _pick(eSimItem, [ - "catalogueId", - "data", - "sms", - "voice", - "validity", - "isUnlimited", - "coverageType", - "serviceRegionCode", - "serviceRegionName", - "serviceRegionFlag", - "planType", - "isTopupAvailable", - "isAutoStart", - "isKycRequired", - "countryWiseNetworkCoverages", - ]) as Esim; - - const reducedTransactionData: StoredTransactionData = { - orderId: transactionData.orderId, - correlationId: correlationId ?? undefined, - iccid: transactionData.iccid, - planId: transactionData.planId, - orderStatus: transactionData.orderStatus, - paymentMethod: transactionData.paymentMethod, - vendor: transactionData.vendor, - esimId: transactionData.esimId, - isNewESim: transactionData.isNewESim, - installationDetails: { - qrcode: transactionData.installationDetails?.qrcode ?? "", - appleInstallationUrl: transactionData.installationDetails?.appleInstallationUrl ?? "", - }, - }; - - return { - eSimItem: reducedESimItem, - transactionData: reducedTransactionData, - }; -}; +interface UserData { + userName: string; + email: string; + organizationId: string; + id: string; + wallets: { address: string }[]; +} type AuthActionType = | { type: "ERROR"; payload: string } @@ -100,23 +47,9 @@ type AuthActionType = | { type: "SET_KOKIO_USER"; payload: UserData } | { type: "SET_KOKIO_PASSKEY"; payload: UserPasskey } | { type: "SET_USER_WALLET"; payload: SmartContractAccount } - | { type: "SET_PURCHASED_ESIMS"; payload: StoredPurchasedESIM[] } | { type: "CLEAR_KOKIO" } | { type: "CLEAR_KOKIO_USER" }; -export interface UserPasskey { - credentialId: string; - x: Hex; - y: Hex; -} - -interface UserData { - userName: string; - email: string; - organizationId: string; - id: string; - wallets: { address: string }[]; -} interface KokioState { error: string; sdk?: Kokio; @@ -126,7 +59,6 @@ interface KokioState { userData?: UserData; userPasskey?: UserPasskey; userWallet?: SmartContractAccount; - purchasedESIMs: StoredPurchasedESIM[]; } const initialState: KokioState = { @@ -138,7 +70,6 @@ const initialState: KokioState = { userData: undefined, userPasskey: undefined, userWallet: undefined, - purchasedESIMs: [], }; function kokioReducer(kokio: KokioState, action: AuthActionType): KokioState { @@ -161,13 +92,8 @@ function kokioReducer(kokio: KokioState, action: AuthActionType): KokioState { return { ...kokio, userPasskey: action.payload }; case "SET_USER_WALLET": return { ...kokio, userWallet: action.payload }; - case "SET_PURCHASED_ESIMS": - return { ...kokio, purchasedESIMs: action.payload }; case "CLEAR_KOKIO": - return { - ...kokio, - sdk: undefined, - }; + return { ...kokio, sdk: undefined }; case "CLEAR_KOKIO_USER": return { ...kokio, @@ -177,35 +103,28 @@ function kokioReducer(kokio: KokioState, action: AuthActionType): KokioState { userPasskey: undefined, userData: undefined, userWallet: undefined, - purchasedESIMs: [], }; default: return kokio; } } +// ─── Context shape ──────────────────────────────────────────────────────────── + export interface KokioProviderType { kokio: KokioState; clearError: () => void; setupKokio: () => void; setupKokioDeviceUID: (deviceUID: string) => Promise; - setupKokioUserWallet: ( - deviceUID: string, - wallet: SmartContractAccount - ) => Promise; - savePurchasedESIM: ( - deviceUID: string, - eSimItem: Esim, - transactionData: OrderStatusResponse, - correlationId?: string | null, - ) => Promise; - upsertOrderRecord: ( - deviceUID: string, - eSimItem: Esim, - correlationId: string, - orderStatus?: string, + setupKokioUserWallet: (deviceUID: string, wallet: SmartContractAccount) => Promise; + setupKokioRegistration: ( + deviceWalletAddress: string, + deviceUniqueIdentifier: string, + credentialId: string, + publicKeyX: Hex, + publicKeyY: Hex, + rawSalt: string, ) => Promise; - setupKokioRegistration: (deviceWalletAddress: string, deviceUniqueIdentifier: string, credentialId: string, publicKeyX: Hex, publicKeyY: Hex, rawSalt: string) => Promise; setupKokioRecovery: (deviceWalletAddress: string, credentialId: string) => Promise; clearKokio: () => void; clearKokioUser: () => Promise; @@ -217,8 +136,6 @@ export const KokioContext = createContext({ setupKokio: async () => Promise.resolve(), setupKokioDeviceUID: async () => Promise.resolve(), setupKokioUserWallet: async () => Promise.resolve(), - savePurchasedESIM: async () => Promise.resolve(), - upsertOrderRecord: async () => Promise.resolve(), setupKokioRegistration: async (_a, _b, _c, _d, _e, _f) => Promise.resolve(), setupKokioRecovery: async (_a, _b) => Promise.resolve(), clearKokio: () => {}, @@ -229,9 +146,13 @@ interface KokioProviderProps { children: ReactNode; } +// ─── Provider ───────────────────────────────────────────────────────────────── + export const KokioProvider: React.FC = ({ children }) => { const [kokio, dispatch] = useReducer(kokioReducer, initialState); + // ── SecureStore helpers ─────────────────────────────────────────────────── + const saveValueForDeviceUID = async (key: string, value: string) => { await SecureStore.setItemAsync(key, JSON.stringify(value)); }; @@ -240,170 +161,30 @@ export const KokioProvider: React.FC = ({ children }) => { await SecureStore.setItemAsync(key, JSON.stringify(value)); }; - const saveValueForUserWallet = async ( - key: string, - value: SmartContractAccount - ) => { + const saveValueForUserWallet = async (key: string, value: SmartContractAccount) => { await SecureStore.setItemAsync(key, JSON.stringify(value)); }; - const getValueForDeviceUID = async (key: string) => { - let result = await SecureStore.getItemAsync(key); - if (result) { - const parsedResult: string = JSON.parse(result); - return parsedResult; - } - }; - - const getValueForUserData = async (key: string) => { - let result = await SecureStore.getItemAsync(key); - if (result) { - const parsedResult: UserData = JSON.parse(result); - return parsedResult; - } + const getValueForDeviceUID = async (key: string): Promise => { + const result = await SecureStore.getItemAsync(key); + if (result) return JSON.parse(result) as string; }; - const getValueForUserWallet = async ( - key: string - ): Promise => { - let result = await SecureStore.getItemAsync(key); - if (result) { - const parsedResult: SmartContractAccount = JSON.parse(result); - return parsedResult; - } + const getValueForUserData = async (key: string): Promise => { + const result = await SecureStore.getItemAsync(key); + if (result) return JSON.parse(result) as UserData; }; - const saveValueForPurchasedESIMs = async ( - key: string, - value: StoredPurchasedESIM[] - ) => { - try { - await AsyncStorage.setItem(key, JSON.stringify(value)); - } catch (error) { - if (__DEV__) console.error("Error saving purchased eSIMs to AsyncStorage:", error); - } + const getValueForUserWallet = async (key: string): Promise => { + const result = await SecureStore.getItemAsync(key); + if (result) return JSON.parse(result) as SmartContractAccount; }; - const getValueForPurchasedESIMs = async ( - key: string - ): Promise => { - try { - const result = await AsyncStorage.getItem(key); - if (result) { - const parsedResult: StoredPurchasedESIM[] = JSON.parse(result); - return parsedResult; - } - } catch (error) { - if (__DEV__) console.error("Error retrieving purchased eSIMs from AsyncStorage:", error); - } - }; - - const deleteValueForPurchasedESIMs = async (key: string): Promise => { - try { - await AsyncStorage.removeItem(key); - } catch (error) { - if (__DEV__) console.error("Error deleting purchased eSIMs from AsyncStorage:", error); - } - }; - - const deleteValueForUser = async (key: string): Promise => { + const deleteValueForUser = async (key: string) => { await SecureStore.deleteItemAsync(key); }; - const syncPurchasedEsimsWithBff = async (deviceUID: string): Promise => { - try { - const [liveEsims, orderListResult] = await Promise.all([ - getAllEsims(), - getOrderList(1, 25).catch(() => null), - ]); - - const esimMap = new Map(liveEsims.map(e => [e.esimId, e])); - const bffOrders: OrderListItem[] = orderListResult?.orders ?? []; - const bffOrderMap = new Map(bffOrders.map(o => [o.idempotencyKey, o])); - - const existingESIMs = (await getValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`)) ?? []; - - // Update locally stored orders with fresh BFF data - const updatedLocal = existingESIMs.map(stored => { - const bff = stored.transactionData.correlationId - ? bffOrderMap.get(stored.transactionData.correlationId) - : undefined; - const live = stored.transactionData.esimId - ? esimMap.get(stored.transactionData.esimId) - : undefined; - return { - ...stored, - transactionData: { - ...stored.transactionData, - iccid: live?.iccid ?? bff?.iccid ?? stored.transactionData.iccid, - orderStatus: live?.activationStatus ?? bff?.orderStatus ?? stored.transactionData.orderStatus, - esimId: bff?.esimId ?? stored.transactionData.esimId, - stripeInvoiceUrl: bff?.stripeInvoiceUrl ?? stored.transactionData.stripeInvoiceUrl, - flaggedForManualReview: bff?.flaggedForManualReview ?? stored.transactionData.flaggedForManualReview, - installationDetails: live?.installationDetails - ? { qrcode: live.installationDetails.qrcode, appleInstallationUrl: live.installationDetails.appleInstallationUrl } - : stored.transactionData.installationDetails, - }, - }; - }); - - // Reinstall recovery: find BFF orders not present locally - const localKeys = new Set(existingESIMs.map(e => e.transactionData.correlationId).filter(Boolean)); - const orphanedOrders = bffOrders.filter( - o => !localKeys.has(o.idempotencyKey) && (o.orderStatus === 'COMPLETED' || o.orderStatus === 'ESIM_PROVISIONED_PENDING_CHAIN'), - ); - - let recovered: StoredPurchasedESIM[] = []; - if (orphanedOrders.length > 0) { - recovered = orphanedOrders.map(o => { - const live = o.esimId ? esimMap.get(o.esimId) : undefined; - // Stub eSimItem — plan display details (data, validity, flag) are not available - // from OrderListItem. These orders will show their status + ICCID correctly; - // plan details will be restored if the user reinstalls from a device that has - // local storage, or when the BFF exposes plan detail in a future endpoint. - //@ts-expect-error actualSellingPrice is missing from the declaration here. TODO: Should be ideally fixed - const eSimItem: Esim = { - catalogueId: o.planId, - data: 0, - sms: null, - voice: null, - validity: 0, - isUnlimited: false, - coverageType: 'LOCAL', - serviceRegionCode: '', - serviceRegionName: o.planId, - serviceRegionFlag: '', - }; - return { - eSimItem, - transactionData: { - orderId: o.orderId, - correlationId: o.idempotencyKey, - iccid: live?.iccid ?? o.iccid ?? undefined, - planId: o.planId, - orderStatus: o.orderStatus, - paymentMethod: o.paymentMethod, - vendor: o.vendor ?? undefined, - esimId: o.esimId ?? undefined, - isNewESim: o.isNewESim ?? undefined, - stripeInvoiceUrl: o.stripeInvoiceUrl, - flaggedForManualReview: o.flaggedForManualReview, - installationDetails: live?.installationDetails - ? { qrcode: live.installationDetails.qrcode, appleInstallationUrl: live.installationDetails.appleInstallationUrl } - : { qrcode: '', appleInstallationUrl: '' }, - }, - }; - }); - } - - const merged = [...updatedLocal, ...recovered]; - await saveValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`, merged); - dispatch({ type: "SET_PURCHASED_ESIMS", payload: merged }); - await AsyncStorage.setItem(`esimLastSync-${deviceUID}`, new Date().toISOString()); - } catch { - // non-critical — sync failure should not surface to the user - } - }; + // ── Boot hydration ──────────────────────────────────────────────────────── useEffect(() => { const fetchUserData = async () => { @@ -414,20 +195,14 @@ export const KokioProvider: React.FC = ({ children }) => { const deviceUID = await getValueForDeviceUID("deviceUID"); - // Guard: deviceUID without deviceWalletAddress means orphaned state - // (e.g. old Turnkey installation, or a crashed registration). Purge it - // so the modal correctly routes to sign-up instead of a broken login attempt. if (deviceUID && !storedWalletAddress) { await SecureStore.deleteItemAsync("deviceUID"); await SecureStore.deleteItemAsync("credentialId"); - if (__DEV__) console.log('[kokio] purged orphaned deviceUID (no deviceWalletAddress)'); + logger.debug('KOKIO_PURGED_ORPHAN_DEVICEUID'); } if (deviceUID && storedWalletAddress) { - dispatch({ - type: "SET_DEVICE_UID", - payload: deviceUID, - }); + dispatch({ type: "SET_DEVICE_UID", payload: deviceUID }); const userData = await getValueForUserData(`userData-${deviceUID}`); if (userData) { @@ -442,59 +217,127 @@ export const KokioProvider: React.FC = ({ children }) => { }, }); } + const credentialId = await SecureStore.getItemAsync('credentialId'); - const publicKeyX = await SecureStore.getItemAsync('publicKeyX'); - const publicKeyY = await SecureStore.getItemAsync('publicKeyY'); - if (__DEV__) console.log('[kokio] SecureStore hydration:', { + const publicKeyX = await SecureStore.getItemAsync('publicKeyX'); + const publicKeyY = await SecureStore.getItemAsync('publicKeyY'); + logger.debug('KOKIO_SECURESTORE_HYDRATION', { hasDeviceWalletAddress: !!storedWalletAddress, - hasCredentialId: !!credentialId, - hasPublicKeyX: !!publicKeyX, - hasPublicKeyY: !!publicKeyY, - hasRawSalt: !!(await SecureStore.getItemAsync('rawSalt')), - hasDeviceUID: !!deviceUID, + hasCredentialId: !!credentialId, + hasPublicKeyX: !!publicKeyX, + hasPublicKeyY: !!publicKeyY, + hasRawSalt: !!(await SecureStore.getItemAsync('rawSalt')), + hasDeviceUID: !!deviceUID, }); + if (credentialId && publicKeyX && publicKeyY) { - dispatch({ type: "SET_KOKIO_PASSKEY", payload: { credentialId, x: publicKeyX as Hex, y: publicKeyY as Hex } }); + dispatch({ + type: "SET_KOKIO_PASSKEY", + payload: { credentialId, x: publicKeyX as Hex, y: publicKeyY as Hex }, + }); } + const rawSalt = await SecureStore.getItemAsync('rawSalt'); if (rawSalt) { dispatch({ type: "SET_RAW_SALT", payload: rawSalt }); } - const userWallet = await getValueForUserWallet( - `userWallet-${deviceUID}` - ); + + const userWallet = await getValueForUserWallet(`userWallet-${deviceUID}`); if (userWallet) { - dispatch({ - type: "SET_USER_WALLET", - payload: userWallet, - }); - } - const purchasedESIMs = await getValueForPurchasedESIMs( - `purchasedESIMs-${deviceUID}` - ); - if (purchasedESIMs) { - dispatch({ - type: "SET_PURCHASED_ESIMS", - payload: purchasedESIMs, - }); + dispatch({ type: "SET_USER_WALLET", payload: userWallet }); } - - // Fire-and-forget: local data already dispatched above; sync runs in background - syncPurchasedEsimsWithBff(deviceUID); } }; fetchUserData(); - // TODO: Visit once order list is available via BFF - //eslint-disable-next-line react-hooks/exhaustive-deps }, []); + /** + * Reset in-memory Kokio state when the account is deleted — either explicitly via Settings, + * or when ANY authed endpoint returns 404 ACCOUNT_DELETED (the interceptor path, where no screen is involved). + * + * State-only by design: storage is owned by purgeAccountLocalState, which runs in the same sequence. + * Calling clearKokioUser() here would race it. + */ + useEffect( + () => + subscribeAccountDeleted((deleted) => { + if (!deleted) return; + dispatch({ type: 'CLEAR_KOKIO_USER' }); + dispatch({ type: 'CLEAR_KOKIO' }); + }), + [], + ); + + /** + * ── SDK initialisation + wallet auto-derivation ─────────────────────────── + * + * Two-step effect: + * Step 1 — Initialise the SDK when deviceUID + userPasskey are available but sdk is not constructed. + * setupKokio dispatches SET_KOKIO and returns; + * the effect re-fires with kokio.sdk populated. + * + * Step 2 — When sdk is ready and userWallet is absent, + * call checkWalletDeployed (Registry.isDeviceWalletValid) to distinguish: + * - New registration : wallet not yet deployed on-chain -> + * Registry returns false -> skip auto-derivation -> + * WalletSetupModal drives deployment via sendUserOperation. + * - Recovery after reinstall : wallet deployed on-chain -> + * Registry returns true -> auto-derive SmartContractAccount + * via getSmartWallet and persist via setupKokioUserWallet. + */ + useEffect(() => { - if (!kokio.sdk && kokio.deviceUID && kokio.userPasskey) { - setupKokio(); - } - // setupKokio is a function, not a dependency to watch for changes + const initSdkAndDeriveWallet = async () => { + if (!kokio.sdk && kokio.deviceUID && kokio.userPasskey) { + await setupKokio(); + return; + } + if ( + kokio.sdk && + kokio.deviceUID && + kokio.userPasskey?.x && + kokio.userPasskey?.y && + kokio.rawSalt && + kokio.deviceWalletAddress && + !kokio.userWallet + ) { + try { + const KokioConstants = await kokio.sdk.constants; + const deployed = await checkWalletDeployed( + kokio.deviceWalletAddress, + kokio.sdk.viemWalletClient, + KokioConstants.factoryAddresses.REGISTRY as Address, + ); + + if (!deployed) { + // New registration + logger.debug('WALLET_AUTO_DERIVE_SKIPPED', { reason: 'not_deployed' }); + return; + } + // Recovery + const ownerKey: [Hex, Hex] = [kokio.userPasskey.x, kokio.userPasskey.y]; + const salt = BigInt(kokio.rawSalt); + + const deviceWallet = await kokio.sdk.smartAccount.getSmartWallet( + kokio.deviceUID, + ownerKey, + salt, + ); + + await setupKokioUserWallet(kokio.deviceUID, deviceWallet); + logger.debug('WALLET_AUTO_DERIVED', { deviceUID: kokio.deviceUID }); + } catch (err) { + // Non-fatal: wallet card stays in setup-prompt state. + logger.error('WALLET_AUTO_DERIVE_FAILED', { err }); + } + } + }; + + initSdkAndDeriveWallet(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kokio.deviceUID, kokio.userPasskey, kokio.sdk]); + }, [kokio.deviceUID, kokio.userPasskey, kokio.sdk, kokio.rawSalt, kokio.userWallet, kokio.deviceWalletAddress]); + + // ── WalletConnect initialisation ────────────────────────────────────────── const wcInitialized = useRef(false); @@ -514,8 +357,6 @@ export const KokioProvider: React.FC = ({ children }) => { const { topic, params, id } = event; const { request } = params; try { - // TODO PAY-011: route eth_sendTransaction / eth_signTypedData_v4 - // through kokio-sdk signer + Pimlico bundler once SDK exposes signing API. void walletAddress; throw new Error(`Method not yet implemented: ${request.method}`); } catch (err) { @@ -531,140 +372,26 @@ export const KokioProvider: React.FC = ({ children }) => { }); client.on("session_delete", ({ topic }) => { - if (__DEV__) console.log("[WC] session deleted:", topic); + logger.debug('WC_SESSION_DELETED', { topic }); }); }).catch((err) => { wcInitialized.current = false; - if (__DEV__) console.error("[WC] init failed:", err); + logger.error('WC_INIT_FAILED', { err }); }); }, [kokio.sdk, kokio.userWallet]); + // ── Actions ─────────────────────────────────────────────────────────────── + const clearError = () => { dispatch({ type: "CLEAR_ERROR" }); }; const setupKokioDeviceUID = async (deviceUID: string) => { await saveValueForDeviceUID("deviceUID", deviceUID); - - dispatch({ - type: "SET_DEVICE_UID", - payload: deviceUID, - }); - }; - - const setupKokioRegistration = async ( - deviceWalletAddress: string, - deviceUniqueIdentifier: string, - credentialId: string, - publicKeyX: Hex, - publicKeyY: Hex, - rawSalt: string, - ) => { - await saveValueForDeviceUID("deviceUID", deviceUniqueIdentifier); - await SecureStore.setItemAsync("deviceWalletAddress", deviceWalletAddress); - await SecureStore.setItemAsync("credentialId", credentialId); - await SecureStore.setItemAsync("publicKeyX", publicKeyX); - await SecureStore.setItemAsync("publicKeyY", publicKeyY); - await SecureStore.setItemAsync("rawSalt", rawSalt); - - const userData: UserData = { - id: deviceUniqueIdentifier, - userName: "", - email: "", - organizationId: "", - wallets: [{ address: deviceWalletAddress }], - }; - await saveValueForUserData(`userData-${deviceUniqueIdentifier}`, userData); - - dispatch({ type: "SET_DEVICE_UID", payload: deviceUniqueIdentifier }); - dispatch({ type: "SET_DEVICE_WALLET_ADDRESS", payload: deviceWalletAddress }); - dispatch({ type: "SET_RAW_SALT", payload: rawSalt }); - dispatch({ type: "SET_KOKIO_USER", payload: userData }); - dispatch({ type: "SET_KOKIO_PASSKEY", payload: { credentialId, x: publicKeyX, y: publicKeyY } }); - }; - - const setupKokioUserWallet = async ( - deviceUID: string, - wallet: SmartContractAccount - ) => { - await saveValueForUserWallet(`userWallet-${deviceUID}`, wallet); - - dispatch({ - type: "SET_USER_WALLET", - payload: wallet, - }); - }; - - const savePurchasedESIM = async ( - deviceUID: string, - eSimItem: Esim, - transactionData: OrderStatusResponse, - correlationId?: string | null, - ) => { - if (__DEV__) console.log('[eSIM] order response:', JSON.stringify(transactionData, null, 2)); - - const existingESIMs = await getValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`); - const currentESIMs = existingESIMs || []; - - const reducedPurchasedESIM = reduceESimDataForStorage(eSimItem, transactionData, correlationId); - if (__DEV__) console.log('[eSIM] stored record:', JSON.stringify(reducedPurchasedESIM, null, 2)); - - const existingIdx = correlationId - ? currentESIMs.findIndex(e => e.transactionData.correlationId === correlationId) - : -1; - - const updatedESIMs = existingIdx >= 0 - ? currentESIMs.map((e, i) => i === existingIdx ? reducedPurchasedESIM : e) - : [...currentESIMs, reducedPurchasedESIM]; - - await saveValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`, updatedESIMs); - dispatch({ type: "SET_PURCHASED_ESIMS", payload: updatedESIMs }); - }; - - const upsertOrderRecord = async ( - deviceUID: string, - eSimItem: Esim, - correlationId: string, - orderStatus?: string, - ) => { - const existingESIMs = await getValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`); - const currentESIMs = existingESIMs || []; - - const existingIdx = currentESIMs.findIndex(e => e.transactionData.correlationId === correlationId); - - let updatedESIMs: StoredPurchasedESIM[]; - if (existingIdx >= 0) { - updatedESIMs = currentESIMs.map((e, i) => - i === existingIdx - ? { ...e, transactionData: { ...e.transactionData, orderStatus: orderStatus ?? e.transactionData.orderStatus } } - : e - ); - } else { - const reducedESimItem = _pick(eSimItem, [ - "catalogueId", "data", "sms", "voice", "validity", "isUnlimited", - "coverageType", "serviceRegionCode", "serviceRegionName", "serviceRegionFlag", - "planType", "isTopupAvailable", "isAutoStart", "isKycRequired", - "countryWiseNetworkCoverages", - ]) as Esim; - const newRecord: StoredPurchasedESIM = { - eSimItem: reducedESimItem, - transactionData: { - orderId: '', - correlationId, - orderStatus: orderStatus ?? 'PAYMENT_PENDING', - installationDetails: { qrcode: '', appleInstallationUrl: '' }, - }, - }; - updatedESIMs = [...currentESIMs, newRecord]; - } - - await saveValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`, updatedESIMs); - dispatch({ type: "SET_PURCHASED_ESIMS", payload: updatedESIMs }); + dispatch({ type: "SET_DEVICE_UID", payload: deviceUID }); }; const setupKokio = async () => { - // Prefer state (populated at mount or registration); fall back to SecureStore - // for the case where setupKokio() is called before hydration completes. const credentialId = kokio.userPasskey?.credentialId ?? await SecureStore.getItemAsync('credentialId') ?? @@ -675,9 +402,6 @@ export const KokioProvider: React.FC = ({ children }) => { return; } - // The SDK requires client.account to be set — it uses client.account.address as - // the `signWith` arg in signTypedData (a TODO stub). Real signing happens via - // Passkey.get() inside _stamp(), so the address value is irrelevant for deployment. const resolvedAddress = kokio.deviceWalletAddress || await SecureStore.getItemAsync('deviceWalletAddress'); @@ -687,10 +411,9 @@ export const KokioProvider: React.FC = ({ children }) => { return; } - const signerAddress = resolvedAddress as `0x${string}`; - - const chainId = Config.CHAIN_ID ?? baseSepolia.id; - const chain = chainId === base.id ? base : baseSepolia; + const signerAddress = resolvedAddress as `0x${string}`; + const chainId = Config.CHAIN_ID ?? baseSepolia.id; + const chain = chainId === base.id ? base : baseSepolia; const alchemySubdomain = chainId === base.id ? 'base-mainnet' : 'base-sepolia'; const rpcUrl = extra.alchemyApiKey @@ -715,10 +438,69 @@ export const KokioProvider: React.FC = ({ children }) => { dispatch({ type: "SET_KOKIO", payload: kokioSDK }); }; + const setupKokioRegistration = async ( + deviceWalletAddress: string, + deviceUniqueIdentifier: string, + credentialId: string, + publicKeyX: Hex, + publicKeyY: Hex, + rawSalt: string, + ) => { + await saveValueForDeviceUID("deviceUID", deviceUniqueIdentifier); + await SecureStore.setItemAsync("deviceWalletAddress", deviceWalletAddress); + await SecureStore.setItemAsync("credentialId", credentialId); + await SecureStore.setItemAsync("publicKeyX", publicKeyX); + await SecureStore.setItemAsync("publicKeyY", publicKeyY); + await SecureStore.setItemAsync("rawSalt", rawSalt); + + const userData: UserData = { + id: deviceUniqueIdentifier, + userName: "", + email: "", + organizationId: "", + wallets: [{ address: deviceWalletAddress }], + }; + await saveValueForUserData(`userData-${deviceUniqueIdentifier}`, userData); + + dispatch({ type: "SET_DEVICE_UID", payload: deviceUniqueIdentifier }); + dispatch({ type: "SET_DEVICE_WALLET_ADDRESS", payload: deviceWalletAddress }); + dispatch({ type: "SET_RAW_SALT", payload: rawSalt }); + dispatch({ type: "SET_KOKIO_USER", payload: userData }); + dispatch({ type: "SET_KOKIO_PASSKEY", payload: { credentialId, x: publicKeyX, y: publicKeyY } }); + }; + + const setupKokioUserWallet = async (deviceUID: string, wallet: SmartContractAccount) => { + await saveValueForUserWallet(`userWallet-${deviceUID}`, wallet); + dispatch({ type: "SET_USER_WALLET", payload: wallet }); + }; + const setupKokioRecovery = async (deviceWalletAddress: string, credentialId: string) => { await SecureStore.setItemAsync('deviceWalletAddress', deviceWalletAddress); await SecureStore.setItemAsync('credentialId', credentialId); dispatch({ type: 'SET_DEVICE_WALLET_ADDRESS', payload: deviceWalletAddress }); + + try { + const account = await getAccount(); + await saveValueForDeviceUID('deviceUID', account.deviceUniqueIdentifier); + await SecureStore.setItemAsync('publicKeyX', account.pubKeyX); + await SecureStore.setItemAsync('publicKeyY', account.pubKeyY); + await SecureStore.setItemAsync('rawSalt', account.salt); + + dispatch({ type: 'SET_DEVICE_UID', payload: account.deviceUniqueIdentifier }); + dispatch({ type: 'SET_RAW_SALT', payload: account.salt }); + dispatch({ + type: 'SET_KOKIO_PASSKEY', + payload: { credentialId, x: account.pubKeyX as Hex, y: account.pubKeyY as Hex }, + }); + // userWallet is intentionally NOT set here. + // The initSdkAndDeriveWallet useEffect calls checkWalletDeployed once the SDK is ready. + } catch (err) { + if ( isAccountDeletedError(err) ) { + // Terminal + logger.debug('RECOVERY_ABORTED_ACCOUNT_DELETED'); + throw err; + } + } }; const clearKokio = () => { @@ -727,11 +509,9 @@ export const KokioProvider: React.FC = ({ children }) => { const clearKokioUser = async () => { dispatch({ type: "CLEAR_KOKIO_USER" }); - // Best-effort deletes — iOS SecureStore throws when a key doesn't exist, - // so each delete is wrapped individually to ensure all keys are attempted. await deleteValueForUser(`userWallet-${kokio.deviceUID}`).catch(() => {}); await deleteValueForUser(`userData-${kokio.deviceUID}`).catch(() => {}); - await deleteValueForPurchasedESIMs(`purchasedESIMs-${kokio.deviceUID}`).catch(() => {}); + await AsyncStorage.removeItem(`purchasedESIMs-${kokio.deviceUID}`).catch(() => {}); await deleteValueForUser("deviceUID").catch(() => {}); await SecureStore.deleteItemAsync("deviceWalletAddress").catch(() => {}); await SecureStore.deleteItemAsync("credentialId").catch(() => {}); @@ -749,8 +529,6 @@ export const KokioProvider: React.FC = ({ children }) => { setupKokio, setupKokioDeviceUID, setupKokioUserWallet, - savePurchasedESIM, - upsertOrderRecord, setupKokioRegistration, setupKokioRecovery, clearKokio, diff --git a/providers/kokioProvider.web.tsx b/providers/kokioProvider.web.tsx deleted file mode 100644 index 4899511..0000000 --- a/providers/kokioProvider.web.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { ReactNode, createContext } from "react"; -import type { Hex } from "viem"; - -type StoredTransactionData = { - orderId: string; - correlationId?: string; - iccid?: string; - planId?: string; - orderStatus?: string; - paymentMethod?: string; - vendor?: string; - esimId?: string; - isNewESim?: boolean; - installationDetails: { - qrcode: string; - appleInstallationUrl: string; - }; -}; - -type StoredPurchasedESIM = { - eSimItem: unknown; - transactionData: StoredTransactionData; -}; - -type KokioState = { - error: string; - sdk?: unknown; - deviceUID: string; - deviceWalletAddress: string; - rawSalt: string; - userData?: unknown; - userPasskey?: unknown; - userWallet?: unknown; - purchasedESIMs: StoredPurchasedESIM[]; -}; - -const initialState: KokioState = { - error: "", - sdk: undefined, - deviceUID: "", - deviceWalletAddress: "", - rawSalt: "", - userData: undefined, - userPasskey: undefined, - userWallet: undefined, - purchasedESIMs: [], -}; - -export interface KokioProviderType { - kokio: KokioState; - clearError: () => void; - setupKokio: () => void; - setupKokioDeviceUID: (deviceUID: string) => Promise; - setupKokioUserWallet: (deviceUID: string, wallet: unknown) => Promise; - savePurchasedESIM: ( - deviceUID: string, - eSimItem: unknown, - transactionData: unknown, - correlationId?: string | null - ) => Promise; - upsertOrderRecord: ( - deviceUID: string, - eSimItem: unknown, - correlationId: string, - orderStatus?: string - ) => Promise; - setupKokioRegistration: ( - deviceWalletAddress: string, - deviceUniqueIdentifier: string, - credentialId: string, - publicKeyX: Hex, - publicKeyY: Hex, - rawSalt: string - ) => Promise; - clearKokio: () => void; - clearKokioUser: () => Promise; -} - -const defaultValue: KokioProviderType = { - kokio: initialState, - clearError: () => {}, - setupKokio: () => {}, - setupKokioDeviceUID: async () => {}, - setupKokioUserWallet: async () => {}, - savePurchasedESIM: async () => {}, - upsertOrderRecord: async () => {}, - setupKokioRegistration: async () => {}, - clearKokio: () => {}, - clearKokioUser: async () => {}, -}; - -export const KokioContext = createContext(defaultValue); - -export function KokioProvider({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/queries/e-sims.ts b/queries/e-sims.ts deleted file mode 100644 index ca11edc..0000000 --- a/queries/e-sims.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; -import _defaults from "lodash/defaults"; - -import { getCatalogue } from "@/utils/bff/catalogue"; -import type { CataloguePlan } from "@/utils/bff/catalogue"; - -type EsimsQueryOptions = Omit, "queryKey" | "queryFn">; - -const STALE_TIME = 5 * 60 * 1000; -const GC_TIME = 10 * 60 * 1000; - -const defaultOptions: EsimsQueryOptions = { - retry: false, - staleTime: STALE_TIME, - gcTime: GC_TIME, -}; - -export const emisQueryKeys = { - all: ["esims"] as const, - esimsByCountry: (item: string) => [...emisQueryKeys.all, "byCountry", item] as const, - esimsByRegion: (item: string) => [...emisQueryKeys.all, "byRegion", item] as const, - esimsByGlobal: (item: string) => [...emisQueryKeys.all, "byGlobal", item] as const, - esimsByCustom: (item: string) => [...emisQueryKeys.all, "byCustom", item] as const, -}; - -export function useEsimsByCountry(serviceRegionCode: string, options: EsimsQueryOptions = defaultOptions) { - return useQuery({ - queryKey: emisQueryKeys.esimsByCountry(serviceRegionCode), - queryFn: async () => { - const response = await getCatalogue({ serviceRegionCode }); - return response.plans; - }, - ..._defaults(options, { ...defaultOptions }), - }); -} - -export function useEsimsByRegion(region: string, options: EsimsQueryOptions = defaultOptions) { - return useQuery({ - queryKey: emisQueryKeys.esimsByRegion(region), - queryFn: async () => { - const response = await getCatalogue({ serviceRegionCode: region }); - return response.plans; - }, - ..._defaults(options, { ...defaultOptions }), - }); -} - -export function useGloabalEsims(options: EsimsQueryOptions = defaultOptions) { - return useQuery({ - queryKey: emisQueryKeys.esimsByGlobal("GLOBAL"), - queryFn: async () => { - const response = await getCatalogue({ serviceRegionCode: "GLOBAL" }); - return response.plans; - }, - ..._defaults(options, { ...defaultOptions }), - }); -} - -export function useCustomEsims(options: EsimsQueryOptions = defaultOptions) { - return useQuery({ - queryKey: emisQueryKeys.esimsByCustom("CUSTOM_REGIONAL"), - queryFn: async () => { - const response = await getCatalogue({ serviceRegionCode: "CUSTOM_REGIONAL" }); - return response.plans; - }, - ..._defaults(options, { ...defaultOptions }), - }); -} diff --git a/screens/EsimsByCountry/EsimsByCountry.tsx b/screens/EsimsByCountry/EsimsByCountry.tsx index 9b86417..9cba002 100644 --- a/screens/EsimsByCountry/EsimsByCountry.tsx +++ b/screens/EsimsByCountry/EsimsByCountry.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useLocalSearchParams } from "expo-router"; -import { ActivityIndicator, StyleSheet, TouchableOpacity, View } from "react-native"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; import { Theme } from "@/constants/Colors"; import { useCatalogueByCountry } from "@/hooks/useCatalogue"; @@ -14,7 +14,7 @@ function EsimsByCountry() { const { data, isLoading, error, refetch } = useCatalogueByCountry(countryCode); if (isLoading) { - return ; + return ; } if (error) { diff --git a/screens/EsimsByRegion/EsimsByRegion.tsx b/screens/EsimsByRegion/EsimsByRegion.tsx index ff97954..81cfac1 100644 --- a/screens/EsimsByRegion/EsimsByRegion.tsx +++ b/screens/EsimsByRegion/EsimsByRegion.tsx @@ -1,5 +1,5 @@ import { useLocalSearchParams } from "expo-router"; -import { ActivityIndicator, StyleSheet, TouchableOpacity, View } from "react-native"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; import { Theme } from "@/constants/Colors"; import { useCatalogueByRegion } from "@/hooks/useCatalogue"; @@ -13,7 +13,7 @@ export default function EsimsByRegion() { const { data, isLoading, error, refetch } = useCatalogueByRegion(region); if (isLoading) { - return ; + return ; } if (error) { diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 085e89d..1e93a46 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -13,9 +13,8 @@ import { import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams, router } from "expo-router"; import { RadioButtonProps, RadioGroup } from "react-native-radio-buttons-group"; -import ToggleSwitch from "toggle-switch-react-native"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; -import _trim from "lodash/trim"; +import { useQueryClient } from '@tanstack/react-query'; import _subtract from "lodash/subtract"; import _toNumber from "lodash/toNumber"; import _toUpper from "lodash/toUpper"; @@ -28,28 +27,29 @@ import DetailItem from "@/components/ui/DetailItem"; import Checkbox from "@/components/ui/Checkbox"; import { Esim } from "@/components/ESIMItem"; import { getEsimOrderPayload } from "@/helpers/esimOrder"; -import { createCryptoOrder, createFiatOrder, createExternalWalletOrder, pollOrderStatus, isOrderSuccess } from "@/utils/bff/order"; +import { useEsims, DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY } from '@/hooks/useDeviceEsims'; +import { isOrderSuccess, pollOrderStatus } from "@/utils/bff/order"; +import type { CreateOrderRequest, CreateOrderResponse, OrderStatusResponse } from "@/utils/bff/order"; import { pollingLabel } from "@/utils/orderStatus"; import OrderFailureModal from "@/components/ui/OrderFailureModal"; import { formatBffError } from "@/utils/bff/koKioBffClient"; import { useCouponLookup } from "@/hooks/useCouponLookup"; import { useEsimCompatibility } from "@/hooks/useEsimCompatibility"; -import { useCreateTopupOrder } from "@/hooks/useCreateOrder"; +import { useCreateOrder, StripeCancelledError, StripeSheetError } from "@/hooks/useCreateOrder"; import { useToast } from "@/contexts/ToastContext"; import CheckoutSuccessModal from "@/components/ui/CheckoutSuccessModal"; import WalletSetupModal from "@/components/ui/WalletSetupModal"; -import { useStripePaymentSheet } from "@/hooks/useStripePaymentSheet"; import { createRadioButtons } from "./checkout.helpers"; import { RADIO_KEYS } from "@/constants/checkout.constants"; import { useKokio } from "@/hooks/useKokio"; -import { Config } from "@/appKeys"; -import type { CreateOrderResponse, OrderStatusResponse, ExternalWalletOrderResponse } from "@/utils/bff/order"; +import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; import * as WebBrowser from "expo-web-browser"; import { MoonpayCommerceProvider, usePayWithCrypto, } from "@heliofi/checkout-react-native"; import type { PaymentCallback } from "@heliofi/checkout-react-native"; +import { logger } from "@/utils/logger"; const SCREEN_WIDTH = Dimensions.get("window").width; const RADIO_WIDTH = SCREEN_WIDTH - 24; @@ -63,7 +63,6 @@ const ExternalWalletCheckout = ({ pendingOrder, eSimItem, kokioDeviceUID, - savePurchasedESIM, setIsCheckoutLoading, setLoadingMessage, onComplete, @@ -73,7 +72,6 @@ const ExternalWalletCheckout = ({ pendingOrder: CreateOrderResponse | null; eSimItem: Esim; kokioDeviceUID: string | undefined; - savePurchasedESIM: (uid: string, item: Esim, order: OrderStatusResponse, cid?: string | null) => Promise; setIsCheckoutLoading: (v: boolean) => void; setLoadingMessage: (msg: string) => void; onComplete: (order: OrderStatusResponse | null) => void; @@ -84,19 +82,22 @@ const ExternalWalletCheckout = ({ const onSuccess = useCallback( async (result) => { successFiredRef.current = true; - if (__DEV__) console.log('[Helio] onSuccess:', result.transactionSignature); + logger.debug('HELIO_ONSUCCESS', { transactionSignature: result.transactionSignature }); setIsCheckoutLoading(true); setLoadingMessage('Processing your order...'); const finalOrder = correlationId - ? await pollOrderStatus(correlationId, 15, 2000, (s) => - setLoadingMessage(pollingLabel(s)), - ).catch(() => null) + ? await pollOrderStatus(correlationId, { + onUpdate: (update) => + setLoadingMessage( + update.kind === 'status' ? pollingLabel(update.orderStatus) : 'Retrying...', + ), + }).catch(() => null) : null; onComplete(finalOrder); }, - //TODO: Probably 'eSimItem', 'kokioDeviceUID', 'pendingOrder', and 'savePurchasedESIM' are not needed in the dependency array here, as they are not part of the changing values of this callback + //TODO: 'eSimItem', 'kokioDeviceUID', 'pendingOrder' are likely not needed in the dep array //eslint-disable-next-line react-hooks/exhaustive-deps - [correlationId, eSimItem, kokioDeviceUID, onComplete, pendingOrder, savePurchasedESIM, setIsCheckoutLoading, setLoadingMessage], + [correlationId, eSimItem, kokioDeviceUID, onComplete, pendingOrder, setIsCheckoutLoading, setLoadingMessage], ); const { payWithCrypto, drawerVisible } = usePayWithCrypto({ onSuccess }); @@ -118,16 +119,9 @@ const ExternalWalletCheckout = ({ }; const createStyles = () => StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - flex: 1, - paddingHorizontal: 12, - }, - scrollContentContainer: { - paddingBottom: 20, - }, + container: { flex: 1 }, + scrollContent: { flex: 1, paddingHorizontal: 12 }, + scrollContentContainer: { paddingBottom: 20 }, bottomButtonContainer: { backgroundColor: "transparent", paddingHorizontal: 16, @@ -135,217 +129,108 @@ const createStyles = () => StyleSheet.create({ paddingBottom: Platform.OS === "ios" ? 8 : 16, }, checkoutButton: { - borderRadius: 32, - paddingVertical: 12, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - }, - checkoutButtonText: { - fontSize: 16, - fontWeight: "600", - }, - logoImage: { - width: 24, - height: 24, - objectFit: "contain", - }, - containerStyle: { - flex: 1, - alignItems: "flex-start", + borderRadius: 32, paddingVertical: 12, flexDirection: "row", + alignItems: "center", justifyContent: "center", }, + checkoutButtonText: { fontSize: 16, fontWeight: "600" }, + logoImage: { width: 24, height: 24, objectFit: "contain" }, + containerStyle: { flex: 1, alignItems: "flex-start" }, buttonStyle: { - flexDirection: "row", - justifyContent: "space-between", - width: RADIO_WIDTH, - backgroundColor: Theme.colors.inputBackground, - paddingVertical: 16, - paddingHorizontal: 24, - marginHorizontal: 0, - marginVertical: 2, - borderRadius: 12, - borderWidth: 1, - }, - walletModalOverlay: { - flex: 1, - backgroundColor: Theme.colors.overlay, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 20, - }, - walletModalContainer: { - backgroundColor: Theme.colors.popover, - borderRadius: 16, - padding: 24, - width: "100%", - maxWidth: 320, - }, - walletModalTitle: { - fontSize: 20, - fontWeight: "600", - textAlign: "center", - marginBottom: 16, - }, - walletModalDescription: { - fontSize: 16, - color: Theme.colors.foreground, - textAlign: "center", - lineHeight: 22, - marginBottom: 32, - }, - walletModalButtons: { - flexDirection: "row", - gap: 1, - }, - laterButton: { - flex: 1, - backgroundColor: Theme.colors.muted, - paddingVertical: 16, - alignItems: "center", - borderTopLeftRadius: 8, - borderBottomLeftRadius: 8, - }, - laterButtonText: { - color: "white", - fontSize: 16, - fontWeight: "500", - }, - continueButton: { - flex: 1, - backgroundColor: Theme.colors.primary, - paddingVertical: 16, - alignItems: "center", - borderTopRightRadius: 8, - borderBottomRightRadius: 8, - }, - continueButtonText: { - color: "white", - fontSize: 16, - fontWeight: "600", - }, - discountContainer: { - flexDirection: "row", - marginTop: 12, - gap: 8, + flexDirection: "row", justifyContent: "space-between", width: RADIO_WIDTH, + backgroundColor: Theme.colors.inputBackground, paddingVertical: 16, paddingHorizontal: 24, + marginHorizontal: 0, marginVertical: 2, borderRadius: 12, borderWidth: 1, }, + discountContainer: { flexDirection: "row", marginTop: 12, gap: 8 }, discountInput: { - flex: 1, - backgroundColor: Theme.colors.inputBackground, - borderRadius: 12, - paddingVertical: 8, - paddingHorizontal: 16, - color: Theme.colors.foreground, - fontSize: 16, - borderWidth: 1, - borderColor: "transparent", - }, - applyButton: { - borderRadius: 12, - paddingVertical: 8, - paddingHorizontal: 24, - justifyContent: "center", - alignItems: "center", - }, - applyButtonText: { - color: Theme.colors.secondaryForeground, - fontSize: 16, - fontWeight: "600", - }, - discountAppliedContainer: { - marginTop: 8, - padding: 12, - backgroundColor: Theme.colors.successBackground, - borderRadius: 8, - }, - discountAppliedContent: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - discountAppliedText: { - color: Theme.colors.success, - fontSize: 14, - }, - removeDiscountButton: { - padding: 4, - backgroundColor: Theme.colors.destructiveBackground, - borderRadius: 32, - }, - discountErrorContainer: { - marginTop: 8, - padding: 12, - backgroundColor: Theme.colors.destructiveBackground, - borderRadius: 8, - }, - discountErrorText: { - color: Theme.colors.destructive, - fontSize: 14, - }, + flex: 1, backgroundColor: Theme.colors.inputBackground, borderRadius: 12, + paddingVertical: 8, paddingHorizontal: 16, color: Theme.colors.foreground, + fontSize: 16, borderWidth: 1, borderColor: "transparent", + }, + applyButton: { borderRadius: 12, paddingVertical: 8, paddingHorizontal: 24, justifyContent: "center", alignItems: "center" }, + applyButtonText: { color: Theme.colors.secondaryForeground, fontSize: 16, fontWeight: "600" }, + discountAppliedContainer:{ marginTop: 8, padding: 12, backgroundColor: Theme.colors.successBackground, borderRadius: 8 }, + discountAppliedContent: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + discountAppliedText: { color: Theme.colors.success, fontSize: 14 }, + topupOptionRow: { + marginTop: 4, padding: 12, borderRadius: 8, borderWidth: 1, + backgroundColor: Theme.colors.inputBackground, borderColor: Theme.colors.mutedForeground, + flexDirection: "row", justifyContent: "space-between", alignItems: "center", + }, + topupOptionRowSelected: { borderColor: Theme.colors.success, borderWidth: 2 }, + topupOptionText: { color: Theme.colors.foreground, fontSize: 14 }, + removeDiscountButton: { padding: 4, backgroundColor: Theme.colors.destructiveBackground, borderRadius: 32 }, + discountErrorContainer: { marginTop: 8, padding: 12, backgroundColor: Theme.colors.destructiveBackground, borderRadius: 8 }, + discountErrorText: { color: Theme.colors.destructive, fontSize: 14 }, loadingOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: Theme.colors.overlay, - justifyContent: 'center', - alignItems: 'center', - gap: 16, - zIndex: 10, - }, - loadingText: { - color: '#FFFFFF', - fontSize: 15, - fontWeight: '500', - }, - walletStatusRow: { - flexDirection: "row", - alignItems: "center", - paddingVertical: 8, - marginTop: 4, - }, - toggleLeftSide: { - flexDirection: "row", - alignItems: "center", - flex: 1, + ...StyleSheet.absoluteFillObject, backgroundColor: Theme.colors.overlay, + justifyContent: 'center', alignItems: 'center', gap: 16, zIndex: 10, }, + loadingText: { color: '#FFFFFF', fontSize: 15, fontWeight: '500' }, }); +// e.g. "United Arab Emirates · 7 Days · 1GB" +function formatPlanLabel(plan?: Esim | null): string | undefined { + if (!plan?.serviceRegionName) return undefined; + const parts = [plan.serviceRegionName]; + if (plan.validity) parts.push(`${plan.validity} Days`); + if (plan.isUnlimited) parts.push('Unlimited'); + else if (plan.data) parts.push(`${plan.data}GB`); + return parts.join(' · '); +} + +// Builds a minimal Esim display shape from an ESimDocument's latest PlanHistoryEntry. +function esimDocToDisplayItem(doc: ESimDocument): Esim { + const entries: PlanHistoryEntry[] = doc.planHistory ?? []; + const latest = entries[entries.length - 1] as PlanHistoryEntry | undefined; + return { + catalogueId: '', + actualSellingPrice: 0, + isUnlimited: latest?.isUnlimited ?? false, + serviceRegionCode: undefined, + serviceRegionFlag: latest?.serviceRegionFlag ?? null, + serviceRegionName: latest?.serviceRegionName ?? null, + coverageType: latest?.coverageType ?? 'LOCAL', + data: latest?.data ?? null, + sms: latest?.sms ?? null, + voice: latest?.voice ?? null, + validity: latest?.validity ?? null, + info: null, + }; +} + const Checkout = () => { const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); + const styles = useMemo(createStyles, [isDark]); const { item: eSimDetails } = useLocalSearchParams(); const eSimItem: Esim = React.useMemo(() => { if (typeof eSimDetails === "string") { - try { - return JSON.parse(eSimDetails); - } catch { - return null; - } + try { return JSON.parse(eSimDetails); } catch { return null; } } return eSimDetails; }, [eSimDetails]); - const [isESimEnabled, setIsESimEnabled] = useState(false); - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState< - string | undefined - >(); - const [showSuccessModal, setShowSuccessModal] = useState(false); - const [isCheckoutLoading, setIsCheckoutLoading] = useState(false); - const [loadingMessage, setLoadingMessage] = useState(''); - const [showWalletSetupModal, setShowWalletSetupModal] = useState(false); - const [pendingPaymentMethod, setPendingPaymentMethod] = useState(null); - const [helioChargeToken, setHelioChargeToken] = useState(null); - const [helioCorrelationId, setHelioCorrelationId] = useState(null); - const [pendingHelioOrder, setPendingHelioOrder] = useState(null); - const { initPaymentSheet, presentPaymentSheet, confirmPaymentSheetPayment } = - useStripePaymentSheet(); - const { kokio, savePurchasedESIM, upsertOrderRecord } = useKokio(); - const [discountCode, setDiscountCode] = useState(""); - const [debouncedCode, setDebouncedCode] = useState(""); - const [isDiscountApplied, setIsDiscountApplied] = useState(false); - const [discountAmount, setDiscountAmount] = useState(0); - const [orderResponse, setOrderResponse] = useState(null); - const [discountError, setDiscountError] = useState(""); + const [isESimEnabled, setIsESimEnabled] = useState(false); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [isCheckoutLoading, setIsCheckoutLoading] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(''); + const [showWalletSetupModal, setShowWalletSetupModal] = useState(false); + const [pendingPaymentMethod, setPendingPaymentMethod] = useState(null); + const [helioChargeToken, setHelioChargeToken] = useState(null); + const [helioCorrelationId, setHelioCorrelationId] = useState(null); + const [pendingHelioOrder, setPendingHelioOrder] = useState(null); + const { kokio } = useKokio(); + const [discountCode, setDiscountCode] = useState(""); + const [debouncedCode, setDebouncedCode] = useState(""); + const [isDiscountApplied, setIsDiscountApplied] = useState(false); + const [discountAmount, setDiscountAmount] = useState(0); + const [orderResponse, setOrderResponse] = useState(null); + const [orderCompleted, setOrderCompleted] = useState(false); + const [topupSuccessInfo, setTopupSuccessInfo] = useState<{ fromLabel: string; toLabel: string } | null>(null); + const [discountError, setDiscountError] = useState(""); const [showManualReviewModal, setShowManualReviewModal] = useState(false); - const [failedOrderInfo, setFailedOrderInfo] = useState<{ + const [failedOrderInfo, setFailedOrderInfo] = useState<{ orderStatus: string; referenceId: string | null; manualReviewReason?: string | null; @@ -353,13 +238,25 @@ const Checkout = () => { const radioButtons: RadioButtonProps[] = useMemo( () => createRadioButtons(selectedPaymentMethod, styles.buttonStyle), - // styles have their own memo watching for changes based on theme + // All missing dependencies are of style attributes which are in their on useMemo() call // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedPaymentMethod] + [selectedPaymentMethod], ); - const { showMessage } = useToast(); - const createTopupOrder = useCreateTopupOrder(); + const { showMessage } = useToast(); + const orderCorrelationRef = useRef(null); + const handlePollUpdate = useCallback( + (update: import("@/utils/bff/order").PollUpdate) => { + setLoadingMessage(update.kind === 'status' ? pollingLabel(update.orderStatus) : 'Retrying...'); + }, + [], + ); + const createOrderMutation = useCreateOrder({ + onOrderCreated: async (correlationId) => { + orderCorrelationRef.current = correlationId; + }, + onPollUpdate: handlePollUpdate, + }); useEffect(() => { const timer = setTimeout(() => setDebouncedCode(discountCode), 500); @@ -367,18 +264,26 @@ const Checkout = () => { }, [discountCode]); const { - data: coupon, - isLoading: isCouponLoading, - isError: isCouponError, + data: coupon, isLoading: isCouponLoading, isError: isCouponError, } = useCouponLookup(debouncedCode, debouncedCode.length === 8); - const hasPriorEsim = kokio.purchasedESIMs.length > 0; - const { isLoading: isCheckingTopup, compatibleEsims } = useEsimCompatibility( + const queryClient = useQueryClient(); + const { esims } = useEsims(); + const hasPriorEsim = esims.length > 0; + const { + isLoading: isCheckingTopup, + isError: isTopupCheckError, + error: topupCheckError, + refetch: refetchTopupCompatibility, + compatibleEsims, + vendorMismatches, + checkErrors, + } = useEsimCompatibility( { planId: eSimItem?.catalogueId }, - { enabled: hasPriorEsim }, + { enabled: hasPriorEsim && !orderCompleted }, ); const isTopupCompatible = compatibleEsims.length > 0; - const [applyAsTopup, setApplyAsTopup] = useState(false); + const [applyAsTopup, setApplyAsTopup] = useState(false); const [compatibleTopUpEsimId, setCompatibleTopUpEsimId] = useState(); const bg = useThemeColor({}, "background"); @@ -388,6 +293,33 @@ const Checkout = () => { } }, [compatibleEsims, compatibleTopUpEsimId]); + // Build a human-readable label for a compatible topup eSIM. + // Source of truth is the live ESimDocument from useEsims() (server-truth), + // using the latest PlanHistoryEntry for region/validity/data fields. + // Falls back to ICCID last-4, then esimId abbreviation. + const buildTopupEsimLabel = useCallback((esimId: string, iccid?: string): string => { + const doc = esims.find((e) => e.esimId === esimId); + const plan = doc ? esimDocToDisplayItem(doc) : null; + const label = formatPlanLabel(plan); + if (label) return label; + if (iccid) return `ICCID ...${iccid.slice(-4)}`; + return `${esimId.slice(0, 6)}...${esimId.slice(-4)}`; + }, [esims]); + + // Append ICCID last-4 only when two labels collide. + const topupEsimOptions = useMemo(() => { + const withLabel = compatibleEsims.map(r => ({ + ...r, label: buildTopupEsimLabel(r.esimId, r.iccid), + })); + const counts = withLabel.reduce>((acc, o) => { + acc[o.label] = (acc[o.label] ?? 0) + 1; return acc; + }, {}); + return withLabel.map(o => ({ + ...o, + label: counts[o.label] > 1 && o.iccid ? `${o.label} (...${o.iccid.slice(-4)})` : o.label, + })); + }, [compatibleEsims, buildTopupEsimLabel]); + const handleOrderResult = useCallback(async ( order: OrderStatusResponse | null, correlationId?: string | null, @@ -396,16 +328,22 @@ const Checkout = () => { showMessage('Unable to confirm order status. Check Order History in Settings.', 'info'); return; } - if (isOrderSuccess(order.orderStatus)) { - if (kokio.deviceUID) { - await savePurchasedESIM(kokio.deviceUID, eSimItem, order, correlationId); - } + setOrderCompleted(true); + queryClient.invalidateQueries({ queryKey: [DEVICE_ESIMS_KEY] }); + queryClient.invalidateQueries({ queryKey: [DEVICE_ORDERS_KEY] }); setOrderResponse(order); + setTopupSuccessInfo( + applyAsTopup && compatibleTopUpEsimId + ? { + fromLabel: formatPlanLabel(eSimItem) ?? 'your new plan', + toLabel: buildTopupEsimLabel(compatibleTopUpEsimId), + } + : null, + ); setShowSuccessModal(true); return; } - if (order.flaggedForManualReview) { setFailedOrderInfo({ orderStatus: order.orderStatus, @@ -415,14 +353,12 @@ const Checkout = () => { setShowManualReviewModal(true); return; } - - // Pre-payment terminal failures: PAYMENT_FAILED, ABANDONED const msg = - order.orderStatus === 'PAYMENT_FAILED' ? 'Payment failed. Please try again.' : - order.orderStatus === 'ABANDONED' ? 'Order expired. Please try again.' : + order.orderStatus === 'PAYMENT_FAILED' ? 'Payment failed. Please try again.' : + order.orderStatus === 'ABANDONED' ? 'Order expired. Please try again.' : 'Order could not be completed. Please try again.'; showMessage(msg, 'info'); - }, [kokio.deviceUID, eSimItem, savePurchasedESIM, showMessage]); + }, [queryClient, showMessage, applyAsTopup, compatibleTopUpEsimId, eSimItem, buildTopupEsimLabel]); const handleRemoveDiscount = useCallback(() => { setIsDiscountApplied(false); @@ -431,74 +367,6 @@ const Checkout = () => { setDiscountError(""); }, []); - const handleEsimCheckout = useCallback(async () => { - try { - setIsCheckoutLoading(true); - - const deviceWalletId = kokio.userWallet?.address || ""; - - if (applyAsTopup && compatibleTopUpEsimId) { - await createTopupOrder.mutateAsync({ - request: { - catalogueId: eSimItem.catalogueId, - isNewESim: false, - esimId: compatibleTopUpEsimId, - isCryptoPayment: true, - coupon: discountCode || undefined, - }, - eSimItem, - }); - showMessage('eSIM topped up successfully!', 'info'); - return; - } - - const payload = getEsimOrderPayload({ - eSimItem, - deviceWalletId, - discountCode, - applyAsTopup, - compatibleTopUpEsimId - }); - const esimBody = { ...payload, payeeAddress: deviceWalletId }; - if (__DEV__) console.log('[Order] eSIM wallet body:', JSON.stringify(esimBody, null, 2)); - const { correlationId } = await createCryptoOrder(esimBody); - if (kokio.deviceUID && correlationId) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); - } - setLoadingMessage('Processing your order...'); - const orderData = correlationId - ? await pollOrderStatus(correlationId, 15, 2000, (s) => setLoadingMessage(pollingLabel(s))).catch(() => null) - : null; - setIsCheckoutLoading(false); - setLoadingMessage(''); - await handleOrderResult(orderData, correlationId); - } catch (err) { - if (__DEV__) console.error("Checkout error:", err); - const e = err as { code?: string; message?: string }; - const errCode = e.code; - if (errCode === 'COUPON_INSUFFICIENT_BALANCE') { - showMessage('Coupon has insufficient balance. Discount removed.', 'info'); - handleRemoveDiscount(); - } else { - showMessage(formatBffError(err), 'info'); - } - setIsCheckoutLoading(false); - setShowSuccessModal(false); - } - }, [ - eSimItem, - discountCode, - kokio?.userWallet, - kokio?.deviceUID, - applyAsTopup, - compatibleTopUpEsimId, - createTopupOrder, - showMessage, - handleRemoveDiscount, - handleOrderResult, - upsertOrderRecord, - ]); - const resetHelioState = useCallback(() => { setHelioChargeToken(null); setPendingHelioOrder(null); @@ -512,15 +380,8 @@ const Checkout = () => { await handleOrderResult(finalOrder, helioCorrelationId); }, [resetHelioState, handleOrderResult, helioCorrelationId]); - // Opens the payment page in SFSafariViewController / Chrome Custom Tab. - // url/cid are passed explicitly so they can be forwarded directly from - // handleCheckout without waiting for setState to flush. - const handleBrowserPay = useCallback(async ( - url: string, - cid: string | null, - ) => { + const handleBrowserPay = useCallback(async (url: string, cid: string | null) => { let polled = false; - const doPoll = async () => { if (polled) return; polled = true; @@ -528,9 +389,7 @@ const Checkout = () => { setLoadingMessage('Checking payment status...'); try { const finalOrder = cid - ? await pollOrderStatus(cid, 5, 3000, (s) => - setLoadingMessage(pollingLabel(s)), - ).catch(() => null) + ? await pollOrderStatus(cid, { onUpdate: handlePollUpdate }).catch(() => null) : null; setIsCheckoutLoading(false); setLoadingMessage(''); @@ -540,196 +399,107 @@ const Checkout = () => { setLoadingMessage(''); } }; - - // When the user returns from the wallet app's browser, dismiss our browser - // and poll BFF for payment status. const appStateSub = AppState.addEventListener('change', (state) => { - if (state === 'active') { - appStateSub.remove(); - WebBrowser.dismissBrowser(); - doPoll(); - } + if (state === 'active') { appStateSub.remove(); WebBrowser.dismissBrowser(); doPoll(); } }); - await WebBrowser.openBrowserAsync(url, { dismissButtonStyle: 'cancel' }); - - // Reaches here when browser is dismissed (user tapped close, or - // dismissBrowser() was called by the AppState handler / moonpay-return). appStateSub.remove(); doPoll(); - }, [handleOrderResult]); + }, [handleOrderResult, handlePollUpdate]); const handleCheckout = useCallback(async () => { - if ( + const isCryptoPayment = !( selectedPaymentMethod === RADIO_KEYS.CREDIT_CARD || selectedPaymentMethod === RADIO_KEYS.APPLE_PAY - ) { - setIsCheckoutLoading(true); - setLoadingMessage('Preparing your payment...'); - let fiatCorrelationId: string | null = null; - try { - const base = getEsimOrderPayload({ eSimItem, deviceWalletId: "", discountCode, applyAsTopup, compatibleTopUpEsimId }); - const fiatBody = { - catalogueId: base.catalogueId, - currency: "USD" as const, - isNewESim: base.isNewESim, - esimId: base.esimId, - coupon: base.coupon, - isCryptoPayment: false as const, - }; - if (__DEV__) console.log('[Order] fiat body:', JSON.stringify(fiatBody, null, 2)); - const { data: orderInit, correlationId } = await createFiatOrder(fiatBody); - fiatCorrelationId = correlationId; - if (__DEV__) console.log('[Order] fiat correlationId:', correlationId); - - if (kokio.deviceUID && correlationId) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); - } - - const { error: initError } = await initPaymentSheet({ - merchantDisplayName: "Kokio", - paymentIntentClientSecret: orderInit.clientSecret, - customFlow: true, - applePay: { merchantCountryCode: "US" }, - googlePay: { merchantCountryCode: "US", testEnv: __DEV__ }, - style: "alwaysDark", - }); - if (initError) { - showMessage(initError.message, "info"); - setIsCheckoutLoading(false); - return; - } + ); + const request: CreateOrderRequest = { + ...getEsimOrderPayload({ eSimItem, discountCode, applyAsTopup, compatibleTopUpEsimId }), + isCryptoPayment, + }; - setLoadingMessage(''); - const { error: presentError } = await presentPaymentSheet(); - if (presentError) { - setIsCheckoutLoading(false); - return; - } + setIsCheckoutLoading(true); + setLoadingMessage('Preparing your payment...'); + orderCorrelationRef.current = null; - const { error: confirmError } = await confirmPaymentSheetPayment(); - if (confirmError) { - if (__DEV__) console.error("[Stripe] confirmPaymentSheetPayment error:", confirmError); - showMessage(confirmError.message, "info"); - setIsCheckoutLoading(false); - return; - } + try { + const result = await createOrderMutation.mutateAsync({ request, eSimItem }); - setLoadingMessage('Processing your order...'); - const finalOrder = correlationId - ? await pollOrderStatus(correlationId, 15, 2000, (s) => - setLoadingMessage(pollingLabel(s)), - ).catch(() => null) - : null; + if (result.kind === 'terminal') { setIsCheckoutLoading(false); setLoadingMessage(''); - await handleOrderResult(finalOrder, correlationId); - } catch (err) { - const bffErr = err as { correlationId?: string | null }; - const errCid = fiatCorrelationId ?? bffErr?.correlationId; - if (__DEV__) { - console.error("[Stripe] checkout error:", err); - if (errCid) console.log('[Order] fiat correlationId (error):', errCid); - } - if (kokio.deviceUID && errCid) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, errCid, 'FAILED'); - } - showMessage(formatBffError(err), "info"); - setIsCheckoutLoading(false); + await handleOrderResult(result.order, result.correlationId); + return; } - return; - } - - if ( - //@ts-expect-error EXTERNAL_WALLET has been intentionally disable for now - selectedPaymentMethod === RADIO_KEYS.EXTERNAL_WALLET || - selectedPaymentMethod === RADIO_KEYS.EXTERNAL_WALLET_BROWSER - ) { - setIsCheckoutLoading(true); - setLoadingMessage('Preparing your payment...'); - let extCorrelationId: string | null = null; - try { - const base = getEsimOrderPayload({ eSimItem, deviceWalletId: "", discountCode, applyAsTopup, compatibleTopUpEsimId }); - const extBody = { - catalogueId: base.catalogueId, - currency: "USD" as const, - isNewESim: base.isNewESim, - esimId: base.esimId, - coupon: base.coupon, - isCryptoPayment: true as const, - payeeAddress: kokio.userWallet?.address, - successRedirectUrl: Config.EXTERNAL_WALLET_CALLBACK, - }; - if (__DEV__) console.log('[Order] external wallet body:', JSON.stringify(extBody, null, 2)); - const { data: orderInit, correlationId } = await createExternalWalletOrder(extBody); - extCorrelationId = correlationId; - if (__DEV__) console.log('[Order] external wallet correlationId:', correlationId); - if (kokio.deviceUID && correlationId) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); - } + // awaiting_crypto_payment + setIsCheckoutLoading(false); + setLoadingMessage(''); + if ( + //@ts-expect-error EXTERNAL_WALLET has been intentionally disabled for now + selectedPaymentMethod === RADIO_KEYS.EXTERNAL_WALLET + ) { + setPendingHelioOrder({ + orderId: result.orderId, + moonpayChargeId: result.moonpayChargeId, + moonpayPaymentPageUrl: result.moonpayPaymentPageUrl, + }); + setHelioCorrelationId(result.correlationId); + setHelioChargeToken(result.moonpayChargeId); + } else { + // EXTERNAL_WALLET_BROWSER and the device-wallet path share this browser + // fallback — both resolve to the same CRYPTO response shape. + // TODO: Revisit once the backend distinguishes direct transfers from processor payments. + handleBrowserPay(result.moonpayPaymentPageUrl, result.correlationId); + } + } catch (err) { + logger.error('CHECKOUT_FAILED', { err }); + if (err instanceof StripeCancelledError) { setIsCheckoutLoading(false); setLoadingMessage(''); - - if (selectedPaymentMethod === RADIO_KEYS.EXTERNAL_WALLET_BROWSER) { - // Skip the SDK drawer — open browser directly with fresh values. - const pageUrl = (orderInit as ExternalWalletOrderResponse).moonpayPaymentPageUrl; - if (pageUrl) handleBrowserPay(pageUrl, correlationId); - } else { - setPendingHelioOrder(orderInit); - setHelioCorrelationId(correlationId); - setHelioChargeToken(orderInit.moonpayChargeId); - } - } catch (err) { - const bffErr = err as { correlationId?: string | null }; - const errCid = extCorrelationId ?? bffErr?.correlationId; - if (__DEV__) { - console.error("[Helio] checkout error:", err); - if (errCid) console.log('[Order] external wallet correlationId (error):', errCid); - } - if (kokio.deviceUID && errCid) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, errCid, 'FAILED'); - } - showMessage(formatBffError(err), "info"); + return; + } + if (err instanceof StripeSheetError) { + showMessage(err.message, 'info'); setIsCheckoutLoading(false); + setLoadingMessage(''); + return; } - return; + const e = err as { code?: string; message?: string }; + if (e.code === 'COUPON_INSUFFICIENT_BALANCE') { + showMessage('Coupon has insufficient balance. Discount removed.', 'info'); + handleRemoveDiscount(); + } else { + showMessage(formatBffError(err), 'info'); + } + setIsCheckoutLoading(false); + setLoadingMessage(''); } - - handleEsimCheckout(); }, [ - selectedPaymentMethod, - eSimItem, - discountCode, - applyAsTopup, - compatibleTopUpEsimId, - kokio.userWallet, - kokio.deviceUID, - upsertOrderRecord, - initPaymentSheet, - presentPaymentSheet, - confirmPaymentSheetPayment, - showMessage, - handleEsimCheckout, - handleBrowserPay, - handleOrderResult, + selectedPaymentMethod, eSimItem, discountCode, applyAsTopup, compatibleTopUpEsimId, + createOrderMutation, handleOrderResult, handleBrowserPay, handleRemoveDiscount, showMessage, ]); const handleInstallESIM = useCallback(() => { setShowSuccessModal(false); + router.dismissAll(); router.navigate({ pathname: "/(tabs)/installation", params: { - orderId: orderResponse?.orderId || "", - qrcode: orderResponse?.installationDetails?.qrcode || "", - appleInstallationUrl: - orderResponse?.installationDetails?.appleInstallationUrl || "", - iccid: orderResponse?.iccid || "", + orderId: orderResponse?.orderId || "", + qrcode: orderResponse?.installationDetails?.qrcode || "", + appleInstallationUrl: orderResponse?.installationDetails?.appleInstallationUrl || "", + iccid: orderResponse?.iccid || "", }, }); }, [orderResponse]); + const handleTopupDone = useCallback(() => { + setShowSuccessModal(false); + router.dismissAll(); + router.navigate("/(tabs)"); + }, []); + const handleWalletModalClose = useCallback(() => { setShowWalletSetupModal(false); setPendingPaymentMethod(null); @@ -737,16 +507,15 @@ const Checkout = () => { const handlePaymentMethodChange = useCallback( (value: string) => { - if (value === RADIO_KEYS.E_SIM_WALLET) return; // disabled — not a payment option yet + if (value === RADIO_KEYS.E_SIM_WALLET) return; if (!kokio.userWallet) { - // Wallet not yet deployed — gate behind deployment modal then resume setPendingPaymentMethod(value); setShowWalletSetupModal(true); } else { setSelectedPaymentMethod(value); } }, - [kokio?.userWallet] + [kokio?.userWallet], ); const handleDiscountCodeChange = useCallback((text: string) => { @@ -759,45 +528,25 @@ const Checkout = () => { const handleApplyDiscount = useCallback(() => { if (!coupon || coupon.isExhausted) return; setDiscountError(''); - const couponBalance = _toNumber(coupon.balance || 0); if (eSimItem.actualSellingPrice > couponBalance) { setDiscountError('Cannot sponsor the entire amount'); return; } - setIsDiscountApplied(true); setDiscountAmount(eSimItem.actualSellingPrice); - - if (!kokio.userWallet) { - setShowWalletSetupModal(true); - } + if (!kokio.userWallet) setShowWalletSetupModal(true); }, [coupon, eSimItem.actualSellingPrice, kokio.userWallet]); const totalAmount = useMemo(() => { - if (isDiscountApplied) { - return _subtract(eSimItem.actualSellingPrice, discountAmount); - } + if (isDiscountApplied) return _subtract(eSimItem.actualSellingPrice, discountAmount); return eSimItem.actualSellingPrice; }, [eSimItem.actualSellingPrice, isDiscountApplied, discountAmount]); - - // const canCheckout = useMemo( - // () => - // isESimEnabled && - // selectedPaymentMethod && - // totalAmount === 0 && - // !isCheckoutLoading, - // [isESimEnabled, selectedPaymentMethod, totalAmount, isCheckoutLoading] - // ); - - const canCheckout = useMemo(() => { - if (!isESimEnabled || isCheckoutLoading || !selectedPaymentMethod) return false; - if (selectedPaymentMethod === RADIO_KEYS.E_SIM_WALLET) { - return !!kokio.userWallet && isDiscountApplied; - } - return true; - }, [isESimEnabled, isCheckoutLoading, selectedPaymentMethod, kokio.userWallet, isDiscountApplied]); + const canCheckout = useMemo( + () => isESimEnabled && !isCheckoutLoading && !!selectedPaymentMethod, + [isESimEnabled, isCheckoutLoading, selectedPaymentMethod], + ); return ( @@ -845,8 +594,6 @@ const Checkout = () => { maxLength={8} /> - - {/* Loading */} {isCouponLoading && ( @@ -855,8 +602,6 @@ const Checkout = () => { )} - - {/* Valid coupon — show balance and Apply button */} {!isCouponLoading && debouncedCode.length === 8 && coupon && !coupon.isExhausted && !isDiscountApplied && ( @@ -866,14 +611,14 @@ const Checkout = () => { Apply Coupon )} - - {/* Applied */} {isDiscountApplied && ( @@ -883,42 +628,32 @@ const Checkout = () => { )} - - {/* Exhausted coupon */} {!isCouponLoading && debouncedCode.length === 8 && coupon?.isExhausted && ( - - This coupon has been fully used - + This coupon has been fully used )} - - {/* Invalid / not found */} {!isCouponLoading && isCouponError && debouncedCode.length === 8 && ( - - Invalid coupon code - + Invalid coupon code )} - - {/* Balance / other errors */} {discountError ? ( - - {discountError} - + {discountError} ) : null} - {/* Top-up compatibility */} {isCheckingTopup && ( @@ -927,82 +662,58 @@ const Checkout = () => { )} - {/* TODO: TOPUP , selection from multiple eSIMs(if exists and comptabile) for top-up*/} + {!isCheckingTopup && isTopupCheckError && ( + + {formatBffError(topupCheckError)} + refetchTopupCompatibility()} accessibilityRole="button"> + Retry + + + )} + {!isCheckingTopup && !isTopupCheckError && !isTopupCompatible && checkErrors.length > 0 && ( + + + Couldn't verify top-up compatibility for your existing eSIM. Please try again. + + refetchTopupCompatibility()} accessibilityRole="button"> + Retry + + + )} + {!isCheckingTopup && !isTopupCheckError && !isTopupCompatible && checkErrors.length === 0 && vendorMismatches.length > 0 && ( + + + Your existing eSIM isn't compatible with this plan for top-up. + + + )} {!isCheckingTopup && isTopupCompatible && ( Apply as Top-up - Top up your existing eSIM instead of buying a new one + Select an eSIM to top up, or leave unselected to buy a new one - - - - - Apply this plan as a top-up - - - - {applyAsTopup && compatibleEsims.length === 1 && compatibleTopUpEsimId && ( - - - {`eSIM: ${compatibleTopUpEsimId.slice(0, 6)}...${compatibleTopUpEsimId.slice(-4)}`} - - - )} - {applyAsTopup && compatibleEsims.length > 1 && ( - - - Select eSIM to top up: - - {compatibleEsims.map((r) => ( - setCompatibleTopUpEsimId(r.esimId)} - style={[ - styles.discountAppliedContainer, - { marginTop: 4, flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, - compatibleTopUpEsimId === r.esimId && { borderWidth: 1, borderColor: Theme.colors.success }, - ]} - > - - {`${r.esimId.slice(0, 6)}...${r.esimId.slice(-4)}`} - - {compatibleTopUpEsimId === r.esimId && ( - - )} - - ))} - - )} + {topupEsimOptions.map((r) => { + const isSelected = applyAsTopup && compatibleTopUpEsimId === r.esimId; + return ( + { + if (isSelected) { setApplyAsTopup(false); setCompatibleTopUpEsimId(undefined); } + else { setApplyAsTopup(true); setCompatibleTopUpEsimId(r.esimId); } + }} + style={[styles.topupOptionRow, isSelected && styles.topupOptionRowSelected]} + accessibilityRole="radio" + accessibilityState={{ checked: isSelected }} + accessibilityLabel={`Apply this plan as a top-up to ${r.label}`} + > + {r.label} + {isSelected && } + + ); + })} )} - - {/* - Fund Device Wallet - - Speed up and secure your next eSIM purchase or top-up by funding - your on-device eSIM crypto wallet. - - - - - I'd like to also fund my on-device wallet - - - {fundOnDeviceWallet && addAmountSection} - */} { style={[styles.bottomButtonContainer, !canCheckout && { opacity: 0.5 }]} onPress={canCheckout ? handleCheckout : undefined} disabled={!canCheckout} + accessibilityRole="button" + accessibilityLabel={`Pay ${totalAmount} USD`} + accessibilityState={{ disabled: !canCheckout }} > { { }} /> - {/* SDK wallet-app drawer flow */} {helioChargeToken && ( { pendingOrder={pendingHelioOrder} eSimItem={eSimItem} kokioDeviceUID={kokio.deviceUID} - savePurchasedESIM={savePurchasedESIM} setIsCheckoutLoading={setIsCheckoutLoading} setLoadingMessage={setLoadingMessage} onComplete={handleHelioComplete} @@ -1070,10 +786,8 @@ const Checkout = () => { /> )} - ); }; export default Checkout; - diff --git a/screens/checkout/components/radioLabels.tsx b/screens/checkout/components/radioLabels.tsx index 0f80cc3..1baec01 100644 --- a/screens/checkout/components/radioLabels.tsx +++ b/screens/checkout/components/radioLabels.tsx @@ -39,16 +39,9 @@ const ESimWallet = () => { return ( Device Wallet - - - Balance - - 0.00 - - + + Coming soon + ); }; diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index a06b175..dc95262 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -1,11 +1,12 @@ -import React, { useRef, useState, useMemo } from "react"; +import React, { useRef, useState, useMemo, useCallback } from "react"; import { + Linking, + Platform, View, Text, StyleSheet, TouchableOpacity, ScrollView, - Platform, } from "react-native"; import ViewShot, { captureRef } from "react-native-view-shot"; import Share from "react-native-share"; @@ -14,13 +15,14 @@ import { Ionicons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; import QRCode from "react-native-qrcode-svg"; import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; -import { useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import _get from "lodash/get"; import _split from "lodash/split"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; import { useTheme } from "@/contexts/ThemeContext"; +import { logger } from "@/utils/logger"; type TabType = "Direct" | "QR" | "Manual"; @@ -98,7 +100,7 @@ const TextWithCopy = ({ label, text }: {label: string, text: string}) => { try { await Clipboard.setStringAsync(text); } catch (error) { - console.error("Error copying to clipboard:", error); + logger.error('CLIPBOARD_COPY_FAILED', { error }); } }; return ( @@ -235,28 +237,57 @@ const createStyles = () => StyleSheet.create({ textCopyContainer: { marginBottom: 12, }, + missingQrContainer: { + alignItems: "center", + justifyContent: "center", + padding: 24, + gap: 16, + }, }); const EsimInstallation = () => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); - const { qrcode } = useLocalSearchParams(); - const qrData = - (Array.isArray(qrcode) ? _head(qrcode) : qrcode) || - "LPA:1$activation.airalo.com$sample-qr-data"; + const { qrcode, appleInstallationUrl: rawAppleUrl } = useLocalSearchParams(); + const appleInstallationUrl = Array.isArray(rawAppleUrl) ? rawAppleUrl[0] : (rawAppleUrl ?? ""); + const router = useRouter(); + const rawQrData = Array.isArray(qrcode) ? _head(qrcode) : qrcode; + const hasQrData = !!rawQrData; + const qrData = rawQrData || ""; const qrDataSplit = _split(qrData, "$"); const activationAddress = _get(qrDataSplit, [1]); const activationCode = _get(qrDataSplit, [2]); const [activeTab, setActiveTab] = useState("QR"); + if (!hasQrData) { + return ( + + + Installation details unavailable + + + We couldn't find the installation QR code for this eSIM. Please go back and try again from Orders. + + router.back()} + accessibilityRole="button" + accessibilityLabel="Go back" + > + Go Back + + + ); + } + const QRScene = () => { const qrViewRef = useRef(null); const handleShareQR = async () => { try { if (!qrViewRef.current) { - console.log("QR view ref is not available"); + logger.warn('QR_VIEW_REF_UNAVAILABLE'); return; } @@ -271,10 +302,10 @@ const EsimInstallation = () => { url: `file://${uri}`, type: "image/png", }).catch((err) => { - err && console.log("react-native-share API failed", err); + if (err) logger.warn('QR_SHARE_API_FAILED', { err }); }); } catch (error) { - console.error("QR Share failed with error", error); + logger.error('QR_SHARE_FAILED', { error }); } }; @@ -337,10 +368,10 @@ const EsimInstallation = () => { text={qrData} /> - {Platform.OS === "ios" && activationAddress && ( + {activationAddress && ( )} - {Platform.OS === "ios" && activationCode && ( + {activationCode && ( )} @@ -363,23 +394,58 @@ const EsimInstallation = () => { ); - const DirectScene = () => ( - - - - Direct Installation - - - Select Install eSIM and wait — do not close the app, installation may - take a few minutes. Select Allow/OK, when prompted. - - - - Coming soon - - - - ); + const DirectScene = () => { + const handleDirectInstall = useCallback(async () => { + try { + await Linking.openURL(appleInstallationUrl); + } catch (err) { + logger.error('ESIM_APPLE_INSTALL_FAILED', { err }); + } + }, []); + + const isEnabled = Platform.OS === "ios" && !!appleInstallationUrl; + + return ( + + + + + Direct Installation + + + + {isEnabled + ? "Tap Install eSIM to begin. Do not close the app — installation may take a few minutes. Select Allow/OK when prompted." + : "Select Install eSIM and wait — do not close the app, installation may take a few minutes. Select Allow/OK, when prompted."} + + + + + {isEnabled ? "Install eSIM" : "Coming soon"} + + + + + ); + }; const renderTabBar = () => { const tabs: TabType[] = ["Direct", "QR", "Manual"]; diff --git a/screens/home/home.tsx b/screens/home/home.tsx index 01083d9..1c09cb7 100644 --- a/screens/home/home.tsx +++ b/screens/home/home.tsx @@ -1,6 +1,5 @@ import { ScrollView } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import _get from "lodash/get"; import ActiveESIMsScroll from "@/components/home/active-esim-scroll"; import Wallet from "@/components/home/wallet"; @@ -16,8 +15,6 @@ export default function HomeScreen() { const [showWalletSetup, setShowWalletSetup] = useState(false); const bg = useThemeColor({}, "background"); - const purchasedESIMs = _get(kokio, "purchasedESIMs") || []; - const handleOpenWalletSetup = async () => { if (!kokio.sdk) { await setupKokio(); @@ -29,7 +26,7 @@ export default function HomeScreen() { - + {kokio.userWallet ? ( { { { const foregroundColor = useThemeColor({}, "foreground"); return ( - + { }, [regionConfig, sanitizedSearchText, countries]); const [scrollOffset, setScrollOffset] = useState(0); + const carouselRef = useRef(null); const chunkedCountries = _chunk(countries, 2); const SNAP_INTERVAL = 160 + SPACING; const maxOffset = (chunkedCountries.length - 1) * SNAP_INTERVAL; @@ -210,13 +219,31 @@ const SearchResult = ({ searchText }: { searchText: string }) => { const isAtStart = scrollOffset <= 0; const isAtEnd = scrollOffset >= maxOffset - SNAP_INTERVAL; + const scrollByOnePage = (direction: 1 | -1) => { + const target = Math.min( + Math.max(scrollOffset + direction * SNAP_INTERVAL, 0), + maxOffset + ); + carouselRef.current?.scrollToOffset({ offset: target, animated: true }); + setScrollOffset(target); + }; + return ( {_size(countries) ? ( - + scrollByOnePage(-1)} + disabled={isAtStart} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + accessibilityRole="button" + accessibilityLabel="Scroll countries left" + > + + String(item?.[0]?.code || index)} @@ -229,7 +256,15 @@ const SearchResult = ({ searchText }: { searchText: string }) => { onScroll={(e) => setScrollOffset(e.nativeEvent.contentOffset.x)} scrollEventThrottle={16} /> - + scrollByOnePage(1)} + disabled={isAtEnd} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + accessibilityRole="button" + accessibilityLabel="Scroll countries right" + > + + ) : null} diff --git a/screens/shop/shop.tsx b/screens/shop/shop.tsx index 0ae166d..fd4b3e2 100644 --- a/screens/shop/shop.tsx +++ b/screens/shop/shop.tsx @@ -1,6 +1,7 @@ -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { StyleSheet } from "react-native"; import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; +import { useNavigation } from "expo-router"; import _debounce from "lodash/debounce"; @@ -51,8 +52,28 @@ const TabsNavigator = () => { const Shop = () => { const [searchText, setSearchText] = useState(""); + const [topTabResetKey, setTopTabResetKey] = useState(0); + const navigation = useNavigation(); - const debouncedOnSearch = _debounce(setSearchText, 500); + const debouncedOnSearch = useMemo(() => _debounce(setSearchText, 500), []); + + useEffect(() => () => debouncedOnSearch.cancel(), [debouncedOnSearch]); + + // tabPress bubbles up to the nearest ancestor tab navigator (the bottom + // Tabs), so this fires whenever the Shop tab icon is pressed — including + // when switching back into Shop from a different tab. Remounting + // TabsNavigator (via the key bump) resets it to its first screen + // (Countries) instead of silently keeping whatever top-tab (Regions/ + // Global/Special) was last active. + useEffect(() => { + // "tabPress" isn't in expo-router's generic NavigationProp event map + // (it's specific to tab navigators, which this screen isn't directly), + // but it still bubbles up correctly to the ancestor Tabs navigator at runtime. + const unsubscribe = (navigation as any).addListener("tabPress", () => { + setTopTabResetKey((key) => key + 1); + }); + return unsubscribe; + }, [navigation]); return ( @@ -61,7 +82,7 @@ const Shop = () => { {searchText ? ( ) : ( - + )} diff --git a/screens/shop/tabs/countries/countries.tsx b/screens/shop/tabs/countries/countries.tsx index 3c7405a..51955a3 100644 --- a/screens/shop/tabs/countries/countries.tsx +++ b/screens/shop/tabs/countries/countries.tsx @@ -15,6 +15,12 @@ const SCREEN_WIDTH = Dimensions.get("window").width; const COLUMN_GAP = 16; const ITEM_WIDTH = (SCREEN_WIDTH * 0.9 - COLUMN_GAP) / COLUMN_COUNT; +const EmptyListComponent = () => ( + + No countries found + +); + export default function Countries() { const list = appBootstrap.getCountries; @@ -22,6 +28,8 @@ export default function Countries() { String(item?.code || index)} contentContainerStyle={{ paddingBottom: 100 }} style={{ backgroundColor: "transparent" }} + ListEmptyComponent={EmptyListComponent} /> diff --git a/screens/shop/tabs/custom/custom.tsx b/screens/shop/tabs/custom/custom.tsx index 3661ceb..24fb1bc 100644 --- a/screens/shop/tabs/custom/custom.tsx +++ b/screens/shop/tabs/custom/custom.tsx @@ -1,4 +1,4 @@ -import { ActivityIndicator, StyleSheet, TouchableOpacity, View } from "react-native"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; import DataPackTabGroup from "@/components/DataPackTabGroup"; import { ThemedText } from "@/components/ThemedText"; @@ -9,7 +9,7 @@ export default function Custom() { const { data, isLoading, error, refetch } = useCatalogue({ serviceRegionCode: "CUSTOM_REGIONAL" }); if (isLoading) { - return ; + return ; } if (error) { diff --git a/screens/shop/tabs/global/global.tsx b/screens/shop/tabs/global/global.tsx index 51964c6..633d11c 100644 --- a/screens/shop/tabs/global/global.tsx +++ b/screens/shop/tabs/global/global.tsx @@ -1,4 +1,4 @@ -import { ActivityIndicator, StyleSheet, TouchableOpacity, View } from "react-native"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; import DataPackTabGroup from "@/components/DataPackTabGroup"; import { ThemedText } from "@/components/ThemedText"; @@ -9,7 +9,7 @@ export default function Global() { const { data, isLoading, error, refetch } = useCatalogue({ serviceRegionCode: "GLOBAL" }); if (isLoading) { - return ; + return ; } if (error) { diff --git a/screens/shop/tabs/regions/regions.tsx b/screens/shop/tabs/regions/regions.tsx index ca7051a..e043de1 100644 --- a/screens/shop/tabs/regions/regions.tsx +++ b/screens/shop/tabs/regions/regions.tsx @@ -16,13 +16,23 @@ import { REGION_CONFIG } from "@/constants/general.constants"; import appBootstrap from "@/utils/appBootstrap"; import { navigateToESIMsByRegion } from "@/utils/general"; +const EmptyListComponent = () => ( + + No regions found + +); + export default function Regions() { const list = appBootstrap.getRegions; const renderItem = ({ item, index }: any) => { const imagePath = _get(REGION_CONFIG, [item?.code, "imagePath"]); return ( - + {item?.name || ""} @@ -44,6 +54,7 @@ export default function Regions() { renderItem={renderItem} style={{ width: "100%", backgroundColor: "transparent" }} keyExtractor={(item, index) => String(item?.code || index)} + ListEmptyComponent={EmptyListComponent} /> ); diff --git a/screens/test/test.tsx b/screens/test/test.tsx index d0e6907..e2593fe 100644 --- a/screens/test/test.tsx +++ b/screens/test/test.tsx @@ -8,6 +8,7 @@ import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { useKokio } from "@/hooks/useKokio"; import { useAppState } from "@/hooks/useAppState"; import { generateKeyPair, SignJWT, calculateJwkThumbprint, exportJWK } from "jose"; +import { logger } from "@/utils/logger"; const isValidEmail = (email: string | undefined) => { if (!email) return false; @@ -16,7 +17,7 @@ const isValidEmail = (email: string | undefined) => { export default function TestScreen() { const appState = useAppState(true); - console.log("AppState in layout", appState); + logger.debug('TEST_APPSTATE', { appState }); const { signUpWithPasskey, loginWithPasskey } = useAuthRelay(); const { kokio, clearKokioUser } = useKokio(); @@ -144,7 +145,7 @@ export default function TestScreen() { try { await loginWithPasskey(); } catch (e) { - console.error("Error signing in", e); + logger.error('TEST_SIGNIN_FAILED', { err: e }); } }, [loginWithPasskey]); @@ -153,9 +154,9 @@ export default function TestScreen() { return alert("Invalid email address"); try { const response = await signUpWithPasskey({ username, email }); - console.log("sign-up result", response); + logger.debug('TEST_SIGNUP_RESULT', { response }); } catch (e) { - console.error("Error signing up", e); + logger.error('TEST_SIGNUP_FAILED', { err: e }); } }, [email, username, signUpWithPasskey]); diff --git a/scripts/scan-console.mjs b/scripts/scan-console.mjs new file mode 100644 index 0000000..80da5c4 --- /dev/null +++ b/scripts/scan-console.mjs @@ -0,0 +1,117 @@ +// Fails if any direct `console.*` call exists in app source. +// The single sanctioned logging seam is `utils/logger.ts` (allowlisted below). +// +// Scope: only tracked-source directories are scanned. Excluded, by design: +// - node_modules, android, ios, dist, coverage — not app source +// - **/generated/** — generated OpenAPI types (never hand-edited) +// - scripts/** — build/CI tooling legitimately uses console +// - **/__tests__/**, *.test.*, *.spec.* — test code +// - utils/logger.ts — the one allowed console site +// +// Comment handling: block comments and `//` line comments are stripped before +// matching, so commented-out console lines are ignored. + +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = process.cwd(); + +// Directories that contain scannable app source. +const SOURCE_DIRS = [ + "app", + "components", + "hooks", + "providers", + "screens", + "services", + "stores", + "utils", +]; + +// Individual root-level source files to include (not under a scanned dir). +const SOURCE_ROOT_FILES = ["appKeys.ts"]; + +// Path (POSIX, repo-relative) of the one allowed console site. +const ALLOWLIST = new Set(["utils/logger.ts"]); +const SOURCE_EXT = new Set([".ts", ".tsx"]); + +function isExcludedFile(relPosix) { + if (ALLOWLIST.has(relPosix)) return true; + if (relPosix.includes("/generated/")) return true; + if (relPosix.includes("/__tests__/")) return true; + if (/\.(test|spec)\.(ts|tsx)$/.test(relPosix)) return true; + return false; +} + +function walk(absDir, out) { + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (entry.name === "node_modules") continue; + const abs = path.join(absDir, entry.name); + if (entry.isDirectory()) { + walk(abs, out); + } else if (SOURCE_EXT.has(path.extname(entry.name))) { + out.push(abs); + } + } +} + +function stripComments(source) { + let out = ""; + let i = 0; + let state = "code"; // "code" | "line" | "block" + while (i < source.length) { + const two = source.slice(i, i + 2); + if (state === "code") { + if (two === "//") { state = "line"; i += 2; continue; } + if (two === "/*") { state = "block"; i += 2; continue; } + out += source[i]; i += 1; + } else if (state === "line") { + if (source[i] === "\n") { state = "code"; out += "\n"; } + i += 1; + } else { // block + if (two === "*/") { state = "code"; i += 2; continue; } + out += source[i] === "\n" ? "\n" : " "; + i += 1; + } + } + return out; +} + +const files = []; +for (const dir of SOURCE_DIRS) { + const abs = path.join(repoRoot, dir); + if (fs.existsSync(abs)) walk(abs, files); +} +for (const rel of SOURCE_ROOT_FILES) { + const abs = path.join(repoRoot, rel); + if (fs.existsSync(abs)) files.push(abs); +} + +const CONSOLE_RE = /\bconsole\s*\.\s*[a-zA-Z]+/; +const violations = []; + +for (const abs of files) { + const relPosix = path.relative(repoRoot, abs).split(path.sep).join("/"); + if (isExcludedFile(relPosix)) continue; + + const stripped = stripComments(fs.readFileSync(abs, "utf8")); + const lines = stripped.split("\n"); + for (let n = 0; n < lines.length; n += 1) { + if (CONSOLE_RE.test(lines[n])) { + violations.push({ file: relPosix, line: n + 1, text: lines[n].trim() }); + } + } +} + +if (violations.length > 0) { + console.error( + `Found ${violations.length} direct console.* call(s) outside utils/logger.ts.\n` + + "Route logging through utils/logger.ts (logger.debug/info/warn/error).\n" + ); + for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.text}`); + } + process.exit(1); +} + +console.log("scan-console: OK — no direct console.* outside utils/logger.ts."); diff --git a/scripts/validate-release-pr.mjs b/scripts/validate-release-pr.mjs index ccb1734..602e765 100644 --- a/scripts/validate-release-pr.mjs +++ b/scripts/validate-release-pr.mjs @@ -15,7 +15,6 @@ const otaSafePatterns = [ "hooks/", "lib/", "providers/", - "queries/", "screens/", "services/", "stores/", diff --git a/scripts/verify-ota-preflight.mjs b/scripts/verify-ota-preflight.mjs index 345042b..eb301e8 100644 --- a/scripts/verify-ota-preflight.mjs +++ b/scripts/verify-ota-preflight.mjs @@ -10,9 +10,7 @@ if (!eventPath && !process.env.RELEASE_BASE_BRANCH) { process.exit(1); } -const event = eventPath - ? JSON.parse(fs.readFileSync(eventPath, "utf8")) - : {}; +const event = eventPath ? JSON.parse(fs.readFileSync(eventPath, "utf8")) : {}; const baseBranch = process.env.RELEASE_BASE_BRANCH ?? event.pull_request?.base?.ref; const labels = (event.pull_request?.labels ?? []).map((label) => label.name); @@ -29,13 +27,31 @@ if (!hasOtaLabel) { process.exit(0); } +const VALID_PLATFORMS = new Set(["android", "ios"]); +const platforms = (process.env.OTA_PLATFORMS ?? "android") + .split(/[\s,]+/) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + +if (platforms.length === 0) { + console.error("OTA_PLATFORMS resolved to an empty platform list."); + process.exit(1); +} +for (const platform of platforms) { + if (!VALID_PLATFORMS.has(platform)) { + console.error( + `Unknown platform "${platform}" in OTA_PLATFORMS. Expected android and/or ios.` + ); + process.exit(1); + } +} + const packageJson = JSON.parse( fs.readFileSync(path.join(repoRoot, "package.json"), "utf8") ); const appVersion = packageJson.version; const runtimeVersion = packageJson.version; const profile = baseBranch; -const platforms = ["android", "ios"]; for (const platform of platforms) { let builds; @@ -79,12 +95,15 @@ for (const platform of platforms) { if (!Array.isArray(builds) || builds.length === 0) { console.error( - `No finished ${platform} store build found for profile ${profile}, app version ${appVersion}, and runtime version ${runtimeVersion}. OTA requires an existing compatible build for both platforms.` + `No finished ${platform} store build found for profile ${profile}, app version ${appVersion}, and runtime version ${runtimeVersion}. ` + + `An OTA update to ${platform} requires an existing compatible store build for that platform.` ); process.exit(1); } + console.log( + `OTA preflight: found a compatible finished ${platform} build for ${profile} @ ${appVersion}.` + ); } - console.log( - `OTA preflight passed for ${baseBranch} using app/runtime version ${appVersion}.` + `OTA preflight passed for ${baseBranch} (${platforms.join(", ")}) using app/runtime version ${appVersion}.` ); diff --git a/services/httpService.ts b/services/httpService.ts index ba2f43c..d637f59 100644 --- a/services/httpService.ts +++ b/services/httpService.ts @@ -3,12 +3,15 @@ import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig, Axi import qs from 'qs'; import { v4 as uuidv4 } from 'uuid'; import { router } from 'expo-router'; +import { logger } from '@/utils/logger'; import { Config } from '@/appKeys'; import { useAuthStore } from '@/stores/authStore'; import { buildDpopProof } from '@/utils/auth/dpopProof'; import { refreshAccessToken, TokenFamilyRevokedError } from '@/utils/auth/refresh'; import { StepUpCancelledError } from '@/utils/auth/errors'; +import { markAccountDeleted } from '@/utils/auth/accountDeleted'; +import { purgeAccountLocalState } from '@/utils/auth/purgeAccountLocalState'; // Allow callers to opt out of auth header injection for public endpoints, // or to override the htu claim for routes with path parameters. @@ -31,9 +34,11 @@ export type StepUpHint = { let _onStepUpNeeded: ((hint: StepUpHint) => void) | null = null; let _onUnauthenticated: (() => void) | null = null; +let _onAccountDeleted: (() => void) | null = null; export function setStepUpHandler(fn: (hint: StepUpHint) => void): void { _onStepUpNeeded = fn; } -export function setUnauthenticatedHandler(fn: () => void): void { _onUnauthenticated = fn; } +export function setUnauthenticatedHandler(fn: () => void): void { _onUnauthenticated = fn; } +export function setAccountDeletedHandler(fn: () => void): void { _onAccountDeleted = fn; } // ─── Step-up queue (AUTH-502) ───────────────────────────────────────────────── // All concurrent requests that hit STEP_UP_REQUIRED park here. AUTH-502 calls @@ -114,7 +119,7 @@ instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => const correlationId = (config.headers['x-correlation-id'] as string | undefined) ?? uuidv4(); config.headers['x-correlation-id'] = correlationId; - if (__DEV__) console.log(`[http] ${(config.method ?? 'GET').toUpperCase()} ${config.url} | correlationId: ${correlationId}`); + logger.debug('HTTP_REQUEST', { method: (config.method ?? 'GET').toUpperCase(), url: config.url, correlationId }); const stored = useAuthStore.getState().tokens; if (!stored) return config; // unauthenticated request — no auth headers @@ -177,6 +182,19 @@ instance.interceptors.response.use( const origin = bffOrigin(); if (errNonce && origin) _bffNonceCache.set(origin, errNonce); + // Terminal state, valid on ANY authenticated endpoint — not just DELETE /account. + if ( + status === 404 && + (body?.code === 'ACCOUNT_DELETED' || body?.error === 'ACCOUNT_DELETED') + ) { + await markAccountDeleted(); + await useAuthStore.getState().clearTokens(); + await purgeAccountLocalState(); + _onAccountDeleted?.(); + router.replace('/'); + return Promise.reject(error.response ?? error); + } + // Non-401 or already retried — pass through. if (status !== 401 || !cfg || cfg._retried) { return Promise.reject(error.response ?? error); diff --git a/stores/authStore.ts b/stores/authStore.ts index 5faf688..7bc9721 100644 --- a/stores/authStore.ts +++ b/stores/authStore.ts @@ -12,6 +12,11 @@ import { type AuthStoreState = { tokens: TokenBundle | null; + /** + * True ONLY after a successful passkey ceremony this process lifetime + * `loadPersistedTokens` intentionally does NOT set this flag. + * The presence of a stored token bundle does not constitute an authenticated session. + */ isAuthenticated: boolean; setTokens: (bundle: TokenBundle) => Promise; loadPersistedTokens: () => Promise; @@ -31,7 +36,8 @@ export const useAuthStore = create((set) => ({ loadPersistedTokens: async () => { const bundle = await loadTokens(); - set({ tokens: bundle, isAuthenticated: bundle !== null }); + // isAuthenticated is deliberately NOT set, a bundle in storage does not constitute an authenticated session. + set({ tokens: bundle }); }, clearTokens: async () => { diff --git a/utils/__tests__/logger.test.ts b/utils/__tests__/logger.test.ts new file mode 100644 index 0000000..f4d6fd4 --- /dev/null +++ b/utils/__tests__/logger.test.ts @@ -0,0 +1,87 @@ +import { logger, getRecentEntries, clearRecentEntries } from "@/utils/logger"; + +declare const global: typeof globalThis & { __DEV__: boolean }; + +function withDev(value: boolean, fn: () => T): T { + const original = global.__DEV__; + global.__DEV__ = value; + try { + return fn(); + } finally { + global.__DEV__ = original; + } +} + +describe("logger", () => { + beforeEach(() => { + clearRecentEntries(); + jest.restoreAllMocks(); + }); + + it("prints to the console in development", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + withDev(true, () => logger.error("PASSKEY_ASSERTION_FAILED", { err: new Error("x") })); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toBe("[PASSKEY_ASSERTION_FAILED]"); + }); + + it("is silent on the console in release", () => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + const info = jest.spyOn(console, "info").mockImplementation(() => {}); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const error = jest.spyOn(console, "error").mockImplementation(() => {}); + + withDev(false, () => { + logger.debug("D"); + logger.info("I"); + logger.warn("W"); + logger.error("E", { err: new Error("boom") }); + }); + + expect(log).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("retains only { ts, level, code } — context never reaches the record", () => { + const secret = { deviceWalletAddress: "0xABCDEF", token: "eyJhbGciOi..." }; + withDev(false, () => logger.error("WALLET_DEPLOYMENT_FAILED", secret)); + + const entries = getRecentEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual( + expect.objectContaining({ level: "error", code: "WALLET_DEPLOYMENT_FAILED" }) + ); + expect(Object.keys(entries[0]).sort()).toEqual(["code", "level", "ts"]); + + expect(JSON.stringify(entries)).not.toContain("0xABCDEF"); + expect(JSON.stringify(entries)).not.toContain("eyJhbGciOi"); + }); + + it("records in development as well (buffer is always on)", () => { + jest.spyOn(console, "info").mockImplementation(() => {}); + withDev(true, () => logger.info("BFF_HEALTH_STATUS", { healthy: true })); + const entries = getRecentEntries(); + expect(entries).toHaveLength(1); + expect(entries[0].code).toBe("BFF_HEALTH_STATUS"); + }); + + it("bounds the ring buffer and evicts oldest first", () => { + withDev(false, () => { + for (let i = 0; i < 150; i += 1) logger.debug(`E_${i}`); + }); + const entries = getRecentEntries(); + expect(entries).toHaveLength(100); + // Oldest 50 (E_0..E_49) evicted; newest retained, oldest-first ordering. + expect(entries[0].code).toBe("E_50"); + expect(entries[entries.length - 1].code).toBe("E_149"); + }); + + it("clears retained records", () => { + withDev(false, () => logger.warn("CONFIG_MISSING_API_BASE_URL")); + expect(getRecentEntries()).toHaveLength(1); + clearRecentEntries(); + expect(getRecentEntries()).toHaveLength(0); + }); +}); diff --git a/utils/auth/accountDeleted.ts b/utils/auth/accountDeleted.ts new file mode 100644 index 0000000..f0a6bd5 --- /dev/null +++ b/utils/auth/accountDeleted.ts @@ -0,0 +1,66 @@ +/** + * Account-deleted flag. + * Persisted because the condition must survive a cold boot. + * The passkey keeps authenticating successfully — login/begin and login/complete still mint valid tokens. + * + * Without this flag the user would loop: relaunch -> passkey login succeeds -> + * first authed call 404s -> back to landing -> repeat. + * The flag lets the auth UI skip the login/recover paths entirely and steer the user to + * (a) remove the stale passkey and + * (b) register a new one. + * + * Cleared only on a successful NEW registration. + * Re-registering the SAME passkey resolves to the same address and must NOT clear this flag. + */ +import * as SecureStore from 'expo-secure-store'; +import { logger } from '@/utils/logger'; + +const KEY = 'kokio.account.deleted'; + +type Listener = (deleted: boolean) => void; +const listeners = new Set(); + +// In-memory mirror so synchronous render paths don't have to await SecureStore. +let _cached = false; + +export function subscribeAccountDeleted(fn: Listener): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +// Last known value. Call hydrateAccountDeleted() once at boot before relying on this. +export function isAccountDeletedCached(): boolean { + return _cached; +} + +// Read the persisted flag into the in-memory mirror. Call once on app boot. +export async function hydrateAccountDeleted(): Promise { + try { + _cached = (await SecureStore.getItemAsync(KEY)) === 'true'; + } catch { + _cached = false; + } + listeners.forEach((fn) => fn(_cached)); + return _cached; +} + +export async function markAccountDeleted(): Promise { + _cached = true; + try { + await SecureStore.setItemAsync(KEY, 'true'); + } catch (err) { + // Non-fatal: the in-memory mirror still guards this session. + logger.error('ACCOUNT_DELETED_FLAG_PERSIST_FAILED', { err }); + } + listeners.forEach((fn) => fn(true)); +} + +export async function clearAccountDeleted(): Promise { + _cached = false; + try { + await SecureStore.deleteItemAsync(KEY); + } catch { + /* best-effort */ + } + listeners.forEach((fn) => fn(false)); +} diff --git a/utils/auth/dpopProof.ts b/utils/auth/dpopProof.ts index 0007a32..8804bb1 100644 --- a/utils/auth/dpopProof.ts +++ b/utils/auth/dpopProof.ts @@ -1,6 +1,7 @@ import { SignJWT, base64url } from 'jose'; import { v4 as uuidv4 } from 'uuid'; import { getDpopKeyPair } from './dpopKeystore'; +import { logger } from '@/utils/logger'; export type BuildDpopProofParams = { /** Full request URL — no query string or fragment (RFC 9449 §4.2 htu). */ @@ -47,7 +48,7 @@ export async function buildDpopProof({ if (nonce !== undefined) payload.nonce = nonce; if (ath !== undefined) payload.ath = ath; - if (__DEV__) console.log('[dpop] proof payload:', JSON.stringify({ htm, htu, nonce, ath })); + logger.debug('DPOP_PROOF_PAYLOAD', { htm, htu, nonce, ath }); return new SignJWT(payload) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk }) diff --git a/utils/auth/kokioAuthClient.ts b/utils/auth/kokioAuthClient.ts index 260eb4d..e3ace09 100644 --- a/utils/auth/kokioAuthClient.ts +++ b/utils/auth/kokioAuthClient.ts @@ -2,6 +2,7 @@ import type { paths, components } from './generated/kokioAuth'; import { Config } from '@/appKeys'; import { v4 as uuidv4 } from 'uuid'; import { AuthError } from './errors'; +import { logger } from '@/utils/logger'; // ─── Re-export generated types consumed across the auth layer ──────────────── @@ -154,9 +155,7 @@ async function authFetch( const text = await res.text(); const contentType = res.headers.get('content-type') ?? ''; - if (__DEV__) { - console.log(`[authFetch] ${method} ${path} → ${res.status} (${contentType})\n req:`, body, '\n res:', text.slice(0, 1000)); - } + logger.debug('AUTHFETCH_ROUNDTRIP', { method, path, status: res.status, contentType, body, res: text.slice(0, 1000) }); if (!contentType.includes('application/json')) { throw new AuthError('SERVER_ERROR', res.status, text || `HTTP ${res.status}`); diff --git a/utils/auth/passkeyLogin.ts b/utils/auth/passkeyLogin.ts index 6e47afd..4c65298 100644 --- a/utils/auth/passkeyLogin.ts +++ b/utils/auth/passkeyLogin.ts @@ -9,61 +9,10 @@ import { parseIdToken } from './tokenStore'; import { AuthError } from './errors'; import { useAuthStore } from '@/stores/authStore'; import { Config } from '@/appKeys'; +import { logger } from '@/utils/logger'; // ─── Shared types ──────────────────────────────────────────────────────────── -/*DEVICE WALLET DEPLOYEMENT FIX*/ -// type LoginCompleteExtended = { -// deviceWalletAddress: string; -// authTime: number; -// deviceUniqueIdentifier?: string; -// rawSalt?: string; -// publicKeyX?: string; -// publicKeyY?: string; -// }; -// -// async function hydrateCredentialStore(data: LoginCompleteExtended): Promise { -// if (data.deviceUniqueIdentifier) { -// const existing = await SecureStore.getItemAsync('deviceUID'); -// if (!existing) { -// await SecureStore.setItemAsync('deviceUID', JSON.stringify(data.deviceUniqueIdentifier)); -// if (__DEV__) console.log('[passkey] hydrated deviceUID from loginComplete'); -// } -// } -// if (data.rawSalt) { -// const existing = await SecureStore.getItemAsync('rawSalt'); -// if (!existing) { -// await SecureStore.setItemAsync('rawSalt', data.rawSalt); -// if (__DEV__) console.log('[passkey] hydrated rawSalt from loginComplete'); -// } -// } -// if (data.publicKeyX && data.publicKeyY) { -// const existing = await SecureStore.getItemAsync('publicKeyX'); -// if (!existing) { -// await SecureStore.setItemAsync('publicKeyX', data.publicKeyX); -// await SecureStore.setItemAsync('publicKeyY', data.publicKeyY); -// if (__DEV__) console.log('[passkey] hydrated publicKeyX/Y from loginComplete'); -// } -// } -// } -// -// async function hydrateDeviceUIDFromUserHandle(userHandle: string | null | undefined): Promise { -// if (!userHandle) return; -// try { -// const b64 = userHandle.replace(/-/g, '+').replace(/_/g, '/'); -// const binary = atob(b64); -// let decoded = ''; -// for (let i = 0; i < binary.length; i++) decoded += binary[i]; -// if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decoded)) return; -// const existing = await SecureStore.getItemAsync('deviceUID'); -// if (!existing) { -// await SecureStore.setItemAsync('deviceUID', JSON.stringify(decoded)); -// if (__DEV__) console.log('[passkey] hydrated deviceUID from userHandle:', decoded); -// } -// } catch { /* non-critical */ } -// } -/*DEVICE WALLET DEPLOYEMENT FIX*/ - export type DiscoverLoginResult = { credentialId: string; deviceWalletAddress: string; @@ -106,7 +55,7 @@ async function captureAuthorizationCode( { authorizationEndpoint }, { preferUniversalLinks: true }, ); - if (__DEV__) console.log(`[authorize] (android/promptAsync) → ${result.type}`); + logger.debug('AUTHORIZE_ANDROID_RESULT', { type: result.type}); if (result.type === 'success') { const { code } = result.params; @@ -127,7 +76,7 @@ async function captureAuthorizationCode( redirect: 'follow', }); const returnUrl = (response as unknown as { url?: string }).url ?? ''; - if (__DEV__) console.log(`[authorize] (ios/fetch) → ${response.status}`, { responseUrl: returnUrl }); + logger.debug('AUTHORIZE_IOS_RESULT', { status: response.status, responseUrl: returnUrl }); if (returnUrl && /[?&](code|error)=/.test(returnUrl)) { const result = request.parseReturnUrl(returnUrl); @@ -174,7 +123,7 @@ async function performLoginCeremony(credentialIdHint?: string, deviceWalletAddre let assertion; try { - console.log('[PASSKEY] calling Passkey.get'); + logger.debug('PASSKEY Calling Passkey.get'); // On Android, empty allowCredentials triggers discoverable-credential discovery // via Google Password Manager, which hangs or shows "Use another device" when // the credential isn't yet locally indexed. Use the stored credential ID to @@ -193,10 +142,10 @@ async function performLoginCeremony(credentialIdHint?: string, deviceWalletAddre allowCredentials: allowCredentials, userVerification: beginData.userVerification, }); - console.log('[PASSKEY] got assertion'); - if (__DEV__) console.log('[PASSKEY] assertion userHandle (raw):', assertion.response.userHandle); + logger.debug('PASSKEY_GOT_ASSERTION'); + logger.debug('PASSKEY_ASSERTION_RAW_USERHANDLE', assertion.response.userHandle); } catch (e) { - console.log('[PASSKEY] error', e); + logger.error('PASSKEY_ASSERTION_FAILED', { err: e }); throw e; } @@ -311,7 +260,7 @@ export async function discoverAndLoginWithPasskey(): Promise( await kokioAuthClient.loginComplete({ diff --git a/utils/auth/purgeAccountLocalState.ts b/utils/auth/purgeAccountLocalState.ts new file mode 100644 index 0000000..8cfd974 --- /dev/null +++ b/utils/auth/purgeAccountLocalState.ts @@ -0,0 +1,61 @@ +/** + * Purge every local trace of the device account. + * Deliberately context-free (no hooks, no React) so it can be invoked from the httpService interceptor. + * + * Covers three stores: + * 1. SecureStore — credential + wallet-derivation artifacts. + * 2. AsyncStorage — purchasedESIMs mirror. + * 3. React Query — device-esims / device-orders are PERSISTED to AsyncStorage by the PersistQueryClientProvider. + * Clearing the in-memory cache alone is not enough as without removeQueries + asyncStoragePersister purge, + * a deleted account's eSIM list is restored on next cold boot. + * + * NOTE: does NOT clear auth tokens. + */ +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { queryClient, asyncStoragePersister } from '@/providers'; +import { DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY } from '@/hooks/useDeviceEsims'; +import { logger } from '@/utils/logger'; + +const SECURE_KEYS = [ + 'deviceWalletAddress', + 'credentialId', + 'publicKeyX', + 'publicKeyY', + 'rawSalt', + 'deviceUID', +] as const; + +export async function purgeAccountLocalState(): Promise { + let deviceUID: string | null = null; + try { + const raw = await SecureStore.getItemAsync('deviceUID'); + // Stored via JSON.stringify (kokioProvider.saveValueForDeviceUID, authProvider.signUpWithPasskey) + if (raw) { + try { deviceUID = JSON.parse(raw) as string; } catch { deviceUID = raw; } + } + } catch { + /* best-effort */ + } + await Promise.all( + SECURE_KEYS.map((k) => SecureStore.deleteItemAsync(k).catch(() => {})), + ); + + if (deviceUID) { + await Promise.all([ + AsyncStorage.removeItem(`purchasedESIMs-${deviceUID}`).catch(() => {}), + SecureStore.deleteItemAsync(`userWallet-${deviceUID}`).catch(() => {}), + SecureStore.deleteItemAsync(`userData-${deviceUID}`).catch(() => {}), + ]); + } + try { + queryClient.removeQueries({ queryKey: [DEVICE_ESIMS_KEY] }); + queryClient.removeQueries({ queryKey: [DEVICE_ORDERS_KEY] }); + queryClient.clear(); + await asyncStoragePersister.removeClient(); + } catch (err) { + logger.error('ACCOUNT_PURGE_QUERY_CACHE_FAILED', { err }); + } + + logger.debug('ACCOUNT_LOCAL_STATE_PURGED'); +} diff --git a/utils/auth/stepUp.ts b/utils/auth/stepUp.ts index 53ce438..0240e90 100644 --- a/utils/auth/stepUp.ts +++ b/utils/auth/stepUp.ts @@ -4,6 +4,7 @@ import { buildDpopProof } from './dpopProof'; import { parseIdToken } from './tokenStore'; import { AuthError } from './errors'; import { useAuthStore } from '@/stores/authStore'; +import { logger } from '@/utils/logger'; // ─── Response envelope helper (mirrors passkeyLogin.ts) ────────────────────── @@ -15,12 +16,6 @@ function assertData(raw: unknown, fallbackCode: string): T { return body.data; } -// ─── Telemetry ──────────────────────────────────────────────────────────────── - -function logEvent(event: string, data?: Record): void { - if (__DEV__) console.log('[stepup]', event, data ?? ''); -} - // ─── Step-up ceremony ───────────────────────────────────────────────────────── // // Flow: stepup/begin → Passkey.get → stepup/complete → swap AT (RT unchanged) @@ -32,11 +27,11 @@ function logEvent(event: string, data?: Record): void { // rejectStepUp(new StepUpCancelledError()) on user cancel (see httpService.ts). export async function performStepUp(): Promise { - logEvent('stepup.started'); + logger.debug('[STEPUP] Stepup started'); const current = useAuthStore.getState().tokens; if (!current) { - logEvent('stepup.failed', { reason: 'NO_TOKENS' }); + logger.error('STEP_UP_FAILED', { reason: 'NO_TOKENS' }); throw new AuthError('STEP_UP_CANCELLED'); } @@ -66,7 +61,7 @@ export async function performStepUp(): Promise { // 3. Complete the ceremony; DPoP nonce retry is handled inside kokioAuthClient. // htu is provided by authFetch from the actual request URL — do not hardcode it here. const buildProof: DpopProofBuilder = (nonce, htu) => { - logEvent('stepup.buildProof', { htu, nonce: !!nonce }); + logger.debug('[STEPUP] buildProof', { htu, nonce: !!nonce }); return buildDpopProof({ htu: htu!, htm: 'POST', nonce }); }; @@ -111,9 +106,9 @@ export async function performStepUp(): Promise { ...(auth_time !== undefined && { auth_time }), }); - logEvent('stepup.completed', { auth_time }); + logger.debug('[STEPUP] Completed', { auth_time }); } catch (err) { - logEvent('stepup.failed', { error: err instanceof Error ? err.message : String(err) }); + logger.error('STEP_UP_FAILED', { error: err instanceof Error ? err.message : String(err) }); throw err; } } diff --git a/utils/bff/__tests__/account.test.ts b/utils/bff/__tests__/account.test.ts new file mode 100644 index 0000000..74d67e4 --- /dev/null +++ b/utils/bff/__tests__/account.test.ts @@ -0,0 +1,98 @@ +/** + * account BFF module tests + * + * Verifies getAccount: correct endpoint, no params (identity resolved from JWT), + * response shape, and BffError propagation. + */ + +import api from '@/services/httpService'; +import { getAccount } from '../account'; +import { BffError } from '../errors'; + +jest.mock('@/services/httpService', () => ({ + __esModule: true, + default: { + get: jest.fn(), + getConfig: jest.fn(() => ({})), + }, +})); + +const mockGet = api.get as jest.MockedFunction; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const ACCOUNT_DATA = { + deviceWalletAddress: '0xabc123def456abc123def456abc123def456abc1', + deviceUniqueIdentifier: 'c3a1b2d4-e5f6-7890-abcd-ef1234567890', + salt: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + pubKeyX: '0x1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d', + pubKeyY: '0x2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e', +}; + +function accountEnvelope(data = ACCOUNT_DATA) { + return { success: true, correlationId: null, message: 'Success', data }; +} + +function bffError(code: string) { + return { success: false, code, correlationId: null, message: code }; +} + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(() => jest.clearAllMocks()); + +// ─── getAccount ─────────────────────────────────────────────────────────────── + +describe('getAccount', () => { + describe('request shape', () => { + it('calls api.get with /v1/account', async () => { + mockGet.mockResolvedValue(accountEnvelope()); + await getAccount(); + expect(mockGet).toHaveBeenCalledWith('/v1/account'); + }); + + it('calls api.get exactly once', async () => { + mockGet.mockResolvedValue(accountEnvelope()); + await getAccount(); + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('takes no parameters — identity is resolved from the JWT server-side', async () => { + mockGet.mockResolvedValue(accountEnvelope()); + await getAccount(); + expect(mockGet.mock.calls[0]).toHaveLength(1); + }); + }); + + describe('response handling', () => { + it('returns the account derivation material', async () => { + mockGet.mockResolvedValue(accountEnvelope()); + const result = await getAccount(); + expect(result).toEqual(ACCOUNT_DATA); + }); + + it('returns deviceUniqueIdentifier for deviceUID persistence', async () => { + mockGet.mockResolvedValue(accountEnvelope()); + const result = await getAccount(); + expect(result.deviceUniqueIdentifier).toBe(ACCOUNT_DATA.deviceUniqueIdentifier); + }); + }); + + describe('error handling', () => { + it('throws BffError when success: false', async () => { + mockGet.mockResolvedValue(bffError('ACCOUNT_NOT_FOUND')); + await expect(getAccount()).rejects.toBeInstanceOf(BffError); + }); + + it('thrown BffError carries STEP_UP_REQUIRED code', async () => { + mockGet.mockResolvedValue(bffError('STEP_UP_REQUIRED')); + await expect(getAccount()).rejects.toMatchObject({ code: 'STEP_UP_REQUIRED' }); + }); + + it('propagates network-level errors', async () => { + const err = new Error('Request timeout'); + mockGet.mockRejectedValue(err); + await expect(getAccount()).rejects.toBe(err); + }); + }); +}); diff --git a/utils/bff/account.ts b/utils/bff/account.ts new file mode 100644 index 0000000..45fad2f --- /dev/null +++ b/utils/bff/account.ts @@ -0,0 +1,21 @@ +import type { components } from './generated/koKioBff'; +import { unwrapBffResponse } from './koKioBffClient'; +import api from '@/services/httpService'; + +type AccountResponse = components['schemas']['AccountResponse']; + +export type { AccountResponse }; + +/** + * Wallet-derivation material (pubKeyX/pubKeyY/salt/deviceWalletAddress) the client SDK needs + * to reconstruct the smart account after a reinstall. + * Identity is resolved server-side from the JWT — no params. Requires a recent step-up assertion. + */ +export function getAccount(): Promise { + return unwrapBffResponse(api.get('/v1/account')); +} + +// Permanently delete the authenticated device's account. IRREVERSIBLE. +export async function deleteAccount(): Promise { + await api.delete('/v1/account', { ...api.getConfig() }); +} diff --git a/utils/bff/coupon.ts b/utils/bff/coupon.ts index 8068a4f..669ace0 100644 --- a/utils/bff/coupon.ts +++ b/utils/bff/coupon.ts @@ -1,16 +1,13 @@ import type { components } from './generated/koKioBff'; import { unwrapBffResponse, BffError } from './koKioBffClient'; import api from '@/services/httpService'; +import { logger } from '@/utils/logger'; type IssueCouponRequest = components['schemas']['IssueCouponRequest']; type CouponDocument = components['schemas']['CouponDocument']; export type { IssueCouponRequest, CouponDocument }; -function log(event: string, data?: Record): void { - if (__DEV__) console.log('[coupon]', event, data ?? ''); -} - export class InvalidCouponCodeError extends Error { constructor() { super('Coupon code must be exactly 8 characters'); } } @@ -19,11 +16,11 @@ export async function getCoupon(code: string): Promise { const normalized = code.trim().toUpperCase(); if (normalized.length !== 8) throw new InvalidCouponCodeError(); - log('lookup.request', { code: normalized, url: `/v1/coupon/${normalized}` }); + logger.debug('[COUPON] Lookup Request', { code: normalized, url: `/v1/coupon/${normalized}` }); try { const doc = await unwrapBffResponse(api.get(`/v1/coupon/${normalized}`)); - log('lookup.success', { + logger.debug('[COUPON] Lookup Success', { code: normalized, balance: doc.balance, tokenName: doc.tokenName, @@ -31,7 +28,7 @@ export async function getCoupon(code: string): Promise { }); return doc; } catch (err) { - log('lookup.error', { + logger.error('COUPON_LOOKUP_ERROR', { code: normalized, url: `/v1/coupon/${normalized}`, errorCode: err instanceof BffError ? err.code : 'UNKNOWN', diff --git a/utils/bff/errors.ts b/utils/bff/errors.ts index a6a3c6b..a3bd7ab 100644 --- a/utils/bff/errors.ts +++ b/utils/bff/errors.ts @@ -92,6 +92,10 @@ export class BffError extends Error { } } +export function isAccountDeletedError(e: unknown): boolean { + return e instanceof BffError && e.code === 'ACCOUNT_DELETED'; +} + // ─── formatBffError ─────────────────────────────────────────────────────────── // Pass the caught value directly; returns a string ready for showMessage(). diff --git a/utils/bff/esim.ts b/utils/bff/esim.ts index b8f1ce1..7966944 100644 --- a/utils/bff/esim.ts +++ b/utils/bff/esim.ts @@ -8,8 +8,18 @@ type CompatibilityResult = components['schemas']['CompatibilityResult']; type ESimDocument = components['schemas']['ESimDocument']; type ESimListResponse = components['schemas']['ESimListResponse']; type PlanHistoryEntry = components['schemas']['PlanHistoryEntry']; +type ESimUsage = components['schemas']['ESimUsage']; +type ESimUsageResponse = components['schemas']['ESimUsageResponse']; -export type { CheckCompatibilityParams, CompatibilityResponse, CompatibilityResult, ESimDocument, ESimListResponse, PlanHistoryEntry }; +export type { + CheckCompatibilityParams, + CompatibilityResponse, + CompatibilityResult, + ESimDocument, + ESimListResponse, + PlanHistoryEntry, + ESimUsage, +}; export function checkEsimCompatibility(params: CheckCompatibilityParams, esimId?: string): Promise { const path = esimId ? `/v1/esim/compatibility/${esimId}` : '/v1/esim/compatibility'; @@ -24,5 +34,11 @@ export function getAllEsims(): Promise { return unwrapBffResponse(api.get('/v1/esim')).then(r => r.eSims); } +export function getEsimUsage(esimId: string): Promise { + return unwrapBffResponse( + api.get(`/v1/esim/usage/${esimId}`), + ).then(r => r.usage[0]); +} + /** @deprecated Use checkEsimCompatibility */ export const checkTopUpCompatibility = checkEsimCompatibility; diff --git a/utils/bff/generated/koKioBff.d.ts b/utils/bff/generated/koKioBff.d.ts index 5cd4ac7..cac4717 100644 --- a/utils/bff/generated/koKioBff.d.ts +++ b/utils/bff/generated/koKioBff.d.ts @@ -121,7 +121,95 @@ export interface paths { get: operations["accountGet"]; put?: never; post?: never; - delete?: never; + /** + * Delete account + * @description Permanently deletes the authenticated device's account. **THIS IS IRREVERSIBLE.** + * + * Returns `204 No Content` on success. This is the only endpoint in the API that + * returns no response body — there is nothing to return, and a `204` carries no + * payload by definition. Clients must not attempt to parse JSON from this response. + * + * **Authentication:** Requires DPoP-constrained JWT (`requireAuth`) **and step-up** + * (`requireStepUp`). Deletion is the only irreversible destructive action in the + * product, so a valid access token alone is deliberately insufficient — a fresh + * passkey assertion is required. A stale token yields `STEP_UP_REQUIRED` (401); + * complete the step-up ceremony on the Auth Server and retry. + * + * **Rate limiting:** Subject to the global IP limiter and the per-device limiter + * (10 requests per minute per `deviceWalletAddress`). + * + * --- + * + * ### What deletion does + * + * Sets a deletion flag and timestamp on the Account document. From that moment the + * `requireAuth` account gate refuses **every** authenticated request from this device + * with `ACCOUNT_DELETED` (404) — including a repeat call to this endpoint. Any access + * token issued before deletion becomes inert. + * + * ### What deletion deliberately does NOT do, and why + * + * This is a **soft delete**. That is a considered design decision, not an incomplete + * implementation. The rationale: + * + * - **The system holds no personally identifiable information.** By design, users + * purchase eSIMs without providing PII. The canonical identity — `deviceWalletAddress` + * — is an EVM address derived deterministically from a passkey's P-256 public key + * coordinates. It is pseudonymous, and there is **no re-identification path**: without + * the passkey assertion, no record in this system can be bound to a person, including + * by the operator. Deleting the identity linkage would therefore remove nothing that + * identifies anyone. + * + * - **Order, eSIM, and payment records are retained** for financial and audit compliance. + * Their `deviceId` linkage *is* the audit trail; severing it would defeat the retention + * obligation while removing no identifying information. + * + * - **The payment processor holds no identifying customer data** configured by this + * service. The Stripe customer object is created without name, email, or address + * fields. Payment instrument data is processed under the processor's own + * controllership and is not available to this service. + * + * - **Provisioned eSIMs remain usable until expiry.** Deleting an account does **NOT** + * cancel eSIMs the user has paid for. An installed eSIM continues to work on the + * device until its plan expires — the user simply loses access to its details through + * this API. Nothing is revoked at the eSIM vendor. + * + * - **On-chain transaction records are immutable** and cannot be deleted. They are + * pseudonymous (a wallet address, no personal data) and persist on the ledger by + * design. + * + * ### Auth Server behaviour — important for clients + * + * The Auth Server is **intentionally not informed** of account deletion. The passkey + * remains registered and will continue to authenticate successfully: `login/begin` and + * `login/complete` will succeed and mint valid access tokens. + * + * **Those tokens buy no access.** Every authenticated endpoint on this service refuses + * them with `ACCOUNT_DELETED` (404). Authentication succeeds; authorisation does not. + * + * This means a user who deletes their account but keeps their passkey will experience a + * successful login followed by `ACCOUNT_DELETED` on the first authenticated call. + * + * ### Required client behaviour after deletion + * + * Clients **must** handle `ACCOUNT_DELETED` (404) as a distinct terminal state on **any** + * authenticated endpoint, not only on this one. On receiving it: + * + * 1. **Do not retry** and do not attempt a token refresh — the token is valid; the account + * is gone. Refreshing will succeed and change nothing. + * 2. **Discard all stored tokens** and clear local session state. + * 3. **Prompt the user to delete their passkey** from their device (iOS Settings → + * Passwords, or Google Password Manager). This is the terminal step the user must + * perform themselves — this service cannot remove a passkey from the user's device, + * and the Auth Server will keep authenticating it until they do. + * 4. **Return the user to the unauthenticated / registration entry point.** + * + * Registering a **new** passkey produces a different key pair, hence a different + * `deviceWalletAddress`, hence a genuinely new account. Re-registering the **same** + * passkey resolves to the same (deleted) `deviceWalletAddress` and will not restore + * access. + */ + delete: operations["accountDelete"]; options?: never; head?: never; patch?: never; @@ -397,9 +485,9 @@ export interface paths { * * **Authentication:** Requires DPoP-constrained JWT (`requireAuth`). * - * **Filtering:** Pass `activeOnly=true` to return only eSIMs with - * `activationStatus: ACTIVE`. When omitted or any other value, - * all eSIMs for the device are returned regardless of activation state. + * **Filtering:** Pass `activeOnly=true` to return only usable eSIMs + * (`activationStatus` of `RELEASED` or `INSTALLED`). When omitted or any other + * value, all eSIMs for the device are returned regardless of activation state. * * **Rate limiting:** Subject to both the global IP limiter and the per-device limiter * (10 requests per minute per `deviceWalletAddress`). @@ -487,6 +575,43 @@ export interface paths { patch?: never; trace?: never; }; + "/esim/usage/{esimId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get eSIM usage + * @description Returns remaining usage allowance for the authenticated device's eSIMs. + * + * - With `esimId` path param → usage for that single eSIM (device ownership enforced). + * - Without `esimId` → usage for all **non-terminal** eSIMs of the device + * (`activationStatus` of `RELEASED` or `INSTALLED`). Terminal eSIMs + * (`UNAVAILABLE`, `DEACTIVATED`) are excluded. + * + * Usage is fetched best-effort from upstream vendors and cached briefly + * (15 min per eSIM). Data allowances are in **GB**; `voice` in **minutes**, + * `sms` as a **count**. For unlimited plans, `isUnlimited` is `true` and the + * numeric `remaining`/`total` are `null` (unless a capped topup is also active, + * in which case the capped figures are returned alongside `isUnlimited: true`). + * + * A per-eSIM `usageError` message is returned in that entry if its usage could + * not be computed; other eSIMs are unaffected. + * + * **Authentication:** Requires DPoP-constrained JWT (`requireAuth`). + * **Rate limiting:** Global IP limiter + per-device limiter (10 req/min per `deviceWalletAddress`). + */ + get: operations["esimGetUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/coupon": { parameters: { query?: never; @@ -642,6 +767,7 @@ export interface paths { * | `cleanup_abandoned_orders` | 24 hours | Voids Stripe invoices and marks stuck orders as `ABANDONED` | * | `retry_vendor_fulfilment` | 3 minutes | Retries transient vendor failures (`VENDOR_RETRY_PENDING`) | * | `retry_onchain_recording` | 15 minutes | Retries on-chain recording (`ESIM_PROVISIONED_PENDING_CHAIN`) | + * | `sync_esim_status` | 4 hours | Syncs eSIM profile status (activationStatus) and per-bundle status from upstream vendors, transitions UNAVAILABLE eSIMs to DEACTIVATED after a 180-day grace window | */ post: operations["adminJobsRun"]; delete?: never; @@ -967,6 +1093,8 @@ export interface components { * |------|--------|---------| * | `VENDOR_ORDER_FAILED` | 502 | Upstream vendor order API call failed | * | `VENDOR_PLANS_FAILED` | 502 | Upstream vendor plans API call failed during catalogue refresh | + * | `VENDOR_GET_STATUS_FAILED` | 502 | Upstream vendor could not fetch the status of requested eSIM | + * | `VENDOR_GET_USAGE_FAILED` | 502 | Upstream vendor could not fetch the usage status of the requested eSIM | * * **Admin errors** * @@ -1188,7 +1316,7 @@ export interface components { /** * @description Server-generated salt persisted on the account, used by the client SDK as * part of deterministic smart-account derivation. - * @example 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + * @example 0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 */ salt: string; /** @@ -1294,7 +1422,7 @@ export interface components { * @example LOCAL * @enum {string} */ - coverageType: "LOCAL" | "REGIONAL" | "GLOBAL"; + coverageType: "LOCAL" | "REGIONAL" | "GLOBAL" | "CUSTOM_REGIONAL"; /** * @description Type of connectivity provided. * @example DATA @@ -2000,11 +2128,17 @@ export interface components { */ planHistory: components["schemas"]["PlanHistoryEntry"][]; /** - * @description Current activation lifecycle state of the eSIM. - * @example ACTIVE + * @description eSIM-level profile/installation status — a normalised projection of the + * upstream vendor's profile state, synced periodically (best-effort). + * Distinct from per-plan bundle usage status carried on `planHistory` entries. + * - `RELEASED` — provisioned, not yet installed. + * - `INSTALLED` — installed and used at least once; usable. + * - `UNAVAILABLE` — cannot be installed/used; reissue recommended. + * - `DEACTIVATED` — no longer exists on the vendor side; dead. + * @example INSTALLED * @enum {string} */ - activationStatus: "PENDING" | "ACTIVE" | "EXPIRED" | "FAILED" | "CANCELED" | "SUSPENDED" | "REVOKED"; + activationStatus: "RELEASED" | "INSTALLED" | "UNAVAILABLE" | "DEACTIVATED"; /** * @description eSIM installation data for device activation. * Present on new eSIM purchases that have been provisioned. @@ -2056,6 +2190,66 @@ export interface components { * @example 2026-04-15T12:30:00Z */ purchaseDate: string; + /** + * @description Data allowance in GB, snapshotted from the catalogue plan at fulfilment time. + * `null` for unlimited plans (`isUnlimited: true`). + * @example 1 + */ + data?: number | null; + /** + * @description SMS allowance, snapshotted from the catalogue plan at fulfilment time. + * `null` when not applicable for this plan type. + * @example null + */ + sms?: number | null; + /** + * @description Voice allowance in minutes, snapshotted from the catalogue plan at fulfilment time. + * `null` when not applicable for this plan type. + * @example null + */ + voice?: number | null; + /** + * @description Human-readable display name of the service region, snapshotted from the + * catalogue plan at fulfilment time. + * @example United Kingdom + */ + serviceRegionName?: string | null; + /** + * @description CDN URL for the service region flag, snapshotted from the catalogue plan at + * fulfilment time. Non-null for `LOCAL` coverage only. + * @example https://flagcdn.com/w320/gb.png + */ + serviceRegionFlag?: string | null; + /** + * @description `true` if this was an unlimited data plan. Snapshotted from the catalogue + * plan at fulfilment time. + * @example false + */ + isUnlimited: boolean; + /** + * @description Coverage scope, snapshotted from the catalogue plan at fulfilment time. + * @example LOCAL + * @enum {string} + */ + coverageType: "LOCAL" | "REGIONAL" | "GLOBAL" | "CUSTOM_REGIONAL"; + /** + * @description Stable per-bundle identifier from the owning vendor, used to correlate this + * entry with the vendor's bundle records during status sync. + * @example 728 + */ + vendorBundleRef?: string | null; + /** + * @description Usage-lifecycle status of this specific bundle, synced from the vendor + * (best-effort). Distinct from the eSIM-level `activationStatus`. + * - `QUEUED` — assigned, not yet started/used. + * - `ACTIVE` — in use, data remaining, within duration. + * - `FINISHED` — data depleted, still within duration. + * - `EXPIRED` — duration exceeded (used, or unused/lapsed). + * - `UNKNOWN` — revoked or indeterminate vendor state. + * @example ACTIVE + * @enum {string} + */ + bundleStatus: "QUEUED" | "ACTIVE" | "FINISHED" | "EXPIRED" | "UNKNOWN"; }; ESimListResponse: { /** @@ -2063,10 +2257,38 @@ export interface components { * Empty array when no eSIMs exist for the device. * * When `activeOnly=true` is provided, only eSIMs with - * `activationStatus: ACTIVE` are included. + * `activationStatus: RELEASED | INSTALLED` are included. */ eSims: components["schemas"]["ESimDocument"][]; }; + /** @description Remaining usage allowance for a single eSIM. */ + ESimUsage: { + esimId: string; + iccid: string; + /** @description Remaining data allowance in GB. Null for unlimited-only active plans. */ + remaining: number | null; + /** @description Initial data allowance in GB. Null for unlimited-only active plans. */ + total: number | null; + /** @description Remaining voice allowance in minutes. Null if not applicable. */ + voice: number | null; + /** @description Remaining SMS allowance (count). Null if not applicable. */ + sms: number | null; + /** @description True if at least one active bundle is unlimited. */ + isUnlimited: boolean; + /** @description Furthest-future expiry among active bundles (vendor timestamp). Null if none active. */ + expiresAt: string | null; + /** @description Per-eSIM usage-computation error message, or null on success. */ + usageError: string | null; + }; + ESimUsageResponse: { + /** + * @description Per-eSIM remaining usage allowance. + * When `esimId` is provided, contains exactly one entry. + * When omitted, contains one entry per non-terminal (RELEASED | INSTALLED) eSIM. + * Empty array when the device has no non-terminal eSIMs. + */ + usage: components["schemas"]["ESimUsage"][]; + }; IssuedFromTx: { /** * @description On-chain transaction hash of the vault transfer that triggered coupon issuance. @@ -2355,10 +2577,11 @@ export interface components { * | `cleanup_abandoned_orders` | 24 hours | Voids/deletes Stripe invoices and marks orders stuck in `CREATED` or `PAYMENT_PENDING` beyond the 24-hour TTL as `ABANDONED`. Deletes `ABANDONED` orders older than 30 days | * | `retry_vendor_fulfilment` | 3 minutes | Retries orders at `VENDOR_RETRY_PENDING` (transient vendor failures). Max 2 retries before `VENDOR_FAILED` | * | `retry_onchain_recording` | 15 minutes | Retries `buildBuyDataBundleTxn` for orders at `ESIM_PROVISIONED_PENDING_CHAIN`. Max 2 retries before `COMPLETED` + `flaggedForManualReview` | + * | `sync_esim_status` | 4 hours | Syncs eSIM profile status (activationStatus) and per-bundle status from upstream vendors, transitions UNAVAILABLE eSIMs to DEACTIVATED after a 180-day grace window | * @example refresh_catalogue * @enum {string} */ - job: "refresh_vendor1_bearer" | "refresh_catalogue" | "cleanup_abandoned_orders" | "retry_vendor_fulfilment" | "retry_onchain_recording"; + job: "refresh_vendor1_bearer" | "refresh_catalogue" | "cleanup_abandoned_orders" | "retry_vendor_fulfilment" | "retry_onchain_recording" | "sync_esim_status"; }; /** * @description Job execution result. Shape varies by job type. @@ -2567,6 +2790,43 @@ export interface components { "application/json": components["schemas"]["ErrorResponse"]; }; }; + AccountDeletedResponse: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** + * @description The authenticated device's account has been deleted. + * + * The presented access token is valid — the Auth Server is intentionally unaware of + * account deletion and will keep authenticating the passkey. Authorisation is refused + * here instead. Do not retry and do not refresh the token. + * + * **Required client handling:** discard stored tokens, clear session state, prompt the + * user to remove their passkey from the device, and return to the unauthenticated entry + * point. See `DELETE /v1/account` for the full rationale. + * + * | Code | Meaning | + * |------|---------| + * | `ACCOUNT_DELETED` | The account was deleted via `DELETE /account` | + */ + AccountDeletedError: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "success": false, + * "code": "ACCOUNT_DELETED", + * "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "message": "This account has been deleted. Please remove the associated passkey from your device." + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; }; parameters: { /** @@ -2874,6 +3134,120 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + 404: components["responses"]["AccountDeletedError"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + accountDelete: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated request correlation identifier. + * + * Propagated through all log entries produced during the handling of an individual request. + * Echoed back in the `correlationId` field of the response envelope. + * + * Use a UUID v4 per request. + * @example a1b2c3d4-e5f6-7890-abcd-ef1234567890 + */ + "x-correlation-id": components["parameters"]["CorrelationId"]; + /** + * @description DPoP proof JWT per RFC 9449. + * + * A compact serialised JWT with: + * + * **Header** + * - `typ`: `dpop+jwt` + * - `alg`: `ES256` + * - `jwk`: client's P-256 public key in JWK format (MUST not contain private key material) + * + * **Payload** + * - `jti`: unique proof identifier (UUID v4) — single-use, replay prevented + * - `htm`: HTTP method of this request (e.g. `POST`, `GET`) — case-insensitive match + * - `htu`: full request URI without query string or fragment + * - `iat`: Unix timestamp (seconds) — must be within ±60 seconds of server time + * - `ath`: `BASE64URL(SHA256())` — binds the proof to the specific token + * + * **Signed** with the client's ES256/P-256 DPoP private key. + * + * Generate a fresh proof for every request as the `jti` and `ath` claims + * make each proof request-specific and non-reusable. + * @example eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiLi4uIiwieSI6Ii4uLiJ9fQ.eyJqdGkiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkucGxhY2Vob2xkZXIuYXBwL3YxL29yZGVyIiwiaWF0IjoxNzQ1MDY0MDAwLCJhdGgiOiJCQVNFNjRVUkxfT0ZfU0hBMjU2X0hBU0gifQ.signature + */ + DPoP: components["parameters"]["DPoP"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** + * @description Account deleted. **No response body.** + * + * The account is now flagged deleted. All subsequent authenticated requests from + * this device — including a repeat `DELETE /v1/account` — will return + * `404 ACCOUNT_DELETED`. + */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** + * @description Authentication, DPoP validation, or step-up recency check failed. + * + * | Code | Meaning | + * |------|---------| + * | `UNAUTHORIZED` | Access token missing, malformed, or signature invalid | + * | `TOKEN_EXPIRED` | Access token has expired — refresh via Auth Server | + * | `STEP_UP_REQUIRED` | Operation requires recent authentication — complete the step-up ceremony and retry | + * | `DPOP_PROOF_MISSING` | `DPoP` header is absent | + * | `DPOP_PROOF_MALFORMED` | `DPoP` proof structure or header fields are invalid | + * | `DPOP_PROOF_SIGNATURE_INVALID` | `DPoP` proof signature verification failed | + * | `DPOP_PROOF_BINDING_INVALID` | `DPoP` proof `htm`, `htu`, or `ath` binding mismatch | + * | `DPOP_PROOF_STALE` | `DPoP` proof `iat` is outside the ±60-second freshness window | + * | `DPOP_PROOF_REPLAYED` | `DPoP` proof `jti` has already been used | + * | `DPOP_PROOF_KEY_MISMATCH` | `DPoP` proof key does not match the `cnf.jkt` claim | + */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** + * @description The account has already been deleted. + * + * Returned when this endpoint is called for an account that is already flagged + * deleted — the `requireAuth` gate refuses the request before the handler runs. + * The operation is therefore **not HTTP-idempotent**: a second call returns `404` + * rather than repeating the `204`. The *effect* is idempotent — the account remains + * deleted and the original deletion timestamp is never overwritten. + * + * | Code | Meaning | + * |------|---------| + * | `ACCOUNT_DELETED` | This account has been deleted — see the required client behaviour above | + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "success": false, + * "code": "ACCOUNT_DELETED", + * "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "message": "This account has been deleted. Please remove the associated passkey from your device." + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; 500: components["responses"]["InternalServerError"]; }; }; @@ -3322,6 +3696,7 @@ export interface operations { * |------|---------| * | `NOT_FOUND` | `catalogueId` does not match an active catalogue entry | * | `NO_EQUIVALENT_TOPUP_PLAN` | No active TOPUP plan equivalent exists for the submitted SIM-type `catalogueId` | + * | `ACCOUNT_DELETED` | The account was deleted via `DELETE /account` | */ 404: { headers: { @@ -3482,6 +3857,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -3552,6 +3928,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -3559,7 +3936,8 @@ export interface operations { parameters: { query?: { /** - * @description When set to `"true"`, filters results to eSIMs with `activationStatus: ACTIVE` only. + * @description When set to `"true"`, filters results to usable eSIMs only — + * those with `activationStatus` of `RELEASED` or `INSTALLED`. * Any other value or omission returns all eSIMs for the device. * @example true */ @@ -3618,6 +3996,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -3696,16 +4075,30 @@ export interface operations { * "orderId": "664f1a2b3c4d5e6f7a8b9c0e", * "planId": "1GB_EU_30D", * "validity": 30, - * "purchaseDate": "2026-04-15T12:30:00Z" + * "purchaseDate": "2026-04-15T12:30:00Z", + * "data": 1, + * "serviceRegionName": "Europe", + * "serviceRegionFlag": null, + * "isUnlimited": false, + * "coverageType": "REGIONAL", + * "vendorBundleRef": "728", + * "bundleStatus": "FINISHED" * }, * { * "orderId": "664f1a2b3c4d5e6f7a8b9c0f", * "planId": "1GB_EU_30D_TOPUP", * "validity": 30, - * "purchaseDate": "2026-05-10T09:00:00Z" + * "purchaseDate": "2026-05-10T09:00:00Z", + * "data": 1, + * "serviceRegionName": "Europe", + * "serviceRegionFlag": null, + * "isUnlimited": false, + * "coverageType": "REGIONAL", + * "vendorBundleRef": "731", + * "bundleStatus": "ACTIVE" * } * ], - * "activationStatus": "ACTIVE", + * "activationStatus": "INSTALLED", * "installationDetails": { * "qrcode": "LPA:1$rsp.truphone.com$QR-G-5C-12345-ABCDE", * "appleInstallationUrl": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$rsp.truphone.com$QR-G-5C-12345-ABCDE" @@ -3746,11 +4139,12 @@ export interface operations { }; 401: components["responses"]["DPoPAuthError"]; /** - * @description No eSIM exists for the provided wallet address. + * @description No eSIM exists for the provided wallet address, or user account has been deleted. * * | Code | Meaning | * |------|---------| * | `NOT_FOUND` | No eSIM record found for the given `esimId` | + * | `ACCOUNT_DELETED` | The account was deleted via `DELETE /account` | */ 404: { headers: { @@ -3884,6 +4278,7 @@ export interface operations { * | `NOT_FOUND` | `planId` does not match an active catalogue entry | * | `NO_EQUIVALENT_TOPUP_PLAN` | No active TOPUP equivalent exists for the submitted plan | * | `NO_ACTIVE_ESIMS_FOR_DEVICE` | The authenticated device has no active eSIMs | + * | `ACCOUNT_DELETED` | The account was deleted via `DELETE /account` | */ 404: { headers: { @@ -3904,6 +4299,118 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; + esimGetUsage: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated request correlation identifier. + * + * Propagated through all log entries produced during the handling of an individual request. + * Echoed back in the `correlationId` field of the response envelope. + * + * Use a UUID v4 per request. + * @example a1b2c3d4-e5f6-7890-abcd-ef1234567890 + */ + "x-correlation-id": components["parameters"]["CorrelationId"]; + /** + * @description DPoP proof JWT per RFC 9449. + * + * A compact serialised JWT with: + * + * **Header** + * - `typ`: `dpop+jwt` + * - `alg`: `ES256` + * - `jwk`: client's P-256 public key in JWK format (MUST not contain private key material) + * + * **Payload** + * - `jti`: unique proof identifier (UUID v4) — single-use, replay prevented + * - `htm`: HTTP method of this request (e.g. `POST`, `GET`) — case-insensitive match + * - `htu`: full request URI without query string or fragment + * - `iat`: Unix timestamp (seconds) — must be within ±60 seconds of server time + * - `ath`: `BASE64URL(SHA256())` — binds the proof to the specific token + * + * **Signed** with the client's ES256/P-256 DPoP private key. + * + * Generate a fresh proof for every request as the `jti` and `ath` claims + * make each proof request-specific and non-reusable. + * @example eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiLi4uIiwieSI6Ii4uLiJ9fQ.eyJqdGkiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkucGxhY2Vob2xkZXIuYXBwL3YxL29yZGVyIiwiaWF0IjoxNzQ1MDY0MDAwLCJhdGgiOiJCQVNFNjRVUkxfT0ZfU0hBMjU2X0hBU0gifQ.signature + */ + DPoP: components["parameters"]["DPoP"]; + }; + path: { + /** + * @description Optional eSIM wallet address. If provided, returns usage for that eSIM only; + * if omitted, returns usage for all non-terminal eSIMs of the device. + * @example 0xdef456abc123def456abc123def456abc123def4 + */ + esimId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Usage list returned. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "success": true, + * "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "message": "Success", + * "data": { + * "usage": [ + * { + * "esimId": "0xdef456abc123def456abc123def456abc123def4", + * "iccid": "8944110068000000001", + * "remaining": 2.38, + * "total": 3, + * "voice": null, + * "sms": null, + * "isUnlimited": false, + * "expiresAt": "2026-05-22T06:28:04.250178Z", + * "usageError": null + * } + * ] + * } + * } + */ + "application/json": components["schemas"]["SuccessResponse"] & { + data?: components["schemas"]["ESimUsageResponse"]; + }; + }; + }; + /** + * @description The provided `esimId` belongs to a different device. + * + * | Code | Meaning | + * |------|---------| + * | `ESIM_NOT_FOUND_FOR_DEVICE` | eSIM exists but is not associated with the authenticated device | + */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "success": false, + * "code": "ESIM_NOT_FOUND_FOR_DEVICE", + * "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "message": "No eSIM found for esimId 0xdef4... associated with device 0xabc1..." + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; + 500: components["responses"]["InternalServerError"]; + }; + }; couponIssue: { parameters: { query?: never; diff --git a/utils/bff/order.ts b/utils/bff/order.ts index f066a67..fd40037 100644 --- a/utils/bff/order.ts +++ b/utils/bff/order.ts @@ -1,7 +1,8 @@ +import { BffError , unwrapBffResponse, unwrapBffResponseWithCorrelation } from './koKioBffClient'; import type { components } from './generated/koKioBff'; -import { unwrapBffResponse, unwrapBffResponseWithCorrelation } from './koKioBffClient'; import api from '@/services/httpService'; import { v4 as uuidv4 } from 'uuid'; +import { logger } from '@/utils/logger'; type CreateOrderRequest = components['schemas']['CreateOrderRequest']; type CreateOrderResponse = components['schemas']['CreateOrderResponse']; @@ -13,79 +14,20 @@ export type InstallationDetails = components['schemas']['InstallationDetails']; export type { CreateOrderRequest, CreateOrderResponse, OrderStatusResponse, OrderListItem, OrderListResponse }; - -// ─── Extended response types ────────────────────────────────────────────────── - -export type FiatOrderResponse = CreateOrderResponse & { - stripeInvoiceId: string; - clientSecret: string; -}; - -export type ExternalWalletOrderResponse = CreateOrderResponse & { - moonpayChargeId: string; - moonpayPaymentPageUrl: string; -}; - -// ─── Request types ──────────────────────────────────────────────────────────── - -type FiatOrderRequest = { - catalogueId: string; - currency: 'USD'; - isNewESim: boolean; - esimId?: string; - coupon?: string; - isCryptoPayment: false; -}; - -type ExternalWalletOrderRequest = { - catalogueId: string; - currency: 'USD'; - isNewESim: boolean; - esimId?: string; - coupon?: string; - isCryptoPayment: true; - payeeAddress?: string; - successRedirectUrl?: string; -}; - // ─── Order functions ────────────────────────────────────────────────────────── -export function createOrder(body: CreateOrderRequest): Promise { - return unwrapBffResponse(api.post('/v1/order', body as Record)); -} - -export function createCryptoOrder( - body: CreateOrderRequest, +// Single order-creation call for every payment method. +export function submitOrder( + request: CreateOrderRequest, ): Promise<{ data: CreateOrderResponse; correlationId: string }> { const idempotencyKey = uuidv4(); return unwrapBffResponseWithCorrelation( - api.post('/v1/order', body as Record, { - headers: { 'x-correlation-id': idempotencyKey }, - }), - ).then((r) => ({ ...r, correlationId: idempotencyKey })); -} - -export function createFiatOrder( - body: FiatOrderRequest, -): Promise<{ data: FiatOrderResponse; correlationId: string | null }> { - const idempotencyKey = uuidv4(); - return unwrapBffResponseWithCorrelation( - api.post('/v1/order', body as Record, { - headers: { 'x-correlation-id': idempotencyKey }, - }), - ).then((r) => ({ ...r, correlationId: idempotencyKey })); -} - -export function createExternalWalletOrder( - body: ExternalWalletOrderRequest, -): Promise<{ data: ExternalWalletOrderResponse; correlationId: string | null }> { - const idempotencyKey = uuidv4(); - return unwrapBffResponseWithCorrelation( - api.post('/v1/order', body as Record, { + api.post('/v1/order', request as Record, { headers: { 'x-correlation-id': idempotencyKey }, }), ).then((r) => ({ ...r, correlationId: idempotencyKey })); } + export function getOrderStatus(idempotencyKey: string): Promise { return unwrapBffResponse(api.get(`/v1/order/${idempotencyKey}`)); @@ -114,18 +56,78 @@ export function isOrderSuccess(status: string): boolean { return status === 'COMPLETED' || status === 'ESIM_PROVISIONED_PENDING_CHAIN'; } +/** + * Thrown when GET /order/{idempotencyKey} returns "no order found" shape, i.e. + * HTTP 200, success envelope, all data fields null and the message containing ORDER_NOT_FOUND. + * Per spec this means no order exists for this idempotency key + device. + * It should only occur transiently, if at all, immediately after creation. + * Not retried. + */ +export class OrderNotFoundError extends Error { + constructor() { + super('No order found for this correlation ID'); + this.name = 'OrderNotFoundError'; + } +} + +export type PollUpdate = + | { kind: 'status'; orderStatus: string } + | { kind: 'retrying'; attempt: number; waitMs: number }; + +export type PollOrderStatusOptions = { + // Steady-state cadence between polls. Default 4000ms. + intervalMs?: number; + // Total budget, including any 429 backoff waits. Default 30000ms. + maxDurationMs?: number; + onUpdate?: (update: PollUpdate) => void; +}; + +const DEFAULT_POLL_INTERVAL_MS = 4000; +const DEFAULT_POLL_MAX_DURATION_MS = 30000; +const BACKOFF_CAP_MS = 8000; + export async function pollOrderStatus( idempotencyKey: string, - maxAttempts = 15, - intervalMs = 2000, - onStatusUpdate?: (orderStatus: string) => void, + options: PollOrderStatusOptions = {}, ): Promise { - for (let i = 0; i < maxAttempts; i++) { - if (i > 0) await new Promise(r => setTimeout(r, intervalMs)); - const status = await getOrderStatus(idempotencyKey); - if (__DEV__) console.log(`[eSIM] poll #${i + 1}:`, JSON.stringify(status, null, 2)); - onStatusUpdate?.(status.orderStatus); + const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxDurationMs = options.maxDurationMs ?? DEFAULT_POLL_MAX_DURATION_MS; + const onUpdate = options.onUpdate; + + const deadline = Date.now() + maxDurationMs; + let backoffMs = intervalMs; + let retryAttempt = 0; + + while (Date.now() < deadline) { + let status: OrderStatusResponse; + try { + status = await getOrderStatus(idempotencyKey); + } catch (err) { + // Throttled and capped exponential backoff. + if (!(err instanceof BffError) || err.httpStatus !== 429) throw err; + + retryAttempt += 1; + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + const waitMs = Math.min(backoffMs, remaining); + onUpdate?.({ kind: 'retrying', attempt: retryAttempt, waitMs }); + await new Promise((r) => setTimeout(r, waitMs)); + backoffMs = Math.min(backoffMs * 2, BACKOFF_CAP_MS); + continue; + } + + logger.debug('ESIM_POLL_STATUS', { status }); + + if (!status.orderId) throw new OrderNotFoundError(); if (TERMINAL_ORDER_STATUSES.has(status.orderStatus)) return status; + + onUpdate?.({ kind: 'status', orderStatus: status.orderStatus }); + backoffMs = intervalMs; // reset after any successful, non-throttled call + + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining))); } + throw new Error('Order confirmation timed out'); } diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..1ac06b6 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,84 @@ +/** + * The single logging seam for the app. It replaces every direct `console.*` + * call site (enforced by scripts/scan-console.mjs). There is exactly one + * __DEV__ gate and one place where release redaction is decided. + * + * Behaviour: + * Development -> prints `[code]` (+ context) to the console at the mapped level. + * Also retains a redacted record. + * Release -> prints nothing; retains a redacted `{ ts, level, code }` + * record in a bounded in-memory ring buffer. + * This is the local diagnostic record. + */ + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + ts: number; // Epoch milliseconds at capture time. + level: LogLevel; + code: string; // Static, non-identifying event code. +} + +// ─── Bounded in-memory record ───────────────────────────────────────────────── + +/** + * FIFO ring buffer. + * Non-identifying by construction (only ts/level/code are stored), + * It is safe to retain in release for a future support/diagnostics surface to read via getRecentEntries(). + */ +const MAX_ENTRIES = 100; +const _entries: LogEntry[] = []; + +function record(level: LogLevel, code: string): void { + _entries.push({ ts: Date.now(), level, code }); + if (_entries.length > MAX_ENTRIES) { + _entries.splice(0, _entries.length - MAX_ENTRIES); + } +} + +// Snapshot of retained records, oldest first. Safe to expose to a UI surface. +export function getRecentEntries(): readonly LogEntry[] { + return _entries.slice(); +} + +// Clears retained records. +export function clearRecentEntries(): void { + _entries.length = 0; +} + +// ─── Level -> console method (development only) ───────────────────────────────── + +// Names are resolved to a `console` method at call time. +const _consoleMethod: Record = { + debug: 'log', + info: 'info', + warn: 'warn', + error: 'error', +}; + +// ─── Core ────────────────────────────────────────────────────────────────────── + +function emit(level: LogLevel, code: string, context?: unknown): void { + if (__DEV__) { + const method = _consoleMethod[level]; + if (context === undefined) { + console[method](`[${code}]`); + } else { + console[method](`[${code}]`, context); + } + } + // Always retain the redacted record. `context` is intentionally never stored, + // so nothing dynamic or identifying can reach the release record. + record(level, code); +} + +// ─── Public API ──────────────────────────────────────────────────────────────── + +export const logger = { + debug: (code: string, context?: unknown): void => emit('debug', code, context), + info: (code: string, context?: unknown): void => emit('info', code, context), + warn: (code: string, context?: unknown): void => emit('warn', code, context), + error: (code: string, context?: unknown): void => emit('error', code, context), +}; diff --git a/utils/nativeRuntimeSetup.web.ts b/utils/nativeRuntimeSetup.web.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/utils/nativeRuntimeSetup.web.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/utils/wallet/checkWalletDeployed.ts b/utils/wallet/checkWalletDeployed.ts new file mode 100644 index 0000000..07ddbfa --- /dev/null +++ b/utils/wallet/checkWalletDeployed.ts @@ -0,0 +1,68 @@ +/** + * Checks whether a device smart contract wallet is deployed and registered. + * Used by kokioProvider to distinguish a genuinely recovered account + * from a new registration that hasn't been deployed yet, + * so auto-derivation of the SmartContractAccount is only triggered for the former. + * + * NOTE: + * The Registry ABI fragment is inlined rather than imported from the SDK package + * because kokio-sdk's package.json#exports does not expose an ./abis path. + * The ABI fragment below is sourced from src/abis/Registry.ts in the SDK repo. + */ + +import { + createPublicClient, + http, + type WalletClient, + type Address, +} from 'viem'; +import { Registry } from 'kokio-sdk/abis'; +import { logger } from '@/utils/logger'; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Returns true when `deviceWalletAddress` is registered in the on-chain Registry contract. + * + * Returns false when: + * - The wallet has not been deployed yet (new registration path). + * - The registry address is not yet deployed for this chain ('0x'). + * - The RPC call fails for any reason. + * + * @param deviceWalletAddress The 0x-prefixed contract address to check. + * @param viemWalletClient The already-constructed WalletClient from + * kokio.sdk.viemWalletClient. + * Carries the correct chain and transport. + * @param registryAddress The Registry contract address for the active chain, + * read from kokio.sdk.constants.factoryAddresses.REGISTRY. + */ +export async function checkWalletDeployed( + deviceWalletAddress: string, + viemWalletClient: WalletClient, + registryAddress: Address, +): Promise { + try { + const chain = viemWalletClient.chain; + const transportUrl = (viemWalletClient.transport as { url?: string }).url; + + const publicClient = createPublicClient({ + chain, + transport: http(transportUrl), + }); + + const isValid = await publicClient.readContract({ + address: registryAddress, + abi: Registry, + functionName: 'isDeviceWalletValid', + args: [deviceWalletAddress as Address], + }) as boolean; + + logger.debug('WALLET_DEPLOY_CHECK', { deployed: isValid }); + return isValid; + } catch (err) { + // Fail-safe: RPC error. + // The user can still deploy manually via WalletSetupModal. + logger.error('WALLET_DEPLOY_CHECK_FAILED', { err }); + return false; + } +}