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;
+ }
+}