From c715215eec8d68c9aa3cfef27e25e734ce8f2468 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 2 Jul 2026 19:33:53 +0530 Subject: [PATCH 01/54] add codeowners for automatic review requests on default branch --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/CODEOWNERS 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 From 7d5e65e3b7663ed9dc045dfd2d4d3b0ff480f2b5 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 2 Jul 2026 19:34:40 +0530 Subject: [PATCH 02/54] add dependabot config to auto-update dependencies --- .github/dependabot.yml | 107 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/dependabot.yml 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" From 1b98ee4138a897a8927f2fc9738614190d34673a Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 2 Jul 2026 19:34:50 +0530 Subject: [PATCH 03/54] add pull request template --- .github/pull_request_template.md | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/pull_request_template.md 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 + + From 83849f66b2912d5652aa101af87a7ac36fee66d6 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 2 Jul 2026 23:02:03 +0530 Subject: [PATCH 04/54] remove deprecated fields from order processing pipeline --- helpers/esimOrder.ts | 1 - screens/checkout/Checkout.tsx | 16 +++------------- utils/bff/order.ts | 4 ---- 3 files changed, 3 insertions(+), 18 deletions(-) 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/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 085e89d..d2951cf 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -42,7 +42,6 @@ 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 * as WebBrowser from "expo-web-browser"; import { @@ -435,8 +434,6 @@ const Checkout = () => { try { setIsCheckoutLoading(true); - const deviceWalletId = kokio.userWallet?.address || ""; - if (applyAsTopup && compatibleTopUpEsimId) { await createTopupOrder.mutateAsync({ request: { @@ -454,12 +451,11 @@ const Checkout = () => { const payload = getEsimOrderPayload({ eSimItem, - deviceWalletId, discountCode, applyAsTopup, compatibleTopUpEsimId }); - const esimBody = { ...payload, payeeAddress: deviceWalletId }; + const esimBody = payload; if (__DEV__) console.log('[Order] eSIM wallet body:', JSON.stringify(esimBody, null, 2)); const { correlationId } = await createCryptoOrder(esimBody); if (kokio.deviceUID && correlationId) { @@ -488,7 +484,6 @@ const Checkout = () => { }, [ eSimItem, discountCode, - kokio?.userWallet, kokio?.deviceUID, applyAsTopup, compatibleTopUpEsimId, @@ -568,10 +563,9 @@ const Checkout = () => { setLoadingMessage('Preparing your payment...'); let fiatCorrelationId: string | null = null; try { - const base = getEsimOrderPayload({ eSimItem, deviceWalletId: "", discountCode, applyAsTopup, compatibleTopUpEsimId }); + const base = getEsimOrderPayload({ eSimItem, discountCode, applyAsTopup, compatibleTopUpEsimId }); const fiatBody = { catalogueId: base.catalogueId, - currency: "USD" as const, isNewESim: base.isNewESim, esimId: base.esimId, coupon: base.coupon, @@ -649,16 +643,13 @@ const Checkout = () => { setLoadingMessage('Preparing your payment...'); let extCorrelationId: string | null = null; try { - const base = getEsimOrderPayload({ eSimItem, deviceWalletId: "", discountCode, applyAsTopup, compatibleTopUpEsimId }); + const base = getEsimOrderPayload({ eSimItem, 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); @@ -704,7 +695,6 @@ const Checkout = () => { discountCode, applyAsTopup, compatibleTopUpEsimId, - kokio.userWallet, kokio.deviceUID, upsertOrderRecord, initPaymentSheet, diff --git a/utils/bff/order.ts b/utils/bff/order.ts index f066a67..e4578c4 100644 --- a/utils/bff/order.ts +++ b/utils/bff/order.ts @@ -30,7 +30,6 @@ export type ExternalWalletOrderResponse = CreateOrderResponse & { type FiatOrderRequest = { catalogueId: string; - currency: 'USD'; isNewESim: boolean; esimId?: string; coupon?: string; @@ -39,13 +38,10 @@ type FiatOrderRequest = { type ExternalWalletOrderRequest = { catalogueId: string; - currency: 'USD'; isNewESim: boolean; esimId?: string; coupon?: string; isCryptoPayment: true; - payeeAddress?: string; - successRedirectUrl?: string; }; // ─── Order functions ────────────────────────────────────────────────────────── From e2524c31dabaae3a5a1d2515e14a28a6c8e27ae8 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 4 Jul 2026 02:01:43 +0530 Subject: [PATCH 05/54] update order orchestration layer --- hooks/useCreateOrder.ts | 146 +++++++++++++----- screens/checkout/Checkout.tsx | 278 ++++++++++------------------------ utils/bff/order.ts | 65 +------- 3 files changed, 196 insertions(+), 293 deletions(-) diff --git a/hooks/useCreateOrder.ts b/hooks/useCreateOrder.ts index 82304b0..4f82d7c 100644 --- a/hooks/useCreateOrder.ts +++ b/hooks/useCreateOrder.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as SecureStore from 'expo-secure-store'; -import { createCryptoOrder, pollOrderStatus } from '@/utils/bff/order'; +import { submitOrder, pollOrderStatus } from '@/utils/bff/order'; import type { CreateOrderRequest, OrderStatusResponse } from '@/utils/bff/order'; -import { useKokio } from '@/hooks/useKokio'; +import { useStripePaymentSheet } from '@/hooks/useStripePaymentSheet'; import type { Esim } from '@/components/ESIMItem'; export type CreateOrderVariables = { @@ -11,56 +11,130 @@ export type CreateOrderVariables = { eSimItem: Esim; }; -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; }; +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(); - - return useMutation({ - mutationFn: ({ request }) => createAndPoll(request).then(r => r.order), +// The user backed out of the Stripe sheet without submitting. +export class StripeCancelledError extends Error { + constructor() { + super('Payment cancelled'); + this.name = 'StripeCancelledError'; + } +} - 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'] }); - }, - }); +// 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'; + } } -export function useCreateTopupOrder() { +export function useCreateOrder(options: CreateOrderOptions = {}) { const queryClient = useQueryClient(); - const { kokio, savePurchasedESIM } = useKokio(); + const { initPaymentSheet, presentPaymentSheet, confirmPaymentSheetPayment } = + useStripePaymentSheet(); - return useMutation({ - mutationFn: ({ request }) => createAndPoll(request as CreateOrderRequest).then(r => r.order), + 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, + ); + }); - onSuccess: async (data, { eSimItem }) => { - if (data.esimId) { - await SecureStore.setItemAsync(ESIM_ID_KEY, data.esimId); + await options.onOrderCreated?.(correlationId); + + // 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 pollOrderStatus(correlationId, 15, 2000).catch(() => { + throw new OrderCreationError('Order confirmation timed out', correlationId); + }); + 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 pollOrderStatus(correlationId, 15, 2000).catch(() => { + throw new OrderCreationError('Order confirmation timed out', correlationId); + }); + 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); } + // savePurchasedESIM intentionally not called here. + // Called vua handleOrderResult in Checkout.tsx await queryClient.invalidateQueries({ queryKey: ['orders'] }); }, }); diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index d2951cf..32f0c0c 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -28,21 +28,20 @@ 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 { 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, OrderCreationError, 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 type { CreateOrderResponse, OrderStatusResponse, ExternalWalletOrderResponse } from "@/utils/bff/order"; import * as WebBrowser from "expo-web-browser"; import { MoonpayCommerceProvider, @@ -334,8 +333,6 @@ const Checkout = () => { 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(""); @@ -358,7 +355,15 @@ const Checkout = () => { ); const { showMessage } = useToast(); - const createTopupOrder = useCreateTopupOrder(); + const orderCorrelationRef = useRef(null); + const createOrderMutation = useCreateOrder({ + onOrderCreated: async (correlationId) => { + orderCorrelationRef.current = correlationId; + if (kokio.deviceUID) { + await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); + } + }, + }); useEffect(() => { const timer = setTimeout(() => setDebouncedCode(discountCode), 500); @@ -430,70 +435,6 @@ const Checkout = () => { setDiscountError(""); }, []); - const handleEsimCheckout = useCallback(async () => { - try { - setIsCheckoutLoading(true); - - 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, - discountCode, - applyAsTopup, - compatibleTopUpEsimId - }); - const esimBody = payload; - 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?.deviceUID, - applyAsTopup, - compatibleTopUpEsimId, - createTopupOrder, - showMessage, - handleRemoveDiscount, - handleOrderResult, - upsertOrderRecord, - ]); - const resetHelioState = useCallback(() => { setHelioChargeToken(null); setPendingHelioOrder(null); @@ -555,140 +496,85 @@ const Checkout = () => { }, [handleOrderResult]); 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, discountCode, applyAsTopup, compatibleTopUpEsimId }); - const fiatBody = { - catalogueId: base.catalogueId, - 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; - } - - const { error: confirmError } = await confirmPaymentSheetPayment(); - if (confirmError) { - if (__DEV__) console.error("[Stripe] confirmPaymentSheetPayment error:", confirmError); - showMessage(confirmError.message, "info"); - setIsCheckoutLoading(false); - return; - } - - setLoadingMessage('Processing your order...'); - const finalOrder = correlationId - ? await pollOrderStatus(correlationId, 15, 2000, (s) => - setLoadingMessage(pollingLabel(s)), - ).catch(() => null) - : null; + setIsCheckoutLoading(true); + setLoadingMessage('Preparing your payment...'); + orderCorrelationRef.current = null; + + try { + const result = await createOrderMutation.mutateAsync({ request, eSimItem }); + + 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, discountCode, applyAsTopup, compatibleTopUpEsimId }); - const extBody = { - catalogueId: base.catalogueId, - isNewESim: base.isNewESim, - esimId: base.esimId, - coupon: base.coupon, - isCryptoPayment: true as const, - }; - 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 disable 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, + * since both resolve to the same CRYPTO response shape. + * Expected to diverge once the backend distinguishes direct transfers from processor payments. + * TODO: Revisit this branch then. + */ + handleBrowserPay(result.moonpayPaymentPageUrl, result.correlationId); + } + } catch (err) { + if (__DEV__) console.error('Checkout error:', 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; + } + 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'); + } + + const errCid = + err instanceof OrderCreationError ? err.correlationId : orderCorrelationRef.current; + if (kokio.deviceUID && errCid) { + await upsertOrderRecord(kokio.deviceUID, eSimItem, errCid, 'FAILED'); } - return; - } - handleEsimCheckout(); + setIsCheckoutLoading(false); + setLoadingMessage(''); + setShowSuccessModal(false); + } }, [ selectedPaymentMethod, eSimItem, @@ -696,14 +582,12 @@ const Checkout = () => { applyAsTopup, compatibleTopUpEsimId, kokio.deviceUID, + createOrderMutation, upsertOrderRecord, - initPaymentSheet, - presentPaymentSheet, - confirmPaymentSheetPayment, showMessage, - handleEsimCheckout, - handleBrowserPay, + handleRemoveDiscount, handleOrderResult, + handleBrowserPay, ]); const handleInstallESIM = useCallback(() => { diff --git a/utils/bff/order.ts b/utils/bff/order.ts index e4578c4..4294c6b 100644 --- a/utils/bff/order.ts +++ b/utils/bff/order.ts @@ -13,75 +13,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; - isNewESim: boolean; - esimId?: string; - coupon?: string; - isCryptoPayment: false; -}; - -type ExternalWalletOrderRequest = { - catalogueId: string; - isNewESim: boolean; - esimId?: string; - coupon?: string; - isCryptoPayment: true; -}; - // ─── 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}`)); From 43a34ab871948cc9625e26eef575473f07089543 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 4 Jul 2026 05:08:02 +0530 Subject: [PATCH 06/54] spec compliant order polling with backoff --- hooks/useCreateOrder.ts | 30 ++++++++++---- screens/checkout/Checkout.tsx | 18 ++++---- utils/bff/order.ts | 78 +++++++++++++++++++++++++++++++---- 3 files changed, 102 insertions(+), 24 deletions(-) diff --git a/hooks/useCreateOrder.ts b/hooks/useCreateOrder.ts index 4f82d7c..1dd1915 100644 --- a/hooks/useCreateOrder.ts +++ b/hooks/useCreateOrder.ts @@ -1,8 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as SecureStore from 'expo-secure-store'; -import { submitOrder, pollOrderStatus } from '@/utils/bff/order'; -import type { CreateOrderRequest, OrderStatusResponse } from '@/utils/bff/order'; +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'; @@ -14,6 +14,7 @@ export type CreateOrderVariables = { // Fired once order creation succeeds, before any payment step. export type CreateOrderOptions = { onOrderCreated?: (correlationId: string) => void | Promise; + onPollUpdate?: (update: PollUpdate) => void; }; export type CreateOrderResult = @@ -61,6 +62,22 @@ export class StripeSheetError extends Error { } } +/** + * 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 useCreateOrder(options: CreateOrderOptions = {}) { const queryClient = useQueryClient(); const { initPaymentSheet, presentPaymentSheet, confirmPaymentSheetPayment } = @@ -96,9 +113,8 @@ export function useCreateOrder(options: CreateOrderOptions = {}) { const { error: confirmError } = await confirmPaymentSheetPayment(); if (confirmError) throw new StripeSheetError(confirmError.message); - const order = await pollOrderStatus(correlationId, 15, 2000).catch(() => { - throw new OrderCreationError('Order confirmation timed out', correlationId); - }); + const order = await pollToTerminal(correlationId, options.onPollUpdate); + return { kind: 'terminal', order, correlationId }; } @@ -122,9 +138,7 @@ export function useCreateOrder(options: CreateOrderOptions = {}) { * $0 invoice already auto-paid server-side. Poll immediately. * TODO: Partial coupon flow to be extended from here. */ - const order = await pollOrderStatus(correlationId, 15, 2000).catch(() => { - throw new OrderCreationError('Order confirmation timed out', correlationId); - }); + const order = await pollToTerminal(correlationId, options.onPollUpdate); return { kind: 'terminal', order, correlationId }; }, diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 32f0c0c..d5c1313 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -86,9 +86,9 @@ const ExternalWalletCheckout = ({ 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); }, @@ -356,6 +356,9 @@ const Checkout = () => { 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; @@ -363,6 +366,7 @@ const Checkout = () => { await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); } }, + onPollUpdate: handlePollUpdate, }); useEffect(() => { @@ -464,9 +468,9 @@ 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(''); @@ -493,7 +497,7 @@ const Checkout = () => { // dismissBrowser() was called by the AppState handler / moonpay-return). appStateSub.remove(); doPoll(); - }, [handleOrderResult]); + }, [handleOrderResult, handlePollUpdate]); const handleCheckout = useCallback(async () => { const isCryptoPayment = !( diff --git a/utils/bff/order.ts b/utils/bff/order.ts index 4294c6b..a6e7005 100644 --- a/utils/bff/order.ts +++ b/utils/bff/order.ts @@ -1,5 +1,5 @@ +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'; @@ -55,18 +55,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; + } + + if (__DEV__) console.log('[eSIM] poll:', JSON.stringify(status, null, 2)); + + 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'); } From adac4ab43fd39c97c6affe91251dca2ca4aaf376 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 4 Jul 2026 05:47:28 +0530 Subject: [PATCH 07/54] fix the route to orders instead of settings --- components/ui/OrderFailureModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 3a51228a0f033b13240c8d49058209b48c70d88f Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 4 Jul 2026 05:48:13 +0530 Subject: [PATCH 08/54] add invoice and manual review fields --- providers/kokioProvider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index ac8a3c7..59e054f 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -75,6 +75,8 @@ const reduceESimDataForStorage = ( planId: transactionData.planId, orderStatus: transactionData.orderStatus, paymentMethod: transactionData.paymentMethod, + stripeInvoiceUrl: transactionData.stripeInvoiceUrl ?? null, + flaggedForManualReview: transactionData.flaggedForManualReview ?? false, vendor: transactionData.vendor, esimId: transactionData.esimId, isNewESim: transactionData.isNewESim, From 4178197ea6b172e52b6cf32e4f76b682ef42c9ab Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 4 Jul 2026 05:57:43 +0530 Subject: [PATCH 09/54] fix enabled guard silent override --- hooks/useEsimCompatibility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/useEsimCompatibility.ts b/hooks/useEsimCompatibility.ts index c24ece2..ead4125 100644 --- a/hooks/useEsimCompatibility.ts +++ b/hooks/useEsimCompatibility.ts @@ -19,7 +19,7 @@ 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, }); From 05ebc90551a6c384cde2ef4d2cf4fec5a0727b99 Mon Sep 17 00:00:00 2001 From: dunegxn Date: Mon, 6 Jul 2026 04:48:08 +0530 Subject: [PATCH 10/54] +orderAligned --- hooks/useCreateOrder.ts | 53 ++++++++++++++++++++--------------- screens/checkout/Checkout.tsx | 36 ++++++++++++++++++++---- utils/bff/order.ts | 10 +++++-- 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/hooks/useCreateOrder.ts b/hooks/useCreateOrder.ts index 82304b0..8d9190d 100644 --- a/hooks/useCreateOrder.ts +++ b/hooks/useCreateOrder.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as SecureStore from 'expo-secure-store'; -import { createCryptoOrder, pollOrderStatus } from '@/utils/bff/order'; +import { createCryptoOrder, pollOrderStatus, isOrderSuccess } from '@/utils/bff/order'; import type { CreateOrderRequest, OrderStatusResponse } from '@/utils/bff/order'; import { useKokio } from '@/hooks/useKokio'; import type { Esim } from '@/components/ESIMItem'; @@ -9,22 +9,27 @@ 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; + idempotencyKey?: string; }; +export type CreateOrderResult = { order: OrderStatusResponse | null; correlationId: string | null }; + // 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); +async function createAndPoll(request: CreateOrderRequest, idempotencyKey?: string): Promise { + const { correlationId } = await createCryptoOrder(request, idempotencyKey); + // Per spec, GET /order/{idempotencyKey} only accepts the correlation id used + // at creation — there is no other key to poll on if the server didn't return one. + if (!correlationId) return { order: null, correlationId: null }; + const order = await pollOrderStatus(correlationId, 15, 2000); return { order, correlationId }; } @@ -32,15 +37,17 @@ export function useCreateOrder() { const queryClient = useQueryClient(); const { kokio, savePurchasedESIM } = useKokio(); - return useMutation({ - mutationFn: ({ request }) => createAndPoll(request).then(r => r.order), + return useMutation({ + mutationFn: ({ request, idempotencyKey }) => createAndPoll(request, idempotencyKey), - onSuccess: async (data, { eSimItem }) => { - if (data.esimId) { - await SecureStore.setItemAsync(ESIM_ID_KEY, data.esimId); - } - if (kokio.deviceUID) { - await savePurchasedESIM(kokio.deviceUID, eSimItem, data); + onSuccess: async ({ order, correlationId }, { eSimItem }) => { + if (order && isOrderSuccess(order.orderStatus)) { + if (order.esimId) { + await SecureStore.setItemAsync(ESIM_ID_KEY, order.esimId); + } + if (kokio.deviceUID) { + await savePurchasedESIM(kokio.deviceUID, eSimItem, order, correlationId); + } } await queryClient.invalidateQueries({ queryKey: ['orders'] }); }, @@ -51,15 +58,17 @@ export function useCreateTopupOrder() { const queryClient = useQueryClient(); const { kokio, savePurchasedESIM } = useKokio(); - return useMutation({ - mutationFn: ({ request }) => createAndPoll(request as CreateOrderRequest).then(r => r.order), + return useMutation({ + mutationFn: ({ request, idempotencyKey }) => createAndPoll(request as CreateOrderRequest, idempotencyKey), - onSuccess: async (data, { eSimItem }) => { - if (data.esimId) { - await SecureStore.setItemAsync(ESIM_ID_KEY, data.esimId); - } - if (kokio.deviceUID) { - await savePurchasedESIM(kokio.deviceUID, eSimItem, data); + onSuccess: async ({ order, correlationId }, { eSimItem }) => { + if (order && isOrderSuccess(order.orderStatus)) { + if (order.esimId) { + await SecureStore.setItemAsync(ESIM_ID_KEY, order.esimId); + } + if (kokio.deviceUID) { + await savePurchasedESIM(kokio.deviceUID, eSimItem, order, correlationId); + } } await queryClient.invalidateQueries({ queryKey: ['orders'] }); }, diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 085e89d..6faa693 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -45,6 +45,7 @@ import { useKokio } from "@/hooks/useKokio"; import { Config } from "@/appKeys"; import type { CreateOrderResponse, OrderStatusResponse, ExternalWalletOrderResponse } from "@/utils/bff/order"; import * as WebBrowser from "expo-web-browser"; +import { v4 as uuidv4 } from "uuid"; import { MoonpayCommerceProvider, usePayWithCrypto, @@ -382,6 +383,22 @@ const Checkout = () => { const [compatibleTopUpEsimId, setCompatibleTopUpEsimId] = useState(); const bg = useThemeColor({}, "background"); + // Stable per-attempt idempotency key: reused across retries of an unchanged + // order (e.g. tapping Pay again after a dropped response) so the BFF can + // recognize them as the same attempt, per its duplicate-order/duplicate-charge + // contract. Regenerated only when the order's defining parameters change. + const orderAttemptRef = useRef<{ signature: string; id: string } | null>(null); + const getOrderAttemptId = useCallback((signature: string): string => { + if (orderAttemptRef.current?.signature !== signature) { + orderAttemptRef.current = { signature, id: uuidv4() }; + } + return orderAttemptRef.current.id; + }, []); + const orderSignature = useMemo( + () => JSON.stringify([eSimItem?.catalogueId, discountCode, applyAsTopup, compatibleTopUpEsimId, selectedPaymentMethod]), + [eSimItem?.catalogueId, discountCode, applyAsTopup, compatibleTopUpEsimId, selectedPaymentMethod], + ); + useEffect(() => { if (compatibleEsims.length > 0 && !compatibleTopUpEsimId) { setCompatibleTopUpEsimId(compatibleEsims[0].esimId); @@ -401,6 +418,8 @@ const Checkout = () => { if (kokio.deviceUID) { await savePurchasedESIM(kokio.deviceUID, eSimItem, order, correlationId); } + // Order attempt is complete — the next purchase (if any) should mint a fresh idempotency key. + orderAttemptRef.current = null; setOrderResponse(order); setShowSuccessModal(true); return; @@ -438,7 +457,7 @@ const Checkout = () => { const deviceWalletId = kokio.userWallet?.address || ""; if (applyAsTopup && compatibleTopUpEsimId) { - await createTopupOrder.mutateAsync({ + const { order, correlationId } = await createTopupOrder.mutateAsync({ request: { catalogueId: eSimItem.catalogueId, isNewESim: false, @@ -447,8 +466,11 @@ const Checkout = () => { coupon: discountCode || undefined, }, eSimItem, + idempotencyKey: getOrderAttemptId(orderSignature), }); - showMessage('eSIM topped up successfully!', 'info'); + setIsCheckoutLoading(false); + setLoadingMessage(''); + await handleOrderResult(order, correlationId); return; } @@ -461,7 +483,7 @@ const Checkout = () => { }); const esimBody = { ...payload, payeeAddress: deviceWalletId }; if (__DEV__) console.log('[Order] eSIM wallet body:', JSON.stringify(esimBody, null, 2)); - const { correlationId } = await createCryptoOrder(esimBody); + const { correlationId } = await createCryptoOrder(esimBody, getOrderAttemptId(orderSignature)); if (kokio.deviceUID && correlationId) { await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); } @@ -497,6 +519,8 @@ const Checkout = () => { handleRemoveDiscount, handleOrderResult, upsertOrderRecord, + getOrderAttemptId, + orderSignature, ]); const resetHelioState = useCallback(() => { @@ -578,7 +602,7 @@ const Checkout = () => { isCryptoPayment: false as const, }; if (__DEV__) console.log('[Order] fiat body:', JSON.stringify(fiatBody, null, 2)); - const { data: orderInit, correlationId } = await createFiatOrder(fiatBody); + const { data: orderInit, correlationId } = await createFiatOrder(fiatBody, getOrderAttemptId(orderSignature)); fiatCorrelationId = correlationId; if (__DEV__) console.log('[Order] fiat correlationId:', correlationId); @@ -661,7 +685,7 @@ const Checkout = () => { 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); + const { data: orderInit, correlationId } = await createExternalWalletOrder(extBody, getOrderAttemptId(orderSignature)); extCorrelationId = correlationId; if (__DEV__) console.log('[Order] external wallet correlationId:', correlationId); @@ -714,6 +738,8 @@ const Checkout = () => { handleEsimCheckout, handleBrowserPay, handleOrderResult, + getOrderAttemptId, + orderSignature, ]); const handleInstallESIM = useCallback(() => { diff --git a/utils/bff/order.ts b/utils/bff/order.ts index f066a67..4c8d81e 100644 --- a/utils/bff/order.ts +++ b/utils/bff/order.ts @@ -54,10 +54,14 @@ export function createOrder(body: CreateOrderRequest): Promise)); } +// idempotencyKey lets a caller reuse the same x-correlation-id across a +// client-side retry of the same order attempt (e.g. tapping Pay again after +// a dropped response), per the BFF's idempotency contract. Defaults to a +// fresh key so existing callers that don't need retry-stability are unaffected. export function createCryptoOrder( body: CreateOrderRequest, + idempotencyKey: string = uuidv4(), ): Promise<{ data: CreateOrderResponse; correlationId: string }> { - const idempotencyKey = uuidv4(); return unwrapBffResponseWithCorrelation( api.post('/v1/order', body as Record, { headers: { 'x-correlation-id': idempotencyKey }, @@ -67,8 +71,8 @@ export function createCryptoOrder( export function createFiatOrder( body: FiatOrderRequest, + idempotencyKey: string = uuidv4(), ): Promise<{ data: FiatOrderResponse; correlationId: string | null }> { - const idempotencyKey = uuidv4(); return unwrapBffResponseWithCorrelation( api.post('/v1/order', body as Record, { headers: { 'x-correlation-id': idempotencyKey }, @@ -78,8 +82,8 @@ export function createFiatOrder( export function createExternalWalletOrder( body: ExternalWalletOrderRequest, + idempotencyKey: string = uuidv4(), ): Promise<{ data: ExternalWalletOrderResponse; correlationId: string | null }> { - const idempotencyKey = uuidv4(); return unwrapBffResponseWithCorrelation( api.post('/v1/order', body as Record, { headers: { 'x-correlation-id': idempotencyKey }, From e94dc02c2873923f2e3ec12b2754104e47a2c775 Mon Sep 17 00:00:00 2001 From: dunegxn Date: Mon, 6 Jul 2026 05:50:45 +0530 Subject: [PATCH 11/54] +topupAligned --- app/(tabs)/orders.tsx | 2 +- components/ui/CheckoutSuccessModal.tsx | 55 +++++-- hooks/useEsimCompatibility.ts | 2 +- screens/checkout/Checkout.tsx | 198 +++++++++++++++++-------- 4 files changed, 181 insertions(+), 76 deletions(-) diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx index c0d1d11..18583fa 100644 --- a/app/(tabs)/orders.tsx +++ b/app/(tabs)/orders.tsx @@ -336,7 +336,7 @@ const PurchaseDetailsModal = ({ {invoiceUrl ? ( Linking.openURL(invoiceUrl)} style={pdStyles.infoRow}> Invoice - + View invoice → diff --git a/components/ui/CheckoutSuccessModal.tsx b/components/ui/CheckoutSuccessModal.tsx index 0fceb7c..cef32ca 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]); @@ -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 // 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 // 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/hooks/useEsimCompatibility.ts b/hooks/useEsimCompatibility.ts index c24ece2..9331df1 100644 --- a/hooks/useEsimCompatibility.ts +++ b/hooks/useEsimCompatibility.ts @@ -19,8 +19,8 @@ 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, ...options, + enabled: (options?.enabled ?? true) && !!params.planId, }); // Pre-filtered slices callers most commonly need diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 6faa693..ce64952 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -13,7 +13,6 @@ 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 _subtract from "lodash/subtract"; @@ -267,6 +266,25 @@ const createStyles = () => StyleSheet.create({ 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, @@ -308,6 +326,16 @@ const createStyles = () => StyleSheet.create({ }, }); +// e.g. "United Arab Emirates · 7 Days · 1GB" — same region/validity/data fields ESIMItem.tsx displays. +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(' · '); +} + const Checkout = () => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); @@ -344,6 +372,7 @@ const Checkout = () => { const [isDiscountApplied, setIsDiscountApplied] = useState(false); const [discountAmount, setDiscountAmount] = useState(0); const [orderResponse, setOrderResponse] = useState(null); + const [topupSuccessInfo, setTopupSuccessInfo] = useState<{ fromLabel: string; toLabel: string } | null>(null); const [discountError, setDiscountError] = useState(""); const [showManualReviewModal, setShowManualReviewModal] = useState(false); const [failedOrderInfo, setFailedOrderInfo] = useState<{ @@ -373,8 +402,21 @@ const Checkout = () => { isError: isCouponError, } = useCouponLookup(debouncedCode, debouncedCode.length === 8); - const hasPriorEsim = kokio.purchasedESIMs.length > 0; - const { isLoading: isCheckingTopup, compatibleEsims } = useEsimCompatibility( + // Per the BFF spec, GET /esim/compatibility 404s with NO_ACTIVE_ESIMS_FOR_DEVICE + // when the device has no active eSIMs — only run the check once the user has + // at least one that actually completed (not a locally-recorded pending/failed attempt). + // esimId (not orderStatus) is the reliable signal: orderStatus gets overwritten with + // the eSIM's live activationStatus once synced (see syncPurchasedEsimsWithBff in + // kokioProvider.tsx), but esimId is only ever set once a vendor actually provisions one. + const hasPriorEsim = kokio.purchasedESIMs.some(e => !!e.transactionData.esimId); + const { + isLoading: isCheckingTopup, + isError: isTopupCheckError, + error: topupCheckError, + refetch: refetchTopupCompatibility, + compatibleEsims, + vendorMismatches, + } = useEsimCompatibility( { planId: eSimItem?.catalogueId }, { enabled: hasPriorEsim }, ); @@ -383,6 +425,34 @@ const Checkout = () => { const [compatibleTopUpEsimId, setCompatibleTopUpEsimId] = useState(); const bg = useThemeColor({}, "background"); + // The compatibility check only returns esimId/iccid — build a human-readable + // row label from local purchase history using the same region/validity/data + // fields ESIMItem.tsx shows elsewhere (e.g. "United Arab Emirates · 7 Days · 1GB"), + // so two eSIMs from the same country are still distinguishable by plan size. + // Falls back to ICCID, then the wallet address, if no local record exists + // (e.g. not yet synced). + const buildTopupEsimLabel = useCallback((esimId: string, iccid?: string): string => { + const plan = kokio.purchasedESIMs.find(e => e.transactionData.esimId === esimId)?.eSimItem; + const label = formatPlanLabel(plan); + if (label) return label; + if (iccid) return `ICCID ...${iccid.slice(-4)}`; + return `${esimId.slice(0, 6)}...${esimId.slice(-4)}`; + }, [kokio.purchasedESIMs]); + + // Two eSIMs can share the exact same region + plan size (e.g. bought the same + // UAE plan twice) — append the ICCID's last 4 digits only when 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]); + // Stable per-attempt idempotency key: reused across retries of an unchanged // order (e.g. tapping Pay again after a dropped response) so the BFF can // recognize them as the same attempt, per its duplicate-order/duplicate-charge @@ -399,12 +469,6 @@ const Checkout = () => { [eSimItem?.catalogueId, discountCode, applyAsTopup, compatibleTopUpEsimId, selectedPaymentMethod], ); - useEffect(() => { - if (compatibleEsims.length > 0 && !compatibleTopUpEsimId) { - setCompatibleTopUpEsimId(compatibleEsims[0].esimId); - } - }, [compatibleEsims, compatibleTopUpEsimId]); - const handleOrderResult = useCallback(async ( order: OrderStatusResponse | null, correlationId?: string | null, @@ -421,6 +485,11 @@ const Checkout = () => { // Order attempt is complete — the next purchase (if any) should mint a fresh idempotency key. orderAttemptRef.current = null; setOrderResponse(order); + setTopupSuccessInfo( + applyAsTopup && compatibleTopUpEsimId + ? { fromLabel: formatPlanLabel(eSimItem) ?? 'your new plan', toLabel: buildTopupEsimLabel(compatibleTopUpEsimId) } + : null + ); setShowSuccessModal(true); return; } @@ -441,7 +510,7 @@ const Checkout = () => { order.orderStatus === 'ABANDONED' ? 'Order expired. Please try again.' : 'Order could not be completed. Please try again.'; showMessage(msg, 'info'); - }, [kokio.deviceUID, eSimItem, savePurchasedESIM, showMessage]); + }, [kokio.deviceUID, eSimItem, savePurchasedESIM, showMessage, applyAsTopup, compatibleTopUpEsimId, buildTopupEsimLabel]); const handleRemoveDiscount = useCallback(() => { setIsDiscountApplied(false); @@ -756,6 +825,15 @@ const Checkout = () => { }); }, [orderResponse]); + const handleTopupDone = useCallback(() => { + setShowSuccessModal(false); + // Checkout lives inside the Shop tab's stack, so a plain navigate("/(tabs)") + // doesn't switch the active tab — same workaround as the InstallationHeader + // back handler in app/(tabs)/_layout.tsx. + router.push("/(tabs)/(shop)"); + router.navigate("/(tabs)"); + }, []); + const handleWalletModalClose = useCallback(() => { setShowWalletSetupModal(false); setPendingPaymentMethod(null); @@ -953,59 +1031,59 @@ const Checkout = () => { )} - {/* TODO: TOPUP , selection from multiple eSIMs(if exists and comptabile) for top-up*/} + {!isCheckingTopup && isTopupCheckError && ( + + + {formatBffError(topupCheckError)} + + refetchTopupCompatibility()}> + + Retry + + + + )} + {!isCheckingTopup && !isTopupCheckError && !isTopupCompatible && 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, + ]} + > + + {r.label} + + {isSelected && ( + + )} + + ); + })} )} @@ -1055,7 +1133,11 @@ const Checkout = () => { Date: Mon, 6 Jul 2026 05:58:32 +0530 Subject: [PATCH 12/54] +routingFixes --- app/(tabs)/_layout.tsx | 7 +++++-- screens/checkout/Checkout.tsx | 12 +++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) 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/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index ce64952..800994c 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -827,11 +827,13 @@ const Checkout = () => { const handleTopupDone = useCallback(() => { setShowSuccessModal(false); - // Checkout lives inside the Shop tab's stack, so a plain navigate("/(tabs)") - // doesn't switch the active tab — same workaround as the InstallationHeader - // back handler in app/(tabs)/_layout.tsx. - router.push("/(tabs)/(shop)"); - router.navigate("/(tabs)"); + // Checkout lives inside the Shop tab's own stack, so navigate("/(tabs)") + // operates on the already-mounted Tabs navigator, which just re-focuses + // Shop (its last-active tab) instead of switching to Home. Resetting the + // root stack to "/" remounts (tabs) fresh, landing on its initial tab + // (Home) — same pattern used elsewhere in the app to return to the main + // shell (Offline.tsx, wc-session.tsx, wc-connect.tsx, callback.tsx). + router.replace("/"); }, []); const handleWalletModalClose = useCallback(() => { From edc62e87b061063910a559466f99f6931d63e22c Mon Sep 17 00:00:00 2001 From: dunegxn Date: Mon, 6 Jul 2026 06:28:07 +0530 Subject: [PATCH 13/54] +alignedAccount --- components/AuthenticationModal.tsx | 6 +- providers/kokioProvider.tsx | 37 ++++++++++- utils/bff/__tests__/account.test.ts | 98 +++++++++++++++++++++++++++++ utils/bff/account.ts | 16 +++++ 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 utils/bff/__tests__/account.test.ts create mode 100644 utils/bff/account.ts diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index 1bd6a2d..4ab32a4 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -204,7 +204,6 @@ export function AuthenticationModal() { data ? { deviceWalletAddress: data.deviceWalletAddress } : null ); if (data) { - succeeded = true; await setupKokioRegistration( data.deviceWalletAddress, data.deviceUniqueIdentifier, @@ -213,6 +212,7 @@ export function AuthenticationModal() { data.publicKeyY, data.rawSalt ?? "" ); + succeeded = true; sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); } } catch (e) { @@ -248,11 +248,11 @@ export function AuthenticationModal() { clearError(); 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) }); } } @@ -264,11 +264,11 @@ export function AuthenticationModal() { recovered ? { credentialId: recovered.credentialId } : null ); if (recovered) { - succeeded = true; await setupKokioRecovery( recovered.deviceWalletAddress, recovered.credentialId ); + succeeded = true; sheetRef.current?.close({ duration: 250, easing: Easing.out(Easing.quad) }); } } diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index ac8a3c7..2f3620a 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -16,6 +16,7 @@ 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, @@ -45,6 +46,17 @@ export interface StoredPurchasedESIM { transactionData: StoredTransactionData; } +// Cosmetic fallback only — the BFF doesn't expose a plan's display name (region, +// flag, data amount) from a bare planId (see stub eSimItem in syncPurchasedEsimsWithBff +// below), so this just makes the raw catalogue/vendor id a bit less raw, e.g. +// "esim_UL_1D_AE_V2" -> "UL 1D AE V2", rather than showing it verbatim. +const prettifyPlanId = (planId: string): string => + planId + .replace(/^esim[_-]?/i, '') + .split(/[_-]+/) + .filter(Boolean) + .join(' '); + const reduceESimDataForStorage = ( eSimItem: Esim, transactionData: OrderStatusResponse, @@ -371,7 +383,7 @@ export const KokioProvider: React.FC = ({ children }) => { isUnlimited: false, coverageType: 'LOCAL', serviceRegionCode: '', - serviceRegionName: o.planId, + serviceRegionName: prettifyPlanId(o.planId), serviceRegionFlag: '', }; return { @@ -719,6 +731,29 @@ export const KokioProvider: React.FC = ({ children }) => { await SecureStore.setItemAsync('deviceWalletAddress', deviceWalletAddress); await SecureStore.setItemAsync('credentialId', credentialId); dispatch({ type: 'SET_DEVICE_WALLET_ADDRESS', payload: deviceWalletAddress }); + + // Fetch the wallet-derivation material (deviceUID/pubKeyX/pubKeyY/salt) needed + // to reconstruct the smart account after this reinstall. Must happen now, right + // after the fresh passkey assertion recovery just completed — GET /account + // requires step-up, and this is what satisfies its 5-minute recency window + // without prompting the user for a second biometric confirmation. + 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 }, + }); + + // Reconcile local purchase/order history against the live BFF now that the + // device is fully set up, instead of waiting for the next cold app launch. + await syncPurchasedEsimsWithBff(account.deviceUniqueIdentifier); }; const clearKokio = () => { 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..0a51cf4 --- /dev/null +++ b/utils/bff/account.ts @@ -0,0 +1,16 @@ +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 (5 min +// recency window); call this immediately after a passkey login/recovery so that +// ceremony's auth_time satisfies it without a second biometric prompt. +export function getAccount(): Promise { + return unwrapBffResponse(api.get('/v1/account')); +} From c3ed648ed4d9fa02edf5828f7d6c622453ed36b6 Mon Sep 17 00:00:00 2001 From: dunegxn Date: Mon, 6 Jul 2026 12:13:29 +0530 Subject: [PATCH 14/54] +UiUxFixes --- __tests__/integration/bff-topup-flow.test.ts | 26 ++-- app/_layout.tsx | 32 +++- assets/images/africa.png | Bin 2550 -> 2156 bytes assets/images/asia.png | Bin 4510 -> 3368 bytes assets/images/caribbean.png | Bin 0 -> 8536 bytes assets/images/europe.png | Bin 5355 -> 4238 bytes assets/images/middle-east.png | Bin 0 -> 7303 bytes assets/images/north-america.png | Bin 6980 -> 5193 bytes assets/images/oceania.png | Bin 0 -> 6738 bytes assets/images/south-america.png | Bin 2622 -> 2363 bytes components/ESIMItem.tsx | 7 +- components/Header.tsx | 18 ++- components/SearchInput.tsx | 9 +- components/WalletScreens.tsx | 137 ------------------ components/checkoutHeader/CheckoutHeader.tsx | 22 ++- components/home/active.tsx | 14 -- components/home/hero.tsx | 2 + components/home/wallet.tsx | 19 ++- components/tabBar/TabBar.tsx | 3 +- components/ui/Button.tsx | 53 ------- components/ui/Buttons/BackButton.tsx | 19 --- components/ui/Checkbox.tsx | 25 +++- components/ui/FullScreenLoader.tsx | 74 +++++++++- components/ui/WalletSetupModal.tsx | 7 +- constants/general.constants.ts | 6 +- contexts/ToastContext.tsx | 21 ++- hooks/__tests__/useEsimCompatibility.test.ts | 93 ++++++++++++ hooks/useBootstrap.ts | 2 +- hooks/useEsimCompatibility.ts | 17 ++- screens/EsimsByCountry/EsimsByCountry.tsx | 4 +- screens/EsimsByRegion/EsimsByRegion.tsx | 4 +- screens/checkout/Checkout.tsx | 56 ++++--- screens/checkout/components/radioLabels.tsx | 13 +- screens/esimInstallation/EsimInstallation.tsx | 51 +++++-- screens/shop/searchResult.tsx | 43 +++++- screens/shop/shop.tsx | 27 +++- screens/shop/tabs/countries/countries.tsx | 9 ++ screens/shop/tabs/custom/custom.tsx | 4 +- screens/shop/tabs/global/global.tsx | 4 +- screens/shop/tabs/regions/regions.tsx | 13 +- 40 files changed, 491 insertions(+), 343 deletions(-) create mode 100644 assets/images/caribbean.png create mode 100644 assets/images/middle-east.png create mode 100644 assets/images/oceania.png delete mode 100644 components/WalletScreens.tsx delete mode 100644 components/home/active.tsx delete mode 100644 components/ui/Button.tsx delete mode 100644 components/ui/Buttons/BackButton.tsx create mode 100644 hooks/__tests__/useEsimCompatibility.test.ts 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/app/_layout.tsx b/app/_layout.tsx index bf38e17..09cc267 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -37,6 +37,7 @@ export default function RootLayout() { const pathname = usePathname(); const [isConnected, setIsConnected] = useState(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} + /> ) : ( diff --git a/assets/images/africa.png b/assets/images/africa.png index 8242c5f9a3dc010b5273430dfe93aff5b774528e..bc98425b490484114a594ba1a346e31454321931 100644 GIT binary patch delta 2144 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 DYZ(0? diff --git a/assets/images/asia.png b/assets/images/asia.png index 7ac494199afa13af40da5ad06fa78a54a40bdad1..31d6499c5a2c7966f37037208c06063d29c7e1e2 100644 GIT binary patch delta 3366 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 literal 5355 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 huL|~}KtU6?{tivOoK~rvz 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 D`g0`~ literal 6980 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 ( <> - {/* */} - {/* TODO: Use flag from API response and fallback to this if not present */} {item?.coverageType === "LOCAL" && item?.serviceRegionCode && ( - {/* replaced "Buy" with "View" */} View @@ -127,6 +126,8 @@ const ESIMItem = ({ style={[styles.esimItemContainer, containerStyle]} onPress={onPress} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`${item?.serviceRegionName || "eSIM"} plan`} > {content} 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/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..08a2074 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -243,13 +243,19 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { () => ( - + hitSlop={12} + accessibilityRole="button" + accessibilityLabel="Go back" + > + + { /> { - 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/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/WalletSetupModal.tsx b/components/ui/WalletSetupModal.tsx index 983aceb..68c09a0 100644 --- a/components/ui/WalletSetupModal.tsx +++ b/components/ui/WalletSetupModal.tsx @@ -450,9 +450,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/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/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/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/useBootstrap.ts b/hooks/useBootstrap.ts index 6924842..f7240ed 100644 --- a/hooks/useBootstrap.ts +++ b/hooks/useBootstrap.ts @@ -41,7 +41,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/useEsimCompatibility.ts b/hooks/useEsimCompatibility.ts index 9331df1..b2255f2 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, @@ -23,10 +33,7 @@ export function useEsimCompatibility( enabled: (options?.enabled ?? true) && !!params.planId, }); - // 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/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 800994c..05a0462 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -416,6 +416,7 @@ const Checkout = () => { refetch: refetchTopupCompatibility, compatibleEsims, vendorMismatches, + checkErrors, } = useEsimCompatibility( { planId: eSimItem?.catalogueId }, { enabled: hasPriorEsim }, @@ -888,22 +889,12 @@ const Checkout = () => { }, [eSimItem.actualSellingPrice, isDiscountApplied, discountAmount]); - // const canCheckout = useMemo( - // () => - // isESimEnabled && - // selectedPaymentMethod && - // totalAmount === 0 && - // !isCheckoutLoading, - // [isESimEnabled, selectedPaymentMethod, totalAmount, isCheckoutLoading] - // ); - + // E_SIM_WALLET is unreachable as selectedPaymentMethod: the radio option is + // hard-disabled (checkout.helpers.tsx) and handlePaymentMethodChange bails + // out before setSelectedPaymentMethod for that value. 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]); + return isESimEnabled && !isCheckoutLoading && !!selectedPaymentMethod; + }, [isESimEnabled, isCheckoutLoading, selectedPaymentMethod]); return ( @@ -972,6 +963,8 @@ const Checkout = () => { Apply Coupon @@ -989,6 +982,9 @@ const Checkout = () => { @@ -1038,14 +1034,34 @@ const Checkout = () => { {formatBffError(topupCheckError)} - refetchTopupCompatibility()}> + refetchTopupCompatibility()} + accessibilityRole="button" + accessibilityLabel="Retry top-up compatibility check" + > + + Retry + + + + )} + {!isCheckingTopup && !isTopupCheckError && !isTopupCompatible && checkErrors.length > 0 && ( + + + Couldn't verify top-up compatibility for your existing eSIM. Please try again. + + refetchTopupCompatibility()} + accessibilityRole="button" + accessibilityLabel="Retry top-up compatibility check" + > Retry )} - {!isCheckingTopup && !isTopupCheckError && !isTopupCompatible && vendorMismatches.length > 0 && ( + {!isCheckingTopup && !isTopupCheckError && !isTopupCompatible && checkErrors.length === 0 && vendorMismatches.length > 0 && ( Your existing eSIM isn't compatible with this plan for top-up. @@ -1076,6 +1092,9 @@ const Checkout = () => { styles.topupOptionRow, isSelected && styles.topupOptionRowSelected, ]} + accessibilityRole="radio" + accessibilityState={{ checked: isSelected }} + accessibilityLabel={`Apply this plan as a top-up to ${r.label}`} > {r.label} @@ -1116,6 +1135,9 @@ const Checkout = () => { style={[styles.bottomButtonContainer, !canCheckout && { opacity: 0.5 }]} onPress={canCheckout ? handleCheckout : undefined} disabled={!canCheckout} + accessibilityRole="button" + accessibilityLabel={`Pay ${totalAmount} USD`} + accessibilityState={{ disabled: !canCheckout }} > { return ( Device Wallet - - - Balance - - 0.00 - - + + Coming soon + ); }; diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index a06b175..92d0d96 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -5,7 +5,6 @@ import { StyleSheet, TouchableOpacity, ScrollView, - Platform, } from "react-native"; import ViewShot, { captureRef } from "react-native-view-shot"; import Share from "react-native-share"; @@ -14,7 +13,7 @@ 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"; @@ -235,21 +234,49 @@ 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 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); @@ -337,10 +364,10 @@ const EsimInstallation = () => { text={qrData} /> - {Platform.OS === "ios" && activationAddress && ( + {activationAddress && ( )} - {Platform.OS === "ios" && activationCode && ( + {activationCode && ( )} @@ -374,8 +401,14 @@ const EsimInstallation = () => { take a few minutes. Select Allow/OK, when prompted. - - Coming soon + + Coming soon diff --git a/screens/shop/searchResult.tsx b/screens/shop/searchResult.tsx index 0865fa2..8abf18a 100644 --- a/screens/shop/searchResult.tsx +++ b/screens/shop/searchResult.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { StyleSheet, FlatList, @@ -60,6 +60,8 @@ const CountryItemRender = ({ item }: { item: ServiceRegion[] }) => { { { 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} /> ); From 53b7feaccab4fe7c0bacf9da7cdb1995b715158b Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Wed, 8 Jul 2026 22:11:18 +0530 Subject: [PATCH 15/54] update bff generated types per the new spec --- utils/bff/generated/koKioBff.d.ts | 288 ++++++++++++++++++++++++++++-- 1 file changed, 275 insertions(+), 13 deletions(-) diff --git a/utils/bff/generated/koKioBff.d.ts b/utils/bff/generated/koKioBff.d.ts index 5cd4ac7..5fb99d7 100644 --- a/utils/bff/generated/koKioBff.d.ts +++ b/utils/bff/generated/koKioBff.d.ts @@ -397,9 +397,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 +487,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 +679,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 +1005,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 +1228,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 +1334,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 +2040,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 +2102,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: { /** @@ -2067,6 +2173,26 @@ export interface components { */ 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: components["schemas"]["ESimUsageResponse"]; IssuedFromTx: { /** * @description On-chain transaction hash of the vault transfer that triggered coupon issuance. @@ -2355,10 +2481,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. @@ -2501,6 +2628,15 @@ export interface components { */ action: "denied" | "allowed"; }; + 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"][]; + }; }; responses: { /** @@ -3559,7 +3695,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 */ @@ -3696,16 +3833,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" @@ -3904,6 +4055,117 @@ 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"]; + 500: components["responses"]["InternalServerError"]; + }; + }; couponIssue: { parameters: { query?: never; From d49c048c6a0d44fa6ab1c937fb33e611aa235d5a Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 9 Jul 2026 22:00:23 +0530 Subject: [PATCH 16/54] add a centralized logger --- utils/__tests__/logger.test.ts | 87 ++++++++++++++++++++++++++++++++++ utils/logger.ts | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 utils/__tests__/logger.test.ts create mode 100644 utils/logger.ts 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/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), +}; From 52163627f3cbf39093da89c0e7c29ee3908bf710 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 9 Jul 2026 22:01:27 +0530 Subject: [PATCH 17/54] script to check for stray console log statements in the codebase --- scripts/scan-console.mjs | 121 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 scripts/scan-console.mjs diff --git a/scripts/scan-console.mjs b/scripts/scan-console.mjs new file mode 100644 index 0000000..0d9f810 --- /dev/null +++ b/scripts/scan-console.mjs @@ -0,0 +1,121 @@ +// 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 +// - **/*.web.ts(x) — TEMPORARY: the web layer is slated for +// removal; drop this exclusion once it is gone +// - 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", + "queries", + "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; + if (/\.web\.(ts|tsx)$/.test(relPosix)) return true; // TEMPORARY — see header + 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."); From 41f2762e0aefc7b306e68ab260441dcf39339494 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 9 Jul 2026 22:01:35 +0530 Subject: [PATCH 18/54] updated logging --- .../(wallet)/(contacts)/addContactScreen.tsx | 11 ++---- .../(wallet)/(contacts)/contactDetails.tsx | 7 ++-- .../(wallet)/(contacts)/editContact.tsx | 8 ++-- app/(tabs)/(wallet)/(contacts)/index.tsx | 13 +------ .../(wallet)/(contacts)/qrCodeScreen.tsx | 3 +- .../(wallet)/(contacts)/sendToContact.tsx | 7 ++-- app/(tabs)/(wallet)/index.tsx | 3 +- app/(tabs)/phone.tsx | 3 +- app/(tabs)/settings.tsx | 3 +- app/wc-connect.tsx | 3 +- app/wc-session.tsx | 5 ++- appKeys.ts | 37 ++++--------------- components/AuthenticationModal.tsx | 25 ++++--------- components/home/wallet.tsx | 5 ++- components/ui/Button.tsx | 3 +- components/ui/Checkbox.tsx | 1 - components/ui/WalletSetupModal.tsx | 13 ++++--- hooks/useAppState.ts | 7 ++-- hooks/useBootstrap.ts | 9 +++-- package.json | 1 + providers/authProvider.tsx | 3 +- providers/kokioProvider.tsx | 19 +++++----- screens/checkout/Checkout.tsx | 5 ++- screens/esimInstallation/EsimInstallation.tsx | 9 +++-- screens/test/test.tsx | 9 +++-- services/httpService.ts | 3 +- utils/auth/dpopProof.ts | 3 +- utils/auth/kokioAuthClient.ts | 5 +-- utils/auth/passkeyLogin.ts | 23 ++++++------ utils/auth/stepUp.ts | 17 +++------ utils/bff/coupon.ts | 11 ++---- utils/bff/order.ts | 3 +- 32 files changed, 122 insertions(+), 155 deletions(-) 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)/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..eb05510 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -20,6 +20,7 @@ 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"; const createStyles = () => StyleSheet.create({ container: { @@ -186,7 +187,7 @@ const AboutContent = ({ onClose }: { onClose: () => void }) => { try { await openBrowserAsync(url); } catch (error) { - console.error("Error opening browser:", error); + logger.error('BROWSER_OPEN_FAILED', { error }); } }, []); 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/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index 1bd6a2d..bfbc299 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -20,6 +20,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"; @@ -198,11 +199,7 @@ export function AuthenticationModal() { 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( @@ -216,7 +213,7 @@ export function AuthenticationModal() { 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"); } @@ -230,15 +227,11 @@ export function AuthenticationModal() { 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) }); @@ -258,11 +251,7 @@ export function AuthenticationModal() { } } 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( @@ -273,7 +262,7 @@ export function AuthenticationModal() { } } } catch (e) { - console.error("[auth] handleExistingUser error", e); + logger.error('AUTH_LOGIN_FAILED', { err: e }); } finally { if (!succeeded) setMode("error"); } diff --git a/components/home/wallet.tsx b/components/home/wallet.tsx index 8bef3dd..bbc3771 100644 --- a/components/home/wallet.tsx +++ b/components/home/wallet.tsx @@ -17,6 +17,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { BASE_SEPOLIA_TESTNET } from "@/constants/general.constants"; import { ThemedView } from "../ThemedView"; import { ThemedText } from "../ThemedText"; +import { logger } from '@/utils/logger'; interface WalletProps { balance?: string; @@ -142,7 +143,7 @@ const Wallet = ({ balance, walletId, isWalletAdded, onSetupWallet }: WalletProps try { await Linking.openURL(url); } catch (error) { - console.error("Error opening browser:", error); + logger.error('BROWSER_OPEN_FAILED', { error }); } } }; @@ -152,7 +153,7 @@ const Wallet = ({ balance, walletId, isWalletAdded, onSetupWallet }: WalletProps try { await Clipboard.setStringAsync(walletId); } catch (error) { - console.error("Error copying to clipboard:", error); + logger.error('CLIPBOARD_COPY_FAILED', { error }); } } }; diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 830dc1b..a04f02c 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -2,6 +2,7 @@ import { Theme } from "@/constants/Colors"; import React, { useMemo } from "react"; import { View, Text, Pressable, StyleSheet } from "react-native"; import { useTheme } from "@/contexts/ThemeContext"; +import { logger } from '@/utils/logger'; type PrimaryButtonProps = { children: React.ReactNode; @@ -34,7 +35,7 @@ function Button({ children }: PrimaryButtonProps) { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); function pressHandler() { - console.log("Button pressed"); + logger.debug('BUTTON_PRESSED'); } return ( diff --git a/components/ui/Checkbox.tsx b/components/ui/Checkbox.tsx index 8e85938..f341673 100644 --- a/components/ui/Checkbox.tsx +++ b/components/ui/Checkbox.tsx @@ -14,7 +14,6 @@ const Checkbox = ({ checked, onChange }: CheckboxProps) => { const border = useThemeColor({}, "mutedForeground"); const handleCheckboxChange = useCallback(() => { - console.log(onChange, !checked); onChange(!checked); }, [onChange, checked]); diff --git a/components/ui/WalletSetupModal.tsx b/components/ui/WalletSetupModal.tsx index 983aceb..1a42703 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 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/useBootstrap.ts b/hooks/useBootstrap.ts index 6924842..84bf03a 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); diff --git a/package.json b/package.json index 4898888..5a7bb3c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "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", diff --git a/providers/authProvider.tsx b/providers/authProvider.tsx index 0f6a30f..30a630d 100644 --- a/providers/authProvider.tsx +++ b/providers/authProvider.tsx @@ -18,6 +18,7 @@ import { } from "@/services/httpService"; import { useAuthStore } from "@/stores/authStore"; import { clearUsedHashes } from "@/utils/orderTracking"; +import { logger } from '@/utils/logger'; // ─── Error formatting ───────────────────────────────────────────────────────── @@ -298,7 +299,7 @@ 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); diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index 59e054f..7b4628f 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -7,6 +7,7 @@ import { createWalletClient, http, type Hex } from "viem"; import { baseSepolia, base } from "viem/chains"; import Constants from "expo-constants"; import { AppExtraConfig, Config } from "@/appKeys"; +import { logger } from '@/utils/logger'; import { SmartContractAccount } from "@aa-sdk/core"; @@ -282,7 +283,7 @@ export const KokioProvider: React.FC = ({ children }) => { try { await AsyncStorage.setItem(key, JSON.stringify(value)); } catch (error) { - if (__DEV__) console.error("Error saving purchased eSIMs to AsyncStorage:", error); + logger.error('ESIM_STORAGE_SAVE_FAILED', { error }); } }; @@ -296,7 +297,7 @@ export const KokioProvider: React.FC = ({ children }) => { return parsedResult; } } catch (error) { - if (__DEV__) console.error("Error retrieving purchased eSIMs from AsyncStorage:", error); + logger.error('ESIM_STORAGE_READ_FAILED', { error }); } }; @@ -304,7 +305,7 @@ export const KokioProvider: React.FC = ({ children }) => { try { await AsyncStorage.removeItem(key); } catch (error) { - if (__DEV__) console.error("Error deleting purchased eSIMs from AsyncStorage:", error); + logger.error('ESIM_STORAGE_DELETE_FAILED', { error }); } }; @@ -422,7 +423,7 @@ export const KokioProvider: React.FC = ({ children }) => { 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_DEVICE_UID'); } if (deviceUID && storedWalletAddress) { @@ -447,7 +448,7 @@ 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:', { + logger.debug('[KOKIO] SecureStore Hydration', { hasDeviceWalletAddress: !!storedWalletAddress, hasCredentialId: !!credentialId, hasPublicKeyX: !!publicKeyX, @@ -533,11 +534,11 @@ 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]); @@ -603,13 +604,13 @@ export const KokioProvider: React.FC = ({ children }) => { transactionData: OrderStatusResponse, correlationId?: string | null, ) => { - if (__DEV__) console.log('[eSIM] order response:', JSON.stringify(transactionData, null, 2)); + logger.debug('ESIM_ORDER_RESPONSE', { transactionData }); 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)); + logger.debug('ESIM_STORED_RECORD', { reducedPurchasedESIM }); const existingIdx = correlationId ? currentESIMs.findIndex(e => e.transactionData.correlationId === correlationId) diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index d5c1313..30d51da 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -48,6 +48,7 @@ import { 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; @@ -82,7 +83,7 @@ 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 @@ -548,7 +549,7 @@ const Checkout = () => { handleBrowserPay(result.moonpayPaymentPageUrl, result.correlationId); } } catch (err) { - if (__DEV__) console.error('Checkout error:', err); + logger.error('CHECKOUT_FAILED', { err }); if (err instanceof StripeCancelledError) { setIsCheckoutLoading(false); diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index a06b175..39e58eb 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -21,6 +21,7 @@ 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 +99,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 ( @@ -256,7 +257,7 @@ const EsimInstallation = () => { const handleShareQR = async () => { try { if (!qrViewRef.current) { - console.log("QR view ref is not available"); + logger.warn('QR_VIEW_REF_UNAVAILABLE'); return; } @@ -271,10 +272,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 }); } }; 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/services/httpService.ts b/services/httpService.ts index ba2f43c..c199d98 100644 --- a/services/httpService.ts +++ b/services/httpService.ts @@ -3,6 +3,7 @@ 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'; @@ -114,7 +115,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 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..8d72c9f 100644 --- a/utils/auth/passkeyLogin.ts +++ b/utils/auth/passkeyLogin.ts @@ -9,6 +9,7 @@ import { parseIdToken } from './tokenStore'; import { AuthError } from './errors'; import { useAuthStore } from '@/stores/authStore'; import { Config } from '@/appKeys'; +import { logger } from '@/utils/logger'; // ─── Shared types ──────────────────────────────────────────────────────────── @@ -27,14 +28,14 @@ import { Config } from '@/appKeys'; // 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'); +// logger.debug('[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'); +// logger.debug('[PASSKEY] hydrated rawSalt from loginComplete'); // } // } // if (data.publicKeyX && data.publicKeyY) { @@ -42,7 +43,7 @@ import { Config } from '@/appKeys'; // if (!existing) { // await SecureStore.setItemAsync('publicKeyX', data.publicKeyX); // await SecureStore.setItemAsync('publicKeyY', data.publicKeyY); -// if (__DEV__) console.log('[passkey] hydrated publicKeyX/Y from loginComplete'); +// logger.debug('[PASSKEY] hydrated publicKeyX/Y from loginComplete'); // } // } // } @@ -58,7 +59,7 @@ import { Config } from '@/appKeys'; // 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); +// logger.debug('PASSKEY_HYDRATED_DEVICE_UID', { userHandle: decoded }); // } // } catch { /* non-critical */ } // } @@ -106,7 +107,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 +128,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 +175,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 +194,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 +312,7 @@ export async function discoverAndLoginWithPasskey(): Promise( await kokioAuthClient.loginComplete({ 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/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/order.ts b/utils/bff/order.ts index a6e7005..fd40037 100644 --- a/utils/bff/order.ts +++ b/utils/bff/order.ts @@ -2,6 +2,7 @@ import { BffError , unwrapBffResponse, unwrapBffResponseWithCorrelation } from ' import type { components } from './generated/koKioBff'; 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']; @@ -115,7 +116,7 @@ export async function pollOrderStatus( continue; } - if (__DEV__) console.log('[eSIM] poll:', JSON.stringify(status, null, 2)); + logger.debug('ESIM_POLL_STATUS', { status }); if (!status.orderId) throw new OrderNotFoundError(); if (TERMINAL_ORDER_STATUSES.has(status.orderStatus)) return status; From 93928f0a8871bd34fb206a0065af19de6f138a73 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 9 Jul 2026 23:31:25 +0530 Subject: [PATCH 19/54] remove web target scaffolding --- .gitignore | 4 +- app.config.js | 5 - components/AuthencticationModal.web.tsx | 304 ------------------------ components/AuthenticationModal.web.tsx | 3 - components/ExternalLink.tsx | 10 +- hooks/useColorScheme.web.ts | 8 - hooks/useStripePaymentSheet.web.ts | 29 --- package.json | 3 - providers/StripeProvider.web.tsx | 9 - providers/authProvider.web.tsx | 64 ----- providers/index.web.tsx | 43 ---- providers/kokioProvider.web.tsx | 100 -------- scripts/scan-console.mjs | 3 - utils/nativeRuntimeSetup.web.ts | 1 - 14 files changed, 3 insertions(+), 583 deletions(-) delete mode 100644 components/AuthencticationModal.web.tsx delete mode 100644 components/AuthenticationModal.web.tsx delete mode 100644 hooks/useColorScheme.web.ts delete mode 100644 hooks/useStripePaymentSheet.web.ts delete mode 100644 providers/StripeProvider.web.tsx delete mode 100644 providers/authProvider.web.tsx delete mode 100644 providers/index.web.tsx delete mode 100644 providers/kokioProvider.web.tsx delete mode 100644 utils/nativeRuntimeSetup.web.ts 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/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/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.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/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/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/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.json b/package.json index 5a7bb3c..e05c55f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "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", @@ -72,7 +71,6 @@ "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", @@ -92,7 +90,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.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.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.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/scripts/scan-console.mjs b/scripts/scan-console.mjs index 0d9f810..7fba9b3 100644 --- a/scripts/scan-console.mjs +++ b/scripts/scan-console.mjs @@ -6,8 +6,6 @@ // - **/generated/** — generated OpenAPI types (never hand-edited) // - scripts/** — build/CI tooling legitimately uses console // - **/__tests__/**, *.test.*, *.spec.* — test code -// - **/*.web.ts(x) — TEMPORARY: the web layer is slated for -// removal; drop this exclusion once it is gone // - utils/logger.ts — the one allowed console site // // Comment handling: block comments and `//` line comments are stripped before @@ -43,7 +41,6 @@ function isExcludedFile(relPosix) { if (relPosix.includes("/generated/")) return true; if (relPosix.includes("/__tests__/")) return true; if (/\.(test|spec)\.(ts|tsx)$/.test(relPosix)) return true; - if (/\.web\.(ts|tsx)$/.test(relPosix)) return true; // TEMPORARY — see header return false; } 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 {}; From dc3cf977a4f573f1299152e789394631c9e75e00 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 9 Jul 2026 23:52:23 +0530 Subject: [PATCH 20/54] bugfix: plan details did not parse info received from catalogue --- components/ESIMItem.tsx | 1 + constants/checkout.constants.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/components/ESIMItem.tsx b/components/ESIMItem.tsx index 3cda26a..8126c01 100644 --- a/components/ESIMItem.tsx +++ b/components/ESIMItem.tsx @@ -23,6 +23,7 @@ export interface Esim { isTopupAvailable?: boolean; isAutoStart?: boolean; isKycRequired?: boolean; + info?: string | null; countryWiseNetworkCoverages?: { countryCode?: string; countryName?: string; 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, }, ]; From 31f9d6b644fbde8307abc93852cf504ed2d9c91b Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Thu, 9 Jul 2026 23:52:46 +0530 Subject: [PATCH 21/54] make details expanded view scrollable --- components/checkoutHeader/CheckoutHeader.tsx | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index bb34809..3182194 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } 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"; @@ -37,12 +37,23 @@ const ExpandableContent = ({ const isMultiCountry = eSimItem?.coverageType !== "LOCAL"; return ( - + {ESIM_EXTRA_DETAILS.map((item, index) => { + const value = _get(eSimItem, item.key); + + // Suppress rows that opt in to hiding when the field is absent or null. + 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 ( ); })} - + ); }; From 12dc315468003a02a9e411625e30d4554c8b9853 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 02:12:49 +0530 Subject: [PATCH 22/54] update packages --- package-lock.json | 627 ++++++++++++++++++++-------------------------- package.json | 4 +- 2 files changed, 278 insertions(+), 353 deletions(-) diff --git a/package-lock.json b/package-lock.json index cde9395..8a358a8 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", @@ -54,7 +56,6 @@ "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", @@ -2716,9 +2716,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 +2882,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 +2987,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 +3173,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 +3412,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" @@ -4611,19 +4611,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 +4658,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 +4673,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 +4724,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 +4766,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 +4833,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 +4879,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 +4930,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 +5011,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 +5360,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 +5378,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 +5397,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 +5408,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 +5455,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 +5546,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": { @@ -5913,29 +5930,73 @@ "tslib": "^2.8.0" } }, + "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": { + "@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" } }, @@ -6108,9 +6169,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" @@ -6207,17 +6268,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,7 +6291,7 @@ "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" } @@ -6246,16 +6307,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 +6332,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 +6354,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 +6372,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 +6389,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 +6414,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 +6428,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 +6508,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 +6532,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": { @@ -7668,9 +7729,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": { @@ -8053,9 +8114,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.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -8129,9 +8190,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": { @@ -8174,9 +8235,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 +8343,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.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", "funding": [ { "type": "opencollective", @@ -8301,10 +8362,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.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -8475,9 +8536,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.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", "funding": [ { "type": "opencollective", @@ -8938,9 +8999,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 +9053,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 +9076,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", @@ -9464,9 +9507,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": { @@ -9622,9 +9665,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 +9686,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": { @@ -10197,9 +10240,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": { @@ -11211,9 +11254,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" @@ -11393,36 +11436,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 +11904,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": { @@ -12276,12 +12289,6 @@ "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 +12303,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 +12438,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", @@ -15426,9 +15424,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": { @@ -15793,9 +15791,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" @@ -16671,9 +16669,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" @@ -17096,6 +17094,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 +17264,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 +17594,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 +18346,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", @@ -20168,12 +20123,6 @@ "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", @@ -20338,9 +20287,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", @@ -20827,32 +20776,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", @@ -21114,9 +21037,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" @@ -22072,9 +21995,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 e05c55f..a73088c 100644 --- a/package.json +++ b/package.json @@ -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", From 835d67e588692d955716193fb01fd5d428b8e802 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 02:13:22 +0530 Subject: [PATCH 23/54] separate isAppActive hook --- hooks/useBffHealth.ts | 14 +------------- hooks/useIsAppActive.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 hooks/useIsAppActive.ts 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/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; +} From d9f7193f170be786ef637a426863de2f2677588d Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 02:14:08 +0530 Subject: [PATCH 24/54] use new persistent query provider --- providers/index.tsx | 53 +++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/providers/index.tsx b/providers/index.tsx index 51611ec..1bae69e 100644 --- a/providers/index.tsx +++ b/providers/index.tsx @@ -1,19 +1,27 @@ -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 '@/queries/esims'; -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]); + +// ─── QueryClient ────────────────────────────────────────────────────────────── const queryClient = new QueryClient({ defaultOptions: { @@ -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. + +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 }) => { - + From bb78277b64815e7e2844b82ad14686d24a7c18a4 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 02:14:29 +0530 Subject: [PATCH 25/54] add new esim usage endpoint hooks --- queries/__tests__/esims.test.ts | 277 ++++++++++++++++++++++++++++++++ queries/esims.ts | 117 ++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 queries/__tests__/esims.test.ts create mode 100644 queries/esims.ts diff --git a/queries/__tests__/esims.test.ts b/queries/__tests__/esims.test.ts new file mode 100644 index 0000000..990ed18 --- /dev/null +++ b/queries/__tests__/esims.test.ts @@ -0,0 +1,277 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'; +import React from 'react'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +// useIsAppActive — default: foregrounded +jest.mock('@/hooks/useIsAppActive', () => ({ + useIsAppActive: jest.fn(() => true), +})); + +// useAuthStore — default: authenticated +jest.mock('@/stores/authStore', () => ({ + useAuthStore: jest.fn((selector: (s: { isAuthenticated: boolean }) => unknown) => + selector({ isAuthenticated: true }), + ), +})); + +// BFF utility functions +jest.mock('@/utils/bff/esim', () => ({ + getAllEsims: jest.fn(), +})); +jest.mock('@/utils/bff/order', () => ({ + getOrderList: jest.fn(), +})); + +import { useIsAppActive } from '@/hooks/useIsAppActive'; +import { useAuthStore } from '@/stores/authStore'; +import { getAllEsims } from '@/utils/bff/esim'; +import { getOrderList } from '@/utils/bff/order'; +import { + useEsims, + useOrders, + DEVICE_ESIMS_KEY, + DEVICE_ORDERS_KEY, +} from '@/queries/esims'; +import type { ESimDocument } from '@/utils/bff/esim'; +import type { OrderListItem } from '@/utils/bff/order'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const ESIM_DOC: ESimDocument = { + iccid: '8944110068000000001', + esimId: '0xdef456abc123def456abc123def456abc123def4', + deviceId: '0xabc123def456abc123def456abc123def456abc1', + vendor: 'VENDOR1', + planId: '1GB_EU_30D', + activationStatus: 'INSTALLED', + planHistory: [], +}; + +const ORDER_ITEM: OrderListItem = { + orderId: '664f1a2b3c4d5e6f7a8b9c0e', + idempotencyKey: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + planId: '1GB_EU_30D', + orderStatus: 'COMPLETED', + paymentMethod: 'FIAT', + flaggedForManualReview: false, + createdAt: '2026-04-20T08:15:00.000Z', +}; + +// ─── Test wrapper ───────────────────────────────────────────────────────────── + +function makeWrapper(client: QueryClient) { + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client }, children); +} + +function freshClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +// ─── useEsims ───────────────────────────────────────────────────────────────── + +describe('useEsims', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsAppActive as jest.Mock).mockReturnValue(true); + (useAuthStore as unknown as jest.Mock).mockImplementation( + (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: true }), + ); + }); + + it('returns data from getAllEsims on success', async () => { + (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); + const client = freshClient(); + + const { result } = renderHook(() => useEsims(), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.esims).toEqual([ESIM_DOC]); + expect(result.current.isError).toBe(false); + }); + + it('returns an empty array as the default before data loads', () => { + (getAllEsims as jest.Mock).mockReturnValue(new Promise(() => {})); // pending + const client = freshClient(); + + const { result } = renderHook(() => useEsims(), { + wrapper: makeWrapper(client), + }); + + expect(result.current.esims).toEqual([]); + expect(result.current.isLoading).toBe(true); + }); + + it('does not fetch when the app is backgrounded', async () => { + (useIsAppActive as jest.Mock).mockReturnValue(false); + (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); + const client = freshClient(); + + renderHook(() => useEsims(), { wrapper: makeWrapper(client) }); + + // Give React Query a tick to attempt a fetch if enabled were true + await new Promise((r) => setTimeout(r, 50)); + expect(getAllEsims).not.toHaveBeenCalled(); + }); + + it('does not fetch when unauthenticated', async () => { + (useAuthStore as unknown as jest.Mock).mockImplementation( + (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: false }), + ); + (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); + const client = freshClient(); + + renderHook(() => useEsims(), { wrapper: makeWrapper(client) }); + + await new Promise((r) => setTimeout(r, 50)); + expect(getAllEsims).not.toHaveBeenCalled(); + }); + + it('sets isError on fetch failure and returns empty array', async () => { + (getAllEsims as jest.Mock).mockRejectedValue(new Error('network error')); + const client = freshClient(); + + const { result } = renderHook(() => useEsims(), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isError).toBe(true); + expect(result.current.esims).toEqual([]); + }); +}); + +// ─── useOrders ──────────────────────────────────────────────────────────────── + +describe('useOrders', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsAppActive as jest.Mock).mockReturnValue(true); + (useAuthStore as unknown as jest.Mock).mockImplementation( + (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: true }), + ); + }); + + it('returns the orders array from getOrderList on success', async () => { + (getOrderList as jest.Mock).mockResolvedValue({ orders: [ORDER_ITEM], page: 1, pageSize: 25, total: 1 }); + const client = freshClient(); + + const { result } = renderHook(() => useOrders(), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.orders).toEqual([ORDER_ITEM]); + expect(result.current.isError).toBe(false); + }); + + it('calls getOrderList with page 1 and pageSize 25', async () => { + (getOrderList as jest.Mock).mockResolvedValue({ orders: [], page: 1, pageSize: 25, total: 0 }); + const client = freshClient(); + + renderHook(() => useOrders(), { wrapper: makeWrapper(client) }); + await waitFor(() => expect(getOrderList).toHaveBeenCalledWith(1, 25)); + }); + + it('returns an empty array as the default before data loads', () => { + (getOrderList as jest.Mock).mockReturnValue(new Promise(() => {})); + const client = freshClient(); + + const { result } = renderHook(() => useOrders(), { + wrapper: makeWrapper(client), + }); + + expect(result.current.orders).toEqual([]); + expect(result.current.isLoading).toBe(true); + }); + + it('does not fetch when the app is backgrounded', async () => { + (useIsAppActive as jest.Mock).mockReturnValue(false); + (getOrderList as jest.Mock).mockResolvedValue({ orders: [], page: 1, pageSize: 25, total: 0 }); + const client = freshClient(); + + renderHook(() => useOrders(), { wrapper: makeWrapper(client) }); + + await new Promise((r) => setTimeout(r, 50)); + expect(getOrderList).not.toHaveBeenCalled(); + }); + + it('sets isError on fetch failure and returns empty array', async () => { + (getOrderList as jest.Mock).mockRejectedValue(new Error('network error')); + const client = freshClient(); + + const { result } = renderHook(() => useOrders(), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isError).toBe(true); + expect(result.current.orders).toEqual([]); + }); +}); + +// ─── Persister key scoping ──────────────────────────────────────────────────── +// Verifies that only device-esims and device-orders are included in the +// dehydrated snapshot — the contract the providers/index.tsx filter enforces. + +describe('persister key scoping', () => { + const PERSISTED_KEYS = new Set([DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY]); + + const shouldDehydrate = (key: string) => PERSISTED_KEYS.has(key); + + it('includes device-esims in the persisted set', () => { + expect(shouldDehydrate(DEVICE_ESIMS_KEY)).toBe(true); + }); + + it('includes device-orders in the persisted set', () => { + expect(shouldDehydrate(DEVICE_ORDERS_KEY)).toBe(true); + }); + + it('excludes catalogue query keys from the persisted set', () => { + expect(shouldDehydrate('esims')).toBe(false); // queries/e-sims.ts root key + expect(shouldDehydrate('catalogue')).toBe(false); + }); + + it('excludes bff-health from the persisted set', () => { + expect(shouldDehydrate('bff-health')).toBe(false); + }); + + it('excludes esim-compatibility from the persisted set', () => { + expect(shouldDehydrate('esim-compatibility')).toBe(false); + }); + + it('device-esims and device-orders appear in the dehydrated snapshot when populated', async () => { + (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); + (getOrderList as jest.Mock).mockResolvedValue({ orders: [ORDER_ITEM], page: 1, pageSize: 25, total: 1 }); + (useIsAppActive as jest.Mock).mockReturnValue(true); + (useAuthStore as unknown as jest.Mock).mockImplementation( + (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: true }), + ); + + const client = freshClient(); + + // Populate both queries + const wrapper = makeWrapper(client); + const { result: esimsResult } = renderHook(() => useEsims(), { wrapper }); + const { result: ordersResult } = renderHook(() => useOrders(), { wrapper }); + + await waitFor(() => !esimsResult.current.isLoading && !ordersResult.current.isLoading); + + // Dehydrate with the same filter as providers/index.tsx + const dehydrated = dehydrate(client, { + shouldDehydrateQuery: (q) => PERSISTED_KEYS.has(q.queryKey[0] as string), + }); + + const keys = dehydrated.queries.map((q) => q.queryKey[0] as string); + expect(keys).toContain(DEVICE_ESIMS_KEY); + expect(keys).toContain(DEVICE_ORDERS_KEY); + expect(keys).not.toContain('esims'); + expect(keys).not.toContain('bff-health'); + }); +}); diff --git a/queries/esims.ts b/queries/esims.ts new file mode 100644 index 0000000..2c90893 --- /dev/null +++ b/queries/esims.ts @@ -0,0 +1,117 @@ +/** + * Server-truth eSIM and order state. + * Authoritative React Query hooks for device eSIM documents and terminal order history. + * + * Distinct from queries/e-sims.ts, which owns catalogue (plan-discovery) queries. + * The two query namespaces are independent: + * - queries/e-sims.ts -> catalogue plans (key root: "esims") + * - queries/esims.ts -> device eSIM docs (key root: "device-esims" / "device-orders") + * + * 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 { useAuthStore } from '@/stores/authStore'; + +// ─── 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 isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + const query = useQuery({ + queryKey: [DEVICE_ESIMS_KEY], + queryFn: getAllEsims, + staleTime: STALE_TIME, + gcTime: GC_TIME, + enabled: isActive && isAuthenticated, + 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 isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + 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 && isAuthenticated, + refetchOnMount: true, + refetchInterval: false, + retry: 1, + }); + + return { + orders: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + }; +} From 0ee78bc02b0112a66a8b7d407b555826b933d10d Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 02:58:57 +0530 Subject: [PATCH 26/54] migrate orders and esim cards to server source of truth --- app/(tabs)/orders.tsx | 467 ++++++++-------------- components/ESIMItem.tsx | 29 +- components/home/active-esim-scroll.tsx | 184 +++++---- hooks/useCreateOrder.ts | 2 - providers/kokioProvider.tsx | 525 +++++-------------------- screens/checkout/Checkout.tsx | 35 +- screens/home/home.tsx | 5 +- 7 files changed, 407 insertions(+), 840 deletions(-) diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx index c0d1d11..98d0af6 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 "@/queries/esims"; -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,39 +283,49 @@ 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 → @@ -342,12 +333,12 @@ const PurchaseDetailsModal = ({ ) : 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/components/ESIMItem.tsx b/components/ESIMItem.tsx index 8126c01..ca47d78 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; @@ -61,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 diff --git a/components/home/active-esim-scroll.tsx b/components/home/active-esim-scroll.tsx index 8dc1d8d..a4bc2c3 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 "@/queries/esims"; +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 }, + params: { expandOrderId: 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/hooks/useCreateOrder.ts b/hooks/useCreateOrder.ts index 1dd1915..d4e614d 100644 --- a/hooks/useCreateOrder.ts +++ b/hooks/useCreateOrder.ts @@ -147,8 +147,6 @@ export function useCreateOrder(options: CreateOrderOptions = {}) { if (result.order.esimId) { await SecureStore.setItemAsync(ESIM_ID_KEY, result.order.esimId); } - // savePurchasedESIM intentionally not called here. - // Called vua handleOrderResult in Checkout.tsx await queryClient.invalidateQueries({ queryKey: ['orders'] }); }, }); diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index 7b4628f..b57bc39 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -1,5 +1,4 @@ -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"; @@ -7,91 +6,32 @@ import { createWalletClient, http, type Hex } from "viem"; import { baseSepolia, base } from "viem/chains"; import Constants from "expo-constants"; import { AppExtraConfig, Config } from "@/appKeys"; -import { logger } from '@/utils/logger'; - 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 { getWcSignClient, setPendingProposal, } from "@/utils/walletconnect/signClient"; +import { logger } from "@/utils/logger"; + 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, - stripeInvoiceUrl: transactionData.stripeInvoiceUrl ?? null, - flaggedForManualReview: transactionData.flaggedForManualReview ?? false, - 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 } @@ -103,23 +43,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; @@ -129,7 +55,6 @@ interface KokioState { userData?: UserData; userPasskey?: UserPasskey; userWallet?: SmartContractAccount; - purchasedESIMs: StoredPurchasedESIM[]; } const initialState: KokioState = { @@ -141,7 +66,6 @@ const initialState: KokioState = { userData: undefined, userPasskey: undefined, userWallet: undefined, - purchasedESIMs: [], }; function kokioReducer(kokio: KokioState, action: AuthActionType): KokioState { @@ -164,13 +88,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, @@ -180,35 +99,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; @@ -220,8 +132,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: () => {}, @@ -232,9 +142,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)); }; @@ -243,170 +157,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 getValueForUserWallet = async ( - key: string - ): Promise => { - let result = await SecureStore.getItemAsync(key); - if (result) { - const parsedResult: SmartContractAccount = JSON.parse(result); - return parsedResult; - } - }; - - const saveValueForPurchasedESIMs = async ( - key: string, - value: StoredPurchasedESIM[] - ) => { - try { - await AsyncStorage.setItem(key, JSON.stringify(value)); - } catch (error) { - logger.error('ESIM_STORAGE_SAVE_FAILED', { error }); - } + const getValueForDeviceUID = async (key: string): Promise => { + const result = await SecureStore.getItemAsync(key); + if (result) return JSON.parse(result) as string; }; - 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) { - logger.error('ESIM_STORAGE_READ_FAILED', { error }); - } + const getValueForUserData = async (key: string): Promise => { + const result = await SecureStore.getItemAsync(key); + if (result) return JSON.parse(result) as UserData; }; - const deleteValueForPurchasedESIMs = async (key: string): Promise => { - try { - await AsyncStorage.removeItem(key); - } catch (error) { - logger.error('ESIM_STORAGE_DELETE_FAILED', { error }); - } + const getValueForUserWallet = async (key: string): Promise => { + const result = await SecureStore.getItemAsync(key); + if (result) return JSON.parse(result) as SmartContractAccount; }; - 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 () => { @@ -417,20 +191,17 @@ 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. + // Guard: deviceUID without deviceWalletAddress is orphaned state + // (e.g. old Turnkey install or a crashed registration). Purge it so the + // auth modal routes to sign-up rather than a broken login attempt. if (deviceUID && !storedWalletAddress) { await SecureStore.deleteItemAsync("deviceUID"); await SecureStore.deleteItemAsync("credentialId"); - logger.debug('KOKIO_PURGED_ORPHAN_DEVICE_UID'); + 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) { @@ -445,10 +216,11 @@ export const KokioProvider: React.FC = ({ children }) => { }, }); } + const credentialId = await SecureStore.getItemAsync('credentialId'); const publicKeyX = await SecureStore.getItemAsync('publicKeyX'); const publicKeyY = await SecureStore.getItemAsync('publicKeyY'); - logger.debug('[KOKIO] SecureStore Hydration', { + logger.debug('KOKIO_SECURESTORE_HYDRATION', { hasDeviceWalletAddress: !!storedWalletAddress, hasCredentialId: !!credentialId, hasPublicKeyX: !!publicKeyX, @@ -456,41 +228,30 @@ export const KokioProvider: React.FC = ({ children }) => { 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 }, []); + // ── SDK initialisation ──────────────────────────────────────────────────── + useEffect(() => { if (!kokio.sdk && kokio.deviceUID && kokio.userPasskey) { setupKokio(); @@ -499,6 +260,8 @@ export const KokioProvider: React.FC = ({ children }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [kokio.deviceUID, kokio.userPasskey, kokio.sdk]); + // ── WalletConnect initialisation ────────────────────────────────────────── + const wcInitialized = useRef(false); useEffect(() => { @@ -542,127 +305,15 @@ export const KokioProvider: React.FC = ({ children }) => { }); }, [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, - ) => { - logger.debug('ESIM_ORDER_RESPONSE', { transactionData }); - - const existingESIMs = await getValueForPurchasedESIMs(`purchasedESIMs-${deviceUID}`); - const currentESIMs = existingESIMs || []; - - const reducedPurchasedESIM = reduceESimDataForStorage(eSimItem, transactionData, correlationId); - logger.debug('ESIM_STORED_RECORD', { reducedPurchasedESIM }); - - 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 () => { @@ -678,9 +329,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'); @@ -691,7 +339,6 @@ export const KokioProvider: React.FC = ({ children }) => { } 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'; @@ -718,6 +365,42 @@ 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); @@ -734,7 +417,9 @@ export const KokioProvider: React.FC = ({ children }) => { // 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(() => {}); + // Remove legacy purchasedESIMs AsyncStorage key if it exists on this device. + // The key is no longer written but may be present from a previous version. + await AsyncStorage.removeItem(`purchasedESIMs-${kokio.deviceUID}`).catch(() => {}); await deleteValueForUser("deviceUID").catch(() => {}); await SecureStore.deleteItemAsync("deviceWalletAddress").catch(() => {}); await SecureStore.deleteItemAsync("credentialId").catch(() => {}); @@ -752,8 +437,6 @@ export const KokioProvider: React.FC = ({ children }) => { setupKokio, setupKokioDeviceUID, setupKokioUserWallet, - savePurchasedESIM, - upsertOrderRecord, setupKokioRegistration, setupKokioRecovery, clearKokio, diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 30d51da..094ecf6 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -15,6 +15,7 @@ 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 { useQueryClient } from '@tanstack/react-query'; import _trim from "lodash/trim"; import _subtract from "lodash/subtract"; import _toNumber from "lodash/toNumber"; @@ -28,6 +29,7 @@ import DetailItem from "@/components/ui/DetailItem"; import Checkbox from "@/components/ui/Checkbox"; import { Esim } from "@/components/ESIMItem"; import { getEsimOrderPayload } from "@/helpers/esimOrder"; +import { useEsims, DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY } from '@/queries/esims'; import { isOrderSuccess, pollOrderStatus } from "@/utils/bff/order"; import type { CreateOrderRequest, CreateOrderResponse, OrderStatusResponse } from "@/utils/bff/order"; import { pollingLabel } from "@/utils/orderStatus"; @@ -35,7 +37,7 @@ import OrderFailureModal from "@/components/ui/OrderFailureModal"; import { formatBffError } from "@/utils/bff/koKioBffClient"; import { useCouponLookup } from "@/hooks/useCouponLookup"; import { useEsimCompatibility } from "@/hooks/useEsimCompatibility"; -import { useCreateOrder, OrderCreationError, StripeCancelledError, StripeSheetError } 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"; @@ -62,7 +64,6 @@ const ExternalWalletCheckout = ({ pendingOrder, eSimItem, kokioDeviceUID, - savePurchasedESIM, setIsCheckoutLoading, setLoadingMessage, onComplete, @@ -72,7 +73,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; @@ -93,9 +93,9 @@ const ExternalWalletCheckout = ({ : 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: Probably 'eSimItem', 'kokioDeviceUID', 'pendingOrder' are not needed in the dependency array here, as they are not part of the changing values of this callback //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 }); @@ -334,7 +334,7 @@ const Checkout = () => { const [helioChargeToken, setHelioChargeToken] = useState(null); const [helioCorrelationId, setHelioCorrelationId] = useState(null); const [pendingHelioOrder, setPendingHelioOrder] = useState(null); - const { kokio, savePurchasedESIM, upsertOrderRecord } = useKokio(); + const { kokio } = useKokio(); const [discountCode, setDiscountCode] = useState(""); const [debouncedCode, setDebouncedCode] = useState(""); const [isDiscountApplied, setIsDiscountApplied] = useState(false); @@ -363,9 +363,6 @@ const Checkout = () => { const createOrderMutation = useCreateOrder({ onOrderCreated: async (correlationId) => { orderCorrelationRef.current = correlationId; - if (kokio.deviceUID) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, correlationId); - } }, onPollUpdate: handlePollUpdate, }); @@ -381,7 +378,9 @@ const Checkout = () => { isError: isCouponError, } = useCouponLookup(debouncedCode, debouncedCode.length === 8); - const hasPriorEsim = kokio.purchasedESIMs.length > 0; + const queryClient = useQueryClient(); + const { esims } = useEsims(); + const hasPriorEsim = esims.length > 0; const { isLoading: isCheckingTopup, compatibleEsims } = useEsimCompatibility( { planId: eSimItem?.catalogueId }, { enabled: hasPriorEsim }, @@ -407,9 +406,8 @@ const Checkout = () => { } if (isOrderSuccess(order.orderStatus)) { - if (kokio.deviceUID) { - await savePurchasedESIM(kokio.deviceUID, eSimItem, order, correlationId); - } + queryClient.invalidateQueries({ queryKey: [DEVICE_ESIMS_KEY] }); + queryClient.invalidateQueries({ queryKey: [DEVICE_ORDERS_KEY] }); setOrderResponse(order); setShowSuccessModal(true); return; @@ -431,7 +429,7 @@ const Checkout = () => { 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]); const handleRemoveDiscount = useCallback(() => { setIsDiscountApplied(false); @@ -570,12 +568,6 @@ const Checkout = () => { showMessage(formatBffError(err), 'info'); } - const errCid = - err instanceof OrderCreationError ? err.correlationId : orderCorrelationRef.current; - if (kokio.deviceUID && errCid) { - await upsertOrderRecord(kokio.deviceUID, eSimItem, errCid, 'FAILED'); - } - setIsCheckoutLoading(false); setLoadingMessage(''); setShowSuccessModal(false); @@ -586,9 +578,7 @@ const Checkout = () => { discountCode, applyAsTopup, compatibleTopUpEsimId, - kokio.deviceUID, createOrderMutation, - upsertOrderRecord, showMessage, handleRemoveDiscount, handleOrderResult, @@ -941,7 +931,6 @@ const Checkout = () => { pendingOrder={pendingHelioOrder} eSimItem={eSimItem} kokioDeviceUID={kokio.deviceUID} - savePurchasedESIM={savePurchasedESIM} setIsCheckoutLoading={setIsCheckoutLoading} setLoadingMessage={setLoadingMessage} onComplete={handleHelioComplete} 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 ? ( Date: Fri, 10 Jul 2026 03:35:14 +0530 Subject: [PATCH 27/54] bugfix: access token refreshed before user login --- queries/__tests__/esims.test.ts | 21 +++++++-------------- queries/esims.ts | 10 +++++----- stores/authStore.ts | 8 +++++++- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/queries/__tests__/esims.test.ts b/queries/__tests__/esims.test.ts index 990ed18..c60a870 100644 --- a/queries/__tests__/esims.test.ts +++ b/queries/__tests__/esims.test.ts @@ -9,11 +9,9 @@ jest.mock('@/hooks/useIsAppActive', () => ({ useIsAppActive: jest.fn(() => true), })); -// useAuthStore — default: authenticated -jest.mock('@/stores/authStore', () => ({ - useAuthStore: jest.fn((selector: (s: { isAuthenticated: boolean }) => unknown) => - selector({ isAuthenticated: true }), - ), +// useAuthRelay — default: authenticated +jest.mock('@/hooks/useAuthRelayer', () => ({ + useAuthRelay: jest.fn(() => ({ state: { authenticated: true } })), })); // BFF utility functions @@ -26,6 +24,7 @@ jest.mock('@/utils/bff/order', () => ({ import { useIsAppActive } from '@/hooks/useIsAppActive'; import { useAuthStore } from '@/stores/authStore'; +import { useAuthRelay } from '@/hooks/useAuthRelayer' import { getAllEsims } from '@/utils/bff/esim'; import { getOrderList } from '@/utils/bff/order'; import { @@ -78,9 +77,7 @@ describe('useEsims', () => { beforeEach(() => { jest.clearAllMocks(); (useIsAppActive as jest.Mock).mockReturnValue(true); - (useAuthStore as unknown as jest.Mock).mockImplementation( - (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: true }), - ); + (useAuthRelay as jest.Mock).mockReturnValue({ state: { authenticated: true } }); }); it('returns data from getAllEsims on success', async () => { @@ -121,9 +118,7 @@ describe('useEsims', () => { }); it('does not fetch when unauthenticated', async () => { - (useAuthStore as unknown as jest.Mock).mockImplementation( - (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: false }), - ); + (useAuthRelay as jest.Mock).mockReturnValue({ state: { authenticated: false } }); (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); const client = freshClient(); @@ -153,9 +148,7 @@ describe('useOrders', () => { beforeEach(() => { jest.clearAllMocks(); (useIsAppActive as jest.Mock).mockReturnValue(true); - (useAuthStore as unknown as jest.Mock).mockImplementation( - (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: true }), - ); + (useAuthRelay as jest.Mock).mockReturnValue({ state: { authenticated: true } }); }); it('returns the orders array from getOrderList on success', async () => { diff --git a/queries/esims.ts b/queries/esims.ts index 2c90893..caaad56 100644 --- a/queries/esims.ts +++ b/queries/esims.ts @@ -25,7 +25,7 @@ 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 { useAuthStore } from '@/stores/authStore'; +import { useAuthRelay } from '@/hooks/useAuthRelayer'; // ─── Query key constants ─────────────────────────────────────────────────────── @@ -54,14 +54,14 @@ export interface UseEsimsResult { */ export function useEsims(): UseEsimsResult { const isActive = useIsAppActive(); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const { state: authState } = useAuthRelay(); const query = useQuery({ queryKey: [DEVICE_ESIMS_KEY], queryFn: getAllEsims, staleTime: STALE_TIME, gcTime: GC_TIME, - enabled: isActive && isAuthenticated, + enabled: isActive && authState.authenticated, refetchOnMount: true, refetchInterval: false, // On a network failure serve whatever is in cache. @@ -92,7 +92,7 @@ export interface UseOrdersResult { */ export function useOrders(): UseOrdersResult { const isActive = useIsAppActive(); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const { state: authState } = useAuthRelay(); const query = useQuery({ queryKey: [DEVICE_ORDERS_KEY], @@ -102,7 +102,7 @@ export function useOrders(): UseOrdersResult { }, staleTime: STALE_TIME, gcTime: GC_TIME, - enabled: isActive && isAuthenticated, + enabled: isActive && authState.authenticated, refetchOnMount: true, refetchInterval: false, retry: 1, 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 () => { From 11254ebd6d20049f400acfe6c2801357acbc98b1 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 05:59:26 +0530 Subject: [PATCH 28/54] bugfix: wallet card not populated on account recovery --- providers/authProvider.tsx | 2 +- providers/kokioProvider.tsx | 102 +++++++++++++++++++++++++----------- utils/auth/passkeyLogin.ts | 52 ------------------ 3 files changed, 71 insertions(+), 85 deletions(-) diff --git a/providers/authProvider.tsx b/providers/authProvider.tsx index 30a630d..e4b7dee 100644 --- a/providers/authProvider.tsx +++ b/providers/authProvider.tsx @@ -189,7 +189,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. diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index f8dee34..5ef41e1 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -182,6 +182,7 @@ export const KokioProvider: React.FC = ({ children }) => { }; // ── Boot hydration ──────────────────────────────────────────────────────── + useEffect(() => { const fetchUserData = async () => { const storedWalletAddress = await SecureStore.getItemAsync("deviceWalletAddress"); @@ -191,7 +192,6 @@ export const KokioProvider: React.FC = ({ children }) => { const deviceUID = await getValueForDeviceUID("deviceUID"); - // Guard: deviceUID without deviceWalletAddress is orphaned state if (deviceUID && !storedWalletAddress) { await SecureStore.deleteItemAsync("deviceUID"); await SecureStore.deleteItemAsync("credentialId"); @@ -216,15 +216,15 @@ export const KokioProvider: React.FC = ({ children }) => { } const credentialId = await SecureStore.getItemAsync('credentialId'); - const publicKeyX = await SecureStore.getItemAsync('publicKeyX'); - const publicKeyY = await SecureStore.getItemAsync('publicKeyY'); + 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) { @@ -248,15 +248,57 @@ export const KokioProvider: React.FC = ({ children }) => { fetchUserData(); }, []); - // ── SDK initialisation ──────────────────────────────────────────────────── + /** + * ── SDK initialisation + wallet auto-derivation ─────────────────────────── + * + * This effect fires whenever the SDK, deviceUID, or userPasskey changes. + * After the SDK is ready it also derives and persists the SmartContractAccount + * when userWallet is absent — covering two cases: + * 1. Normal cold boot: userWallet was loaded from SecureStore in the hydration effect above, + * so kokio.userWallet is already set and the derivation block is skipped. + * + * 2. Account recovery after reinstall: setupKokioRecovery populates all derivation material + * and sets the SDK, but userWallet is absent because the SmartContractAccount was never persisted. + * The derivation block runs here, producing the address for the wallet card without a biometric prompt. + */ 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(); + // setupKokio dispatches SET_KOKIO which will re-trigger this effect with kokio.sdk populated. + // Return here to avoid racing the dispatch. + return; + } + + if ( + kokio.sdk && + kokio.deviceUID && + kokio.userPasskey?.x && + kokio.userPasskey?.y && + kokio.rawSalt && + !kokio.userWallet + ) { + try { + 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) { + 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]); // ── WalletConnect initialisation ────────────────────────────────────────── @@ -278,8 +320,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) { @@ -315,8 +355,6 @@ export const KokioProvider: React.FC = ({ children }) => { }; 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') ?? @@ -336,9 +374,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 @@ -387,11 +425,11 @@ export const KokioProvider: React.FC = ({ children }) => { }; await saveValueForUserData(`userData-${deviceUniqueIdentifier}`, userData); - dispatch({ type: "SET_DEVICE_UID", payload: deviceUniqueIdentifier }); + 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 } }); + 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) => { @@ -414,14 +452,18 @@ export const KokioProvider: React.FC = ({ children }) => { await saveValueForDeviceUID('deviceUID', account.deviceUniqueIdentifier); await SecureStore.setItemAsync('publicKeyX', account.pubKeyX); await SecureStore.setItemAsync('publicKeyY', account.pubKeyY); - await SecureStore.setItemAsync('rawSalt', account.salt); + 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_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 SDK-init useEffect above + // detects that sdk is ready + userWallet is absent and calls + // getSmartWallet() automatically once the SDK is initialised. This avoids + // racing setupKokio() which fires concurrently on the same state change. }; const clearKokio = () => { @@ -430,12 +472,8 @@ 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(() => {}); - // Remove legacy purchasedESIMs AsyncStorage key if it exists on this device. - // The key is no longer written but may be present from a previous version. await AsyncStorage.removeItem(`purchasedESIMs-${kokio.deviceUID}`).catch(() => {}); await deleteValueForUser("deviceUID").catch(() => {}); await SecureStore.deleteItemAsync("deviceWalletAddress").catch(() => {}); diff --git a/utils/auth/passkeyLogin.ts b/utils/auth/passkeyLogin.ts index 8d72c9f..4c65298 100644 --- a/utils/auth/passkeyLogin.ts +++ b/utils/auth/passkeyLogin.ts @@ -13,58 +13,6 @@ 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)); -// logger.debug('[PASSKEY] hydrated deviceUID from loginComplete'); -// } -// } -// if (data.rawSalt) { -// const existing = await SecureStore.getItemAsync('rawSalt'); -// if (!existing) { -// await SecureStore.setItemAsync('rawSalt', data.rawSalt); -// logger.debug('[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); -// logger.debug('[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)); -// logger.debug('PASSKEY_HYDRATED_DEVICE_UID', { userHandle: decoded }); -// } -// } catch { /* non-critical */ } -// } -/*DEVICE WALLET DEPLOYEMENT FIX*/ - export type DiscoverLoginResult = { credentialId: string; deviceWalletAddress: string; From bb53b20b2eee27398b021bc500979cd54bce3a79 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 06:16:30 +0530 Subject: [PATCH 29/54] remove dead code --- queries/__tests__/esims.test.ts | 270 -------------------------------- queries/e-sims.ts | 68 -------- queries/esims.ts | 117 -------------- 3 files changed, 455 deletions(-) delete mode 100644 queries/__tests__/esims.test.ts delete mode 100644 queries/e-sims.ts delete mode 100644 queries/esims.ts diff --git a/queries/__tests__/esims.test.ts b/queries/__tests__/esims.test.ts deleted file mode 100644 index c60a870..0000000 --- a/queries/__tests__/esims.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react-native'; -import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'; -import React from 'react'; - -// ─── Mocks ──────────────────────────────────────────────────────────────────── - -// useIsAppActive — default: foregrounded -jest.mock('@/hooks/useIsAppActive', () => ({ - useIsAppActive: jest.fn(() => true), -})); - -// useAuthRelay — default: authenticated -jest.mock('@/hooks/useAuthRelayer', () => ({ - useAuthRelay: jest.fn(() => ({ state: { authenticated: true } })), -})); - -// BFF utility functions -jest.mock('@/utils/bff/esim', () => ({ - getAllEsims: jest.fn(), -})); -jest.mock('@/utils/bff/order', () => ({ - getOrderList: jest.fn(), -})); - -import { useIsAppActive } from '@/hooks/useIsAppActive'; -import { useAuthStore } from '@/stores/authStore'; -import { useAuthRelay } from '@/hooks/useAuthRelayer' -import { getAllEsims } from '@/utils/bff/esim'; -import { getOrderList } from '@/utils/bff/order'; -import { - useEsims, - useOrders, - DEVICE_ESIMS_KEY, - DEVICE_ORDERS_KEY, -} from '@/queries/esims'; -import type { ESimDocument } from '@/utils/bff/esim'; -import type { OrderListItem } from '@/utils/bff/order'; - -// ─── Fixtures ──────────────────────────────────────────────────────────────── - -const ESIM_DOC: ESimDocument = { - iccid: '8944110068000000001', - esimId: '0xdef456abc123def456abc123def456abc123def4', - deviceId: '0xabc123def456abc123def456abc123def456abc1', - vendor: 'VENDOR1', - planId: '1GB_EU_30D', - activationStatus: 'INSTALLED', - planHistory: [], -}; - -const ORDER_ITEM: OrderListItem = { - orderId: '664f1a2b3c4d5e6f7a8b9c0e', - idempotencyKey: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - planId: '1GB_EU_30D', - orderStatus: 'COMPLETED', - paymentMethod: 'FIAT', - flaggedForManualReview: false, - createdAt: '2026-04-20T08:15:00.000Z', -}; - -// ─── Test wrapper ───────────────────────────────────────────────────────────── - -function makeWrapper(client: QueryClient) { - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client }, children); -} - -function freshClient() { - return new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); -} - -// ─── useEsims ───────────────────────────────────────────────────────────────── - -describe('useEsims', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useIsAppActive as jest.Mock).mockReturnValue(true); - (useAuthRelay as jest.Mock).mockReturnValue({ state: { authenticated: true } }); - }); - - it('returns data from getAllEsims on success', async () => { - (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); - const client = freshClient(); - - const { result } = renderHook(() => useEsims(), { - wrapper: makeWrapper(client), - }); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.esims).toEqual([ESIM_DOC]); - expect(result.current.isError).toBe(false); - }); - - it('returns an empty array as the default before data loads', () => { - (getAllEsims as jest.Mock).mockReturnValue(new Promise(() => {})); // pending - const client = freshClient(); - - const { result } = renderHook(() => useEsims(), { - wrapper: makeWrapper(client), - }); - - expect(result.current.esims).toEqual([]); - expect(result.current.isLoading).toBe(true); - }); - - it('does not fetch when the app is backgrounded', async () => { - (useIsAppActive as jest.Mock).mockReturnValue(false); - (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); - const client = freshClient(); - - renderHook(() => useEsims(), { wrapper: makeWrapper(client) }); - - // Give React Query a tick to attempt a fetch if enabled were true - await new Promise((r) => setTimeout(r, 50)); - expect(getAllEsims).not.toHaveBeenCalled(); - }); - - it('does not fetch when unauthenticated', async () => { - (useAuthRelay as jest.Mock).mockReturnValue({ state: { authenticated: false } }); - (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); - const client = freshClient(); - - renderHook(() => useEsims(), { wrapper: makeWrapper(client) }); - - await new Promise((r) => setTimeout(r, 50)); - expect(getAllEsims).not.toHaveBeenCalled(); - }); - - it('sets isError on fetch failure and returns empty array', async () => { - (getAllEsims as jest.Mock).mockRejectedValue(new Error('network error')); - const client = freshClient(); - - const { result } = renderHook(() => useEsims(), { - wrapper: makeWrapper(client), - }); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.isError).toBe(true); - expect(result.current.esims).toEqual([]); - }); -}); - -// ─── useOrders ──────────────────────────────────────────────────────────────── - -describe('useOrders', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useIsAppActive as jest.Mock).mockReturnValue(true); - (useAuthRelay as jest.Mock).mockReturnValue({ state: { authenticated: true } }); - }); - - it('returns the orders array from getOrderList on success', async () => { - (getOrderList as jest.Mock).mockResolvedValue({ orders: [ORDER_ITEM], page: 1, pageSize: 25, total: 1 }); - const client = freshClient(); - - const { result } = renderHook(() => useOrders(), { - wrapper: makeWrapper(client), - }); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.orders).toEqual([ORDER_ITEM]); - expect(result.current.isError).toBe(false); - }); - - it('calls getOrderList with page 1 and pageSize 25', async () => { - (getOrderList as jest.Mock).mockResolvedValue({ orders: [], page: 1, pageSize: 25, total: 0 }); - const client = freshClient(); - - renderHook(() => useOrders(), { wrapper: makeWrapper(client) }); - await waitFor(() => expect(getOrderList).toHaveBeenCalledWith(1, 25)); - }); - - it('returns an empty array as the default before data loads', () => { - (getOrderList as jest.Mock).mockReturnValue(new Promise(() => {})); - const client = freshClient(); - - const { result } = renderHook(() => useOrders(), { - wrapper: makeWrapper(client), - }); - - expect(result.current.orders).toEqual([]); - expect(result.current.isLoading).toBe(true); - }); - - it('does not fetch when the app is backgrounded', async () => { - (useIsAppActive as jest.Mock).mockReturnValue(false); - (getOrderList as jest.Mock).mockResolvedValue({ orders: [], page: 1, pageSize: 25, total: 0 }); - const client = freshClient(); - - renderHook(() => useOrders(), { wrapper: makeWrapper(client) }); - - await new Promise((r) => setTimeout(r, 50)); - expect(getOrderList).not.toHaveBeenCalled(); - }); - - it('sets isError on fetch failure and returns empty array', async () => { - (getOrderList as jest.Mock).mockRejectedValue(new Error('network error')); - const client = freshClient(); - - const { result } = renderHook(() => useOrders(), { - wrapper: makeWrapper(client), - }); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.isError).toBe(true); - expect(result.current.orders).toEqual([]); - }); -}); - -// ─── Persister key scoping ──────────────────────────────────────────────────── -// Verifies that only device-esims and device-orders are included in the -// dehydrated snapshot — the contract the providers/index.tsx filter enforces. - -describe('persister key scoping', () => { - const PERSISTED_KEYS = new Set([DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY]); - - const shouldDehydrate = (key: string) => PERSISTED_KEYS.has(key); - - it('includes device-esims in the persisted set', () => { - expect(shouldDehydrate(DEVICE_ESIMS_KEY)).toBe(true); - }); - - it('includes device-orders in the persisted set', () => { - expect(shouldDehydrate(DEVICE_ORDERS_KEY)).toBe(true); - }); - - it('excludes catalogue query keys from the persisted set', () => { - expect(shouldDehydrate('esims')).toBe(false); // queries/e-sims.ts root key - expect(shouldDehydrate('catalogue')).toBe(false); - }); - - it('excludes bff-health from the persisted set', () => { - expect(shouldDehydrate('bff-health')).toBe(false); - }); - - it('excludes esim-compatibility from the persisted set', () => { - expect(shouldDehydrate('esim-compatibility')).toBe(false); - }); - - it('device-esims and device-orders appear in the dehydrated snapshot when populated', async () => { - (getAllEsims as jest.Mock).mockResolvedValue([ESIM_DOC]); - (getOrderList as jest.Mock).mockResolvedValue({ orders: [ORDER_ITEM], page: 1, pageSize: 25, total: 1 }); - (useIsAppActive as jest.Mock).mockReturnValue(true); - (useAuthStore as unknown as jest.Mock).mockImplementation( - (sel: (s: { isAuthenticated: boolean }) => unknown) => sel({ isAuthenticated: true }), - ); - - const client = freshClient(); - - // Populate both queries - const wrapper = makeWrapper(client); - const { result: esimsResult } = renderHook(() => useEsims(), { wrapper }); - const { result: ordersResult } = renderHook(() => useOrders(), { wrapper }); - - await waitFor(() => !esimsResult.current.isLoading && !ordersResult.current.isLoading); - - // Dehydrate with the same filter as providers/index.tsx - const dehydrated = dehydrate(client, { - shouldDehydrateQuery: (q) => PERSISTED_KEYS.has(q.queryKey[0] as string), - }); - - const keys = dehydrated.queries.map((q) => q.queryKey[0] as string); - expect(keys).toContain(DEVICE_ESIMS_KEY); - expect(keys).toContain(DEVICE_ORDERS_KEY); - expect(keys).not.toContain('esims'); - expect(keys).not.toContain('bff-health'); - }); -}); 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/queries/esims.ts b/queries/esims.ts deleted file mode 100644 index caaad56..0000000 --- a/queries/esims.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Server-truth eSIM and order state. - * Authoritative React Query hooks for device eSIM documents and terminal order history. - * - * Distinct from queries/e-sims.ts, which owns catalogue (plan-discovery) queries. - * The two query namespaces are independent: - * - queries/e-sims.ts -> catalogue plans (key root: "esims") - * - queries/esims.ts -> device eSIM docs (key root: "device-esims" / "device-orders") - * - * 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, - }; -} From ededf6c4cc09487d673e9d476a262e512c07f784 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 06:17:42 +0530 Subject: [PATCH 30/54] migrate device esim hook from /queries --- app/(tabs)/orders.tsx | 2 +- components/home/active-esim-scroll.tsx | 2 +- hooks/useDeviceEsims.ts | 112 +++++++++++++++++++++++++ providers/index.tsx | 2 +- screens/checkout/Checkout.tsx | 2 +- scripts/scan-console.mjs | 1 - scripts/validate-release-pr.mjs | 1 - 7 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 hooks/useDeviceEsims.ts diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx index 898f4dd..42ca262 100644 --- a/app/(tabs)/orders.tsx +++ b/app/(tabs)/orders.tsx @@ -23,7 +23,7 @@ import type { OrderListItem } from "@/utils/bff/order"; import { labelForStatus, colorForStatus } from "@/utils/orderStatus"; import ESIMItem from "@/components/ESIMItem"; import type { Esim } from "@/components/ESIMItem"; -import { useEsims, useOrders } from "@/queries/esims"; +import { useEsims, useOrders } from "@/hooks/useDeviceEsims"; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/components/home/active-esim-scroll.tsx b/components/home/active-esim-scroll.tsx index a4bc2c3..7cddc4d 100644 --- a/components/home/active-esim-scroll.tsx +++ b/components/home/active-esim-scroll.tsx @@ -5,7 +5,7 @@ import _isEmpty from "lodash/isEmpty"; import { Theme } from "@/constants/Colors"; import { useTheme } from "@/contexts/ThemeContext"; -import { useEsims } from "@/queries/esims"; +import { useEsims } from "@/hooks/useDeviceEsims"; import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; import type { Esim } from "@/components/ESIMItem"; import ESIMItem from "@/components/ESIMItem"; 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/providers/index.tsx b/providers/index.tsx index 1bae69e..9d9d9b0 100644 --- a/providers/index.tsx +++ b/providers/index.tsx @@ -16,7 +16,7 @@ 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 '@/queries/esims'; +import { DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY } from '@/hooks/useDeviceEsims'; // ─── Persisted query keys ────────────────────────────────────────────────────── const PERSISTED_KEYS: Set = new Set([DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY]); diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index e63455e..99be639 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -27,7 +27,7 @@ import DetailItem from "@/components/ui/DetailItem"; import Checkbox from "@/components/ui/Checkbox"; import { Esim } from "@/components/ESIMItem"; import { getEsimOrderPayload } from "@/helpers/esimOrder"; -import { useEsims, DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY } from '@/queries/esims'; +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"; diff --git a/scripts/scan-console.mjs b/scripts/scan-console.mjs index 7fba9b3..80da5c4 100644 --- a/scripts/scan-console.mjs +++ b/scripts/scan-console.mjs @@ -22,7 +22,6 @@ const SOURCE_DIRS = [ "components", "hooks", "providers", - "queries", "screens", "services", "stores", 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/", From 2d660dce6b4645de746fa0270be349461f9bdda9 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 09:17:00 +0530 Subject: [PATCH 31/54] add esim usage metric integration --- app/_layout.tsx | 2 + app/coverage-modal.tsx | 246 ++++++++++ app/esim-detail.tsx | 650 +++++++++++++++++++++++++ components/home/active-esim-scroll.tsx | 6 +- constants/route.constants.ts | 48 +- hooks/useEsimUsage.ts | 46 ++ utils/bff/esim.ts | 18 +- 7 files changed, 989 insertions(+), 27 deletions(-) create mode 100644 app/coverage-modal.tsx create mode 100644 app/esim-detail.tsx create mode 100644 hooks/useEsimUsage.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 09cc267..6b5e8ce 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -160,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/components/home/active-esim-scroll.tsx b/components/home/active-esim-scroll.tsx index 7cddc4d..28fdae8 100644 --- a/components/home/active-esim-scroll.tsx +++ b/components/home/active-esim-scroll.tsx @@ -111,9 +111,9 @@ const ActiveESIMsScroll = () => { // Uses esimId as the expand key — orders.tsx matches on esimId. const handleESIMPress = useCallback((doc: ESimDocument) => { return () => { - router.navigate({ - pathname: "/(tabs)/orders", - params: { expandOrderId: doc.esimId }, + router.push({ + pathname: "/esim-detail", + params: { esimId: doc.esimId }, }); }; }, []); 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/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/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; From 624ea2083a9dda69e29060f5a3aca617f2cb2183 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 09:26:38 +0530 Subject: [PATCH 32/54] enable direct install on iOS --- screens/esimInstallation/EsimInstallation.tsx | 82 +++++++++++++------ 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index 64b2a77..917333b 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -1,5 +1,7 @@ -import React, { useRef, useState, useMemo } from "react"; +import React, { useRef, useState, useMemo, useCallback } from "react"; import { + Linking, + Platform, View, Text, StyleSheet, @@ -246,7 +248,8 @@ const createStyles = () => StyleSheet.create({ const EsimInstallation = () => { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); - const { qrcode } = useLocalSearchParams(); + 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; @@ -391,29 +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 }); + } + }, [appleInstallationUrl]); + + 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"]; From edaf73d363e0d8a664d40dfacdf9019a25249837 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 10:07:41 +0530 Subject: [PATCH 33/54] add tap capability for expansion toggle --- components/checkoutHeader/CheckoutHeader.tsx | 51 ++++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index e8d1555..b91a562 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -146,16 +146,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", @@ -219,29 +217,42 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { 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, { + 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 combinedGesture = Gesture.Simultaneous(tapGesture, panGesture); + const onContentLayout = (event: any) => setContentHeight(_get(event, "nativeEvent.layout.height")); @@ -359,7 +370,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { })); return ( - + Date: Fri, 10 Jul 2026 10:08:04 +0530 Subject: [PATCH 34/54] remove direct link from dependency array --- screens/esimInstallation/EsimInstallation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index 917333b..dc95262 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -401,7 +401,7 @@ const EsimInstallation = () => { } catch (err) { logger.error('ESIM_APPLE_INSTALL_FAILED', { err }); } - }, [appleInstallationUrl]); + }, []); const isEnabled = Platform.OS === "ios" && !!appleInstallationUrl; From 9376bd67eac0605635ddb558e24244080d8a6f7d Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Fri, 10 Jul 2026 21:54:33 +0530 Subject: [PATCH 35/54] bugfix: only recover wallet if it is actually deployed --- providers/kokioProvider.tsx | 54 ++++++++------ screens/checkout/Checkout.tsx | 20 ++++- utils/wallet/checkWalletDeployed.ts | 109 ++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 utils/wallet/checkWalletDeployed.ts diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index 5ef41e1..bf57080 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -2,7 +2,7 @@ import React, { ReactNode, createContext, useEffect, useReducer, useRef } from " 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"; @@ -15,6 +15,7 @@ import { setPendingProposal, } from "@/utils/walletconnect/signClient"; import { logger } from "@/utils/logger"; +import { checkWalletDeployed } from "@/utils/wallet/checkWalletDeployed"; const extra = Constants.expoConfig?.extra as AppExtraConfig; @@ -251,37 +252,52 @@ export const KokioProvider: React.FC = ({ children }) => { /** * ── SDK initialisation + wallet auto-derivation ─────────────────────────── * - * This effect fires whenever the SDK, deviceUID, or userPasskey changes. - * After the SDK is ready it also derives and persists the SmartContractAccount - * when userWallet is absent — covering two cases: - * 1. Normal cold boot: userWallet was loaded from SecureStore in the hydration effect above, - * so kokio.userWallet is already set and the derivation block is skipped. + * 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. * - * 2. Account recovery after reinstall: setupKokioRecovery populates all derivation material - * and sets the SDK, but userWallet is absent because the SmartContractAccount was never persisted. - * The derivation block runs here, producing the address for the wallet card without a biometric prompt. + * 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(() => { const initSdkAndDeriveWallet = async () => { if (!kokio.sdk && kokio.deviceUID && kokio.userPasskey) { await setupKokio(); - // setupKokio dispatches SET_KOKIO which will re-trigger this effect with kokio.sdk populated. - // Return here to avoid racing the dispatch. return; } - if ( kokio.sdk && kokio.deviceUID && kokio.userPasskey?.x && kokio.userPasskey?.y && kokio.rawSalt && + kokio.deviceWalletAddress && !kokio.userWallet ) { try { + const deployed = await checkWalletDeployed( + kokio.deviceWalletAddress, + kokio.sdk.viemWalletClient, + kokio.sdk.constants?.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, @@ -291,6 +307,7 @@ export const KokioProvider: React.FC = ({ children }) => { 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 }); } } @@ -298,7 +315,7 @@ export const KokioProvider: React.FC = ({ children }) => { initSdkAndDeriveWallet(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kokio.deviceUID, kokio.userPasskey, kokio.sdk, kokio.rawSalt, kokio.userWallet]); + }, [kokio.deviceUID, kokio.userPasskey, kokio.sdk, kokio.rawSalt, kokio.userWallet, kokio.deviceWalletAddress]); // ── WalletConnect initialisation ────────────────────────────────────────── @@ -442,11 +459,6 @@ export const KokioProvider: React.FC = ({ children }) => { await SecureStore.setItemAsync('credentialId', credentialId); dispatch({ type: 'SET_DEVICE_WALLET_ADDRESS', payload: deviceWalletAddress }); - // Fetch the wallet-derivation material (deviceUID/pubKeyX/pubKeyY/salt) needed - // to reconstruct the smart account after this reinstall. Must happen now, right - // after the fresh passkey assertion recovery just completed — GET /account - // requires step-up, and this is what satisfies its 5-minute recency window - // without prompting the user for a second biometric confirmation. const account = await getAccount(); await saveValueForDeviceUID('deviceUID', account.deviceUniqueIdentifier); @@ -460,10 +472,8 @@ export const KokioProvider: React.FC = ({ children }) => { type: 'SET_KOKIO_PASSKEY', payload: { credentialId, x: account.pubKeyX as Hex, y: account.pubKeyY as Hex }, }); - // userWallet is intentionally NOT set here. The SDK-init useEffect above - // detects that sdk is ready + userWallet is absent and calls - // getSmartWallet() automatically once the SDK is initialised. This avoids - // racing setupKokio() which fires concurrently on the same state change. + // userWallet is intentionally NOT set here. + // The initSdkAndDeriveWallet useEffect calls checkWalletDeployed once the SDK is ready. }; const clearKokio = () => { diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 99be639..0d4c065 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -226,6 +226,7 @@ const Checkout = () => { const [isDiscountApplied, setIsDiscountApplied] = useState(false); const [discountAmount, setDiscountAmount] = useState(0); const [orderResponse, setOrderResponse] = useState(null); + const [topupSuccessInfo, setTopupSuccessInfo] = useState<{ fromLabel: string; toLabel: string } | null>(null); const [discountError, setDiscountError] = useState(""); const [showManualReviewModal, setShowManualReviewModal] = useState(false); const [failedOrderInfo, setFailedOrderInfo] = useState<{ @@ -329,6 +330,14 @@ const Checkout = () => { 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; } @@ -346,7 +355,7 @@ const Checkout = () => { order.orderStatus === 'ABANDONED' ? 'Order expired. Please try again.' : 'Order could not be completed. Please try again.'; showMessage(msg, 'info'); - }, [queryClient, showMessage]); + }, [queryClient, showMessage, applyAsTopup, compatibleTopUpEsimId, eSimItem, buildTopupEsimLabel]); const handleRemoveDiscount = useCallback(() => { setIsDiscountApplied(false); @@ -481,6 +490,11 @@ const Checkout = () => { }); }, [orderResponse]); + const handleTopupDone = useCallback(() => { + setShowSuccessModal(false); + router.replace("/"); + }, []); + const handleWalletModalClose = useCallback(() => { setShowWalletSetupModal(false); setPendingPaymentMethod(null); @@ -724,7 +738,11 @@ const Checkout = () => { = { + [baseSepolia.id]: '0xCa447f5C75C57f6C59027304A5Fb5A09F0E005c9', + [base.id]: '0x', +}; + +// ─── 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 chainId = await viemWalletClient.getChainId(); + const useRegistryAddress = registryAddress ?? REGISTRY_BY_CHAIN_ID[chainId]; + + if (!useRegistryAddress || useRegistryAddress === '0x') { + logger.debug('WALLET_DEPLOY_CHECK_SKIPPED', { + chainId, + reason: 'registry_not_deployed', + }); + return false; + } + + const chain = viemWalletClient.chain ?? baseSepolia; + const transportUrl = (viemWalletClient.transport as { url?: string }).url; + const publicClient = createPublicClient({ + chain, + transport: http(transportUrl), + }); + + const isValid = await publicClient.readContract({ + address: useRegistryAddress, + abi: IS_DEVICE_WALLET_VALID_ABI, + functionName: 'isDeviceWalletValid', + args: [deviceWalletAddress as Address], + }); + + 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; + } +} From 7522564ef20c65b2e34e4b0541667312f42192ac Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 11:58:54 +0530 Subject: [PATCH 36/54] fix routing bugs --- screens/checkout/Checkout.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 0d4c065..5efeb73 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -226,6 +226,7 @@ const Checkout = () => { 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); @@ -278,7 +279,7 @@ const Checkout = () => { checkErrors, } = useEsimCompatibility( { planId: eSimItem?.catalogueId }, - { enabled: hasPriorEsim }, + { enabled: hasPriorEsim && !orderCompleted }, ); const isTopupCompatible = compatibleEsims.length > 0; const [applyAsTopup, setApplyAsTopup] = useState(false); @@ -327,6 +328,7 @@ const Checkout = () => { return; } if (isOrderSuccess(order.orderStatus)) { + setOrderCompleted(true); queryClient.invalidateQueries({ queryKey: [DEVICE_ESIMS_KEY] }); queryClient.invalidateQueries({ queryKey: [DEVICE_ORDERS_KEY] }); setOrderResponse(order); @@ -479,6 +481,7 @@ const Checkout = () => { const handleInstallESIM = useCallback(() => { setShowSuccessModal(false); + router.dismissAll(); router.navigate({ pathname: "/(tabs)/installation", params: { @@ -492,7 +495,8 @@ const Checkout = () => { const handleTopupDone = useCallback(() => { setShowSuccessModal(false); - router.replace("/"); + router.dismissAll(); + router.navigate("/(tabs)"); }, []); const handleWalletModalClose = useCallback(() => { From d0cc85194247747f42c3fc96be5c40464404bd6d Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 11:59:52 +0530 Subject: [PATCH 37/54] bugfix: expansion card not opening fully --- components/checkoutHeader/CheckoutHeader.tsx | 61 ++++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index b91a562..a1528c5 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, View, Text, Dimensions, Platform, Pressable, ScrollView } from "react-native"; import { Ionicons } from "@expo/vector-icons"; @@ -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 = (Platform.OS === "android" ? 150 : 200) + 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,9 +30,11 @@ 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]); @@ -41,11 +45,11 @@ const ExpandableContent = ({ showsVerticalScrollIndicator={false} scrollEnabled={true} contentContainerStyle={{ gap: 12 }} + onContentSizeChange={onContentSizeChange} > {ESIM_EXTRA_DETAILS.map((item, index) => { const value = _get(eSimItem, item.key); - // Suppress rows that opt in to hiding when the field is absent or null. if (item.hideWhenNullish && (value === null || value === undefined)) { return null; } @@ -194,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] @@ -212,11 +225,6 @@ 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) { @@ -227,7 +235,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { }); } else { isExpanded.value = true; - animatedHeight.value = withTiming(headerMaxHeight, { + animatedHeight.value = withTiming(headerMaxHeight.value, { duration: 250, easing: Easing.out(Easing.ease), }); @@ -253,9 +261,6 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const combinedGesture = Gesture.Simultaneous(tapGesture, panGesture); - const onContentLayout = (event: any) => - setContentHeight(_get(event, "nativeEvent.layout.height")); - const handleBack = () => { if (navigation.canGoBack()) { navigation.goBack(); @@ -297,8 +302,6 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { )} ), - // styles have their own memo watching for changes based on theme - // eslint-disable-next-line react-hooks/exhaustive-deps [handleBack, eSimItem] ); @@ -327,8 +330,6 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { /> ), - // styles have their own memo watching for changes based on theme - // eslint-disable-next-line react-hooks/exhaustive-deps [eSimItem] ); @@ -336,11 +337,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] ), })); @@ -348,7 +348,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const animatedContentStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [0, 1] ), })); @@ -356,7 +356,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const animatedArrowDownStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [1, 0] ), })); @@ -364,7 +364,7 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const animatedArrowUpStyle = useAnimatedStyle(() => ({ opacity: interpolate( animatedHeight.value, - [computedHeaderHeight, headerMaxHeight], + [computedHeaderHeight, headerMaxHeight.value], [0, 1] ), })); @@ -378,7 +378,6 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { animatedHeaderStyle, ]} > - {/* Country + flag + back */} { {detailItems} - {/* Pill drag handle */} @@ -405,19 +403,18 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { - {/* Expandable details */} - - - + { + contentHeight.value = height; + }} + /> ); }; - export default React.memo(CheckoutHeader); From 4d2079fd111534746cc4dedef4dc895d1230b981 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 18:48:21 +0530 Subject: [PATCH 38/54] package updates --- package-lock.json | 1677 ++------------------------------------------- package.json | 2 +- 2 files changed, 52 insertions(+), 1627 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a358a8..d38b69d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "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", @@ -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", @@ -1808,9 +1780,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "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", "dependencies": { @@ -1820,7 +1792,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1843,733 +1815,35 @@ "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" - } - }, - "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" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@ethersproject/bytes": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.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, - "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" - } - }, - "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, - "dependencies": { - "@ethersproject/bignumber": "^5.8.0", - "@ethersproject/constants": "^5.8.0", - "@ethersproject/logger": "^5.8.0" + }, + "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/@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" - } - ], - "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" - } - ], - "license": "MIT", - "optional": true, + "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", @@ -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", @@ -5663,87 +4908,6 @@ "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", - "dependencies": { - "@segment/isodate": "^1.0.3" - } - }, "node_modules/@simplewebauthn/server": { "version": "13.3.0", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz", @@ -5787,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", @@ -5920,16 +4971,6 @@ } } }, - "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, - "dependencies": { - "tslib": "^2.8.0" - } - }, "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", @@ -6062,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", @@ -6235,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", @@ -6297,9 +5311,9 @@ } }, "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": { @@ -6563,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": { @@ -7361,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", @@ -7380,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", @@ -7410,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", @@ -8074,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", @@ -8125,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", @@ -8201,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", @@ -8375,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", @@ -8430,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", @@ -9173,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", @@ -9381,28 +8284,15 @@ "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "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" + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9635,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", @@ -9858,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": { @@ -9956,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", @@ -10429,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", @@ -10531,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", @@ -11318,25 +10112,6 @@ "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" - } - }, "node_modules/fast-base64-decode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", @@ -11392,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", @@ -12279,16 +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/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -12949,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", @@ -13026,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", @@ -13152,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", @@ -14078,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", @@ -14094,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", @@ -14241,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", @@ -14335,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", @@ -15634,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", @@ -15669,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", @@ -15726,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", @@ -15885,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", @@ -19086,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", @@ -19262,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", @@ -19509,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" @@ -19890,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", @@ -19910,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", @@ -20116,13 +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/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -20156,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", @@ -20352,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", @@ -20634,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", @@ -20752,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", @@ -20822,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", @@ -21196,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", @@ -21463,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", @@ -21864,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", diff --git a/package.json b/package.json index a73088c..c4c638c 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "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", From 43492907b3421046840aeb36610ead713c15b556 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 18:48:53 +0530 Subject: [PATCH 39/54] theme performance improvement --- app/(tabs)/(shop)/coverage.tsx | 10 +-- .../(wallet)/(contacts)/qrCodeScreen.tsx | 7 +- .../(wallet)/(contacts)/sendToContact.tsx | 7 +- app/(tabs)/orders.tsx | 9 +-- app/(tabs)/settings.tsx | 15 ++-- app/Offline.tsx | 7 +- app/coverage-modal.tsx | 14 ++-- app/esim-detail.tsx | 12 +--- app/wc-session.tsx | 7 +- components/AuthenticationModal.tsx | 4 +- components/ESIMItem.tsx | 5 +- components/EsimItemSkeleton.tsx | 7 +- components/ServiceStatusBanner.tsx | 6 +- components/StepUpPromptModal.tsx | 7 +- components/ThemedText.tsx | 9 +-- components/amountInput/AmountInput.tsx | 7 +- components/amountInput/CheckoutInput.tsx | 7 +- components/checkoutHeader/CheckoutHeader.tsx | 7 +- components/home/active-esim-scroll.tsx | 7 +- components/home/hero.tsx | 5 +- components/home/wallet.tsx | 7 +- components/ui/Avatar.tsx | 7 +- components/ui/Card.tsx | 6 +- components/ui/CheckoutSuccessModal.tsx | 3 +- components/ui/OrderFailureModal.tsx | 7 +- components/ui/WalletSetupModal.tsx | 5 +- constants/Colors.ts | 68 +++++++++---------- screens/checkout/Checkout.tsx | 5 +- screens/checkout/components/radioLabels.tsx | 15 +--- screens/esimInstallation/EsimInstallation.tsx | 53 +++++++-------- 30 files changed, 111 insertions(+), 224 deletions(-) diff --git a/app/(tabs)/(shop)/coverage.tsx b/app/(tabs)/(shop)/coverage.tsx index 92dd977..4447542 100644 --- a/app/(tabs)/(shop)/coverage.tsx +++ b/app/(tabs)/(shop)/coverage.tsx @@ -7,7 +7,6 @@ import { } from "react-native"; import { useLocalSearchParams } from "expo-router"; 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"; @@ -20,8 +19,7 @@ type CountryNetworkEntry = { const SEARCH_THRESHOLD = 3; -const createStyles = () => - StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, paddingTop: 12, @@ -90,10 +88,8 @@ const createStyles = () => const CoverageRow = ({ entry, - styles, }: { entry: CountryNetworkEntry; - styles: ReturnType; }) => { const networks = entry.networks ?? []; return ( @@ -130,8 +126,6 @@ const CoverageRow = ({ }; export default function CoverageScreen() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const bg = useThemeColor({}, "background"); const { data: rawData } = useLocalSearchParams<{ data: string }>(); const [query, setQuery] = useState(""); @@ -172,7 +166,7 @@ export default function CoverageScreen() { i.toString()} - renderItem={({ item }) => } + renderItem={({ item }) => } style={styles.list} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" diff --git a/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx b/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx index e2e9ece..3a307f8 100644 --- a/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx +++ b/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx @@ -1,13 +1,12 @@ import { ThemedText } from '@/components/ThemedText'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { router, useLocalSearchParams } from 'expo-router'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } 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({ +const styles = StyleSheet.create({ container: { flex: 1, }, @@ -101,8 +100,6 @@ const createStyles = () => StyleSheet.create({ }); export default function QrCodeScreen() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const [permission, requestPermission] = useCameraPermissions(); const [permissionDenied, setPermissionDenied] = useState(false); const [scanned, setScanned] = useState(false); diff --git a/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx b/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx index 60f4b5b..5fd101d 100644 --- a/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx +++ b/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx @@ -11,7 +11,6 @@ import _ from 'lodash'; 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 { @@ -43,7 +42,7 @@ interface Transaction { transactions: Transaction[]; } -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ contentContainer: { backgroundColor: Theme.colors.background, padding: 0, @@ -52,8 +51,6 @@ const createStyles = () => StyleSheet.create({ }) const SendToContact = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const params = useLocalSearchParams(); const [amount, setAmount] = useState("0"); const [token, setToken] = useState(null); @@ -61,10 +58,8 @@ const SendToContact = () => { const [isLoading,setIsLoading] = useState(false); const { showToast, showMessage } = useToast(); - const sheetRef = useRef(null); - const snapPoints = useMemo(() => ['96.5%', '97%'], []); const handleShowSheet = () => { sheetRef.current?.snapToIndex(1); // Snap to the first snap point (65%) diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx index 42ca262..3879944 100644 --- a/app/(tabs)/orders.tsx +++ b/app/(tabs)/orders.tsx @@ -14,7 +14,6 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; 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"; @@ -61,8 +60,7 @@ function toDisplayItem(doc: ESimDocument): Esim { // ─── Styles ─────────────────────────────────────────────────────────────────── -const createStyles = () => - StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, padding: 10, @@ -369,9 +367,6 @@ const OrderCard = ({ isExpanded: boolean; onToggle: () => void; }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); - // const router = useRouter(); const [showPurchaseDetails, setShowPurchaseDetails] = useState(false); const statusColor = colorForStatus(order.orderStatus); @@ -481,8 +476,6 @@ const OrderCard = ({ // ─── OrdersScreen ───────────────────────────────────────────────────────────── export default function OrdersScreen() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const router = useRouter(); const bg = useThemeColor({}, "background"); const { expandOrderId } = useLocalSearchParams<{ expandOrderId?: string }>(); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index eb05510..5638beb 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,11 +1,10 @@ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback } from "react"; import { StyleSheet, FlatList, TouchableOpacity, View, ScrollView, - // Switch, Text, Switch, Linking, @@ -22,7 +21,7 @@ import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { SafeAreaView } from "react-native-safe-area-context"; import { logger } from "@/utils/logger"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, padding: 10, @@ -144,8 +143,7 @@ const MenuItem = ({ action: (() => void) | undefined; disabled?: boolean; }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); + useTheme(); return ( void }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); + useTheme(); const handleLinkPress = useCallback(async (url: string) => { try { await openBrowserAsync(url); @@ -238,8 +235,7 @@ const AboutContent = ({ onClose }: { onClose: () => void }) => { }; const ContactContent = ({ onClose }: { onClose: () => void }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); + useTheme(); return ( @@ -283,7 +279,6 @@ export default function MenuScreen() { const [showAbout, setShowAbout] = useState(false); const [showContact, setShowContact] = useState(false); const bg = useThemeColor({}, "background"); - const styles = useMemo(createStyles, [isDark]); const menuItems = [ { diff --git a/app/Offline.tsx b/app/Offline.tsx index 3ea7e8d..ed8e038 100644 --- a/app/Offline.tsx +++ b/app/Offline.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback } from "react"; import { View, Text, @@ -11,11 +11,10 @@ import _isNull from "lodash/isNull"; import NetInfo from "@react-native-community/netinfo"; import { ThemedText } from "@/components/ThemedText"; import { Colors, Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import { useToast } from "@/contexts/ToastContext"; import { setSkipNextOfflineRedirect } from "@/utils/offlineRedirectFlag"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: Theme.colors.background, @@ -79,8 +78,6 @@ const createStyles = () => StyleSheet.create({ }); const OfflineScreen: React.FC = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const router = useRouter(); const { showMessage } = useToast(); diff --git a/app/coverage-modal.tsx b/app/coverage-modal.tsx index ba150b7..053ddce 100644 --- a/app/coverage-modal.tsx +++ b/app/coverage-modal.tsx @@ -11,7 +11,6 @@ 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"; @@ -30,8 +29,7 @@ const SEARCH_THRESHOLD = 3; // ─── Styles ─────────────────────────────────────────────────────────────────── -const createStyles = () => - StyleSheet.create({ +const styles = StyleSheet.create({ safeArea: { flex: 1, }, @@ -122,10 +120,8 @@ const createStyles = () => const CoverageRow = ({ entry, - styles, }: { entry: CountryNetworkEntry; - styles: ReturnType; }) => { const networks = entry.networks ?? []; return ( @@ -161,10 +157,8 @@ const CoverageRow = ({ // ─── Screen ─────────────────────────────────────────────────────────────────── export default function CoverageModal() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); - const bg = useThemeColor({}, "background"); - const router = useRouter(); + const bg = useThemeColor({}, "background"); + const router = useRouter(); const { data: rawData } = useLocalSearchParams<{ data: string }>(); const [query, setQuery] = useState(""); @@ -229,7 +223,7 @@ export default function CoverageModal() { data={filtered} keyExtractor={(_, i) => i.toString()} renderItem={({ item }) => ( - + )} style={styles.list} showsVerticalScrollIndicator={false} diff --git a/app/esim-detail.tsx b/app/esim-detail.tsx index 9f18ef9..8c10b23 100644 --- a/app/esim-detail.tsx +++ b/app/esim-detail.tsx @@ -17,7 +17,6 @@ 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"; @@ -77,8 +76,7 @@ const BUNDLE_COLOR: Record = { // ─── Styles ─────────────────────────────────────────────────────────────────── -const createStyles = () => - StyleSheet.create({ +const styles = StyleSheet.create({ safeArea: { flex: 1 }, header: { flexDirection: "row", @@ -239,7 +237,6 @@ const createStyles = () => // ─── Sub-components ─────────────────────────────────────────────────────────── const Chip = ({ label, color }: { label: string; color: string }) => { - const styles = useMemo(createStyles, []); return ( {label} @@ -256,7 +253,6 @@ const CopyRow = ({ value: string; last?: boolean; }) => { - const styles = useMemo(createStyles, []); const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -289,10 +285,8 @@ const CopyRow = ({ // ─── Screen ─────────────────────────────────────────────────────────────────── export default function EsimDetailScreen() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); - const bg = useThemeColor({}, "background"); - const router = useRouter(); + const bg = useThemeColor({}, "background"); + const router = useRouter(); const { esimId } = useLocalSearchParams<{ esimId: string }>(); diff --git a/app/wc-session.tsx b/app/wc-session.tsx index d9877bd..bb8eb49 100644 --- a/app/wc-session.tsx +++ b/app/wc-session.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState } from "react"; import { ActivityIndicator, Image, @@ -9,7 +9,6 @@ import { import { useRouter } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import { useKokio } from "@/hooks/useKokio"; import { Config } from "@/appKeys"; import { @@ -19,7 +18,7 @@ import { } from "@/utils/walletconnect/signClient"; import { logger } from "@/utils/logger"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: Theme.colors.background, @@ -97,8 +96,6 @@ const createStyles = () => StyleSheet.create({ }); export default function WcSessionScreen() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const router = useRouter(); const { kokio } = useKokio(); const [loading, setLoading] = useState(false); diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index c8cd4c3..8125845 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -24,8 +24,7 @@ import { logger } from '@/utils/logger'; type AuthMode = "choice" | "authenticating" | "error"; -const createStyles = () => - StyleSheet.create({ +const styles = StyleSheet.create({ kokioImage: { height: 60, marginTop: 10, @@ -128,7 +127,6 @@ const createStyles = () => export function AuthenticationModal() { const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const [mode, setMode] = useState("choice"); const sheetRef = useRef(null); diff --git a/components/ESIMItem.tsx b/components/ESIMItem.tsx index c914656..e899386 100644 --- a/components/ESIMItem.tsx +++ b/components/ESIMItem.tsx @@ -3,7 +3,6 @@ import { router } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import CountryFlag from "@/components/ui/CountryFlag"; import DetailItem from "./ui/DetailItem"; @@ -43,8 +42,6 @@ const ESIMItem = ({ containerStyle?: object; onPress?: () => void; }) => { - const { isDark } = useTheme(); - const handleBuyCTAClick = useCallback( (id: string) => () => { router.navigate({ @@ -122,7 +119,7 @@ const ESIMItem = ({ // isDark is required here, useTheme() does not change the element on regional, global and homepage // It only works on the local tab of the shop // eslint-disable-next-line react-hooks/exhaustive-deps - [item, showBuyButton, handleBuyCTAClick, isDark] + [item, showBuyButton, handleBuyCTAClick] ); if (onPress) { diff --git a/components/EsimItemSkeleton.tsx b/components/EsimItemSkeleton.tsx index 1ac2b4c..07c32aa 100644 --- a/components/EsimItemSkeleton.tsx +++ b/components/EsimItemSkeleton.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import { View, StyleSheet } from "react-native"; import Animated, { useSharedValue, @@ -8,9 +8,8 @@ import Animated, { withSequence, } from "react-native-reanimated"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ esimItemContainer: { marginTop: Theme.spacing.lg, }, @@ -64,8 +63,6 @@ const EsimItemSkeleton = ({ }: { containerStyle?: object; }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const opacity = useSharedValue(0.3); // Create a pulsing animation diff --git a/components/ServiceStatusBanner.tsx b/components/ServiceStatusBanner.tsx index 0c3feb9..234997a 100644 --- a/components/ServiceStatusBanner.tsx +++ b/components/ServiceStatusBanner.tsx @@ -1,13 +1,11 @@ -import { useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ThemedText } from '@/components/ThemedText'; import { useBffHealth } from '@/hooks/useBffHealth'; import { Theme } from '@/constants/Colors'; -import { useTheme } from '@/contexts/ThemeContext'; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ banner: { backgroundColor: Theme.colors.warning, flexDirection: 'row', @@ -25,8 +23,6 @@ const createStyles = () => StyleSheet.create({ }); export function ServiceStatusBanner() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const { isHealthy } = useBffHealth(); const insets = useSafeAreaInsets(); diff --git a/components/StepUpPromptModal.tsx b/components/StepUpPromptModal.tsx index 53877e5..9774b13 100644 --- a/components/StepUpPromptModal.tsx +++ b/components/StepUpPromptModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { ActivityIndicator, Modal, @@ -10,9 +10,8 @@ import { } from "react-native"; import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlayMedium, @@ -125,8 +124,6 @@ function friendlyOperation(raw: string | undefined): string { } export function StepUpPromptModal() { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const { stepUpVisible, stepUpHint, stepUpError, stepUp, dismissStepUp } = useAuthRelay(); const [loading, setLoading] = useState(false); diff --git a/components/ThemedText.tsx b/components/ThemedText.tsx index 55f76f4..d064773 100644 --- a/components/ThemedText.tsx +++ b/components/ThemedText.tsx @@ -7,7 +7,7 @@ export type ThemedTextProps = TextProps & { variant?: "xsm" | "xsmb" | "sm" | "smb" | "normal" | "xl" | "xxl"; className?: string; bold?: boolean; - light?:boolean + light?: boolean; lightColor?: string; darkColor?: string; }; @@ -28,11 +28,8 @@ export function ThemedText({ return ( StyleSheet.create({ +const styles = StyleSheet.create({ labelText: { color: Theme.colors.foreground, fontSize: 14, @@ -43,8 +42,6 @@ const AmountInput = ({ value: number | null; onChangeValue: (num: number | null) => void; }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( diff --git a/components/amountInput/CheckoutInput.tsx b/components/amountInput/CheckoutInput.tsx index 0dce6f4..9bdc01b 100644 --- a/components/amountInput/CheckoutInput.tsx +++ b/components/amountInput/CheckoutInput.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import { TextInput, StyleSheet, @@ -9,14 +9,13 @@ import { } from "react-native"; import { ThemedText } from "../ThemedText"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; interface CheckoutInputProps extends TextInputProps { label: string; containerStyle?: StyleProp; } -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ blurContainer: { borderRadius: 20, overflow: "hidden", @@ -51,8 +50,6 @@ const CheckoutInput: React.FC = ({ value, ...props }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( {label} diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index a1528c5..a6b51a5 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -15,7 +15,6 @@ import Animated, { } from "react-native-reanimated"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import { ESIM_EXTRA_DETAILS } from "@/constants/checkout.constants"; import CountryFlag from "@/components/ui/CountryFlag"; @@ -36,8 +35,6 @@ const ExpandableContent = ({ onNetworkPress: () => void; onContentSizeChange?: (w: number, h: number) => void; }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const isMultiCountry = eSimItem?.coverageType !== "LOCAL"; return ( @@ -106,7 +103,7 @@ const ExpandableContent = ({ ); }; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ header: { borderBottomLeftRadius: 16, borderBottomRightRadius: 16, @@ -176,8 +173,6 @@ const createStyles = () => StyleSheet.create({ }); const CheckoutHeader = ({ eSimDetails = {} }: any) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const insets = useSafeAreaInsets(); const computedHeaderHeight = useMemo(() => { diff --git a/components/home/active-esim-scroll.tsx b/components/home/active-esim-scroll.tsx index 28fdae8..4b53a75 100644 --- a/components/home/active-esim-scroll.tsx +++ b/components/home/active-esim-scroll.tsx @@ -4,7 +4,6 @@ import { router } from "expo-router"; import _isEmpty from "lodash/isEmpty"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import { useEsims } from "@/hooks/useDeviceEsims"; import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; import type { Esim } from "@/components/ESIMItem"; @@ -22,8 +21,7 @@ const SPACING = 8; // ─── Styles ─────────────────────────────────────────────────────────────────── -const createStyles = () => - StyleSheet.create({ +const styles = StyleSheet.create({ container: { marginVertical: 12, }, @@ -97,9 +95,6 @@ function toDisplayItem(doc: ESimDocument): Esim { // No props — self-fetching via useEsims(). const ActiveESIMsScroll = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); - const { esims, isLoading } = useEsims(); const activeEsims = useMemo( diff --git a/components/home/hero.tsx b/components/home/hero.tsx index f2ef8f6..c278b68 100644 --- a/components/home/hero.tsx +++ b/components/home/hero.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback } from "react"; import { View, Text, @@ -14,7 +14,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { Card, CardFooter } from "../ui/Card"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ backgroundImageContainer: { overflow: "hidden", width: "100%", @@ -68,7 +68,6 @@ const createStyles = () => StyleSheet.create({ const Hero = () => { const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const handleShopCTAClick = useCallback(() => { router.navigate("/(tabs)/(shop)"); }, []); diff --git a/components/home/wallet.tsx b/components/home/wallet.tsx index 66e71bd..59e9484 100644 --- a/components/home/wallet.tsx +++ b/components/home/wallet.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import { StyleSheet, Image, @@ -13,7 +13,6 @@ import { LinearGradient } from "expo-linear-gradient"; import * as Linking from "expo-linking"; import { MaterialIcons, Ionicons } from "@expo/vector-icons"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import { BASE_SEPOLIA_TESTNET } from "@/constants/general.constants"; import { ThemedView } from "../ThemedView"; import { ThemedText } from "../ThemedText"; @@ -34,7 +33,7 @@ const shortenId = ( return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; }; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ headingText: { fontSize: 16, paddingLeft: 20, @@ -135,8 +134,6 @@ const createStyles = () => StyleSheet.create({ }); const Wallet = ({ balance, walletId, isWalletAdded, onSetupWallet }: WalletProps) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const handleAddressPress = async () => { if (walletId) { const url = `${BASE_SEPOLIA_TESTNET}/${walletId}`; diff --git a/components/ui/Avatar.tsx b/components/ui/Avatar.tsx index 072c221..e1ff0f9 100644 --- a/components/ui/Avatar.tsx +++ b/components/ui/Avatar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import { View, Text, @@ -7,9 +7,8 @@ import { ImageSourcePropType, } from "react-native"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ container: { justifyContent: "center", alignItems: "center", @@ -31,8 +30,6 @@ const Avatar = ({ name: string; size?: number; }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const twoLetters = name ? name.charAt(0).toUpperCase() + (name.charAt(1) || "").toLowerCase() : "NA"; diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx index 7afe25b..d442545 100644 --- a/components/ui/Card.tsx +++ b/components/ui/Card.tsx @@ -3,13 +3,11 @@ import { View as DefaultView, StyleSheet, } from "react-native"; -import { useMemo } from "react"; import type { ThemedTextProps } from "@/components/ThemedText"; import type { ThemedViewProps } from "@/components/ThemedView"; import { Theme } from "@/constants/Colors"; import { useThemeColor } from "@/hooks/useThemeColor"; -import { useTheme } from "@/contexts/ThemeContext"; // Static layout styles — no Theme.colors, safe at module level const staticStyles = StyleSheet.create({ @@ -36,7 +34,7 @@ const staticStyles = StyleSheet.create({ }, }); -const createCardStyle = () => StyleSheet.create({ +const cardStyle = StyleSheet.create({ card: { width: "100%", backgroundColor: Theme.colors.card, @@ -46,8 +44,6 @@ const createCardStyle = () => StyleSheet.create({ function Card(props: ThemedViewProps) { const { style, lightColor, darkColor, children, ...otherProps } = props; - const { isDark } = useTheme(); - const cardStyle = useMemo(createCardStyle, [isDark]); const backgroundColor = useThemeColor( { light: lightColor, dark: darkColor }, "card" diff --git a/components/ui/CheckoutSuccessModal.tsx b/components/ui/CheckoutSuccessModal.tsx index cef32ca..d731cb8 100644 --- a/components/ui/CheckoutSuccessModal.tsx +++ b/components/ui/CheckoutSuccessModal.tsx @@ -33,7 +33,7 @@ interface CheckoutSuccessModalProps { topupToLabel?: string; } -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlay, @@ -119,7 +119,6 @@ const CheckoutSuccessModal: React.FC = ({ topupToLabel, }) => { const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const textColor = useThemeColor({}, "text"); const scale = useSharedValue(0); diff --git a/components/ui/OrderFailureModal.tsx b/components/ui/OrderFailureModal.tsx index ff99c85..ab107e3 100644 --- a/components/ui/OrderFailureModal.tsx +++ b/components/ui/OrderFailureModal.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import { Modal, StyleSheet, TouchableOpacity, View } from "react-native"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; @@ -6,7 +6,6 @@ import { router } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; import { labelForStatus } from "@/utils/orderStatus"; interface OrderFailureModalProps { @@ -25,7 +24,7 @@ const REASON_LABELS: Record = { WALLET_REGISTRATION_FAILED:'Device wallet registration failed.', }; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlay, @@ -118,8 +117,6 @@ const OrderFailureModal: React.FC = ({ manualReviewReason, onDismiss, }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const [copied, setCopied] = useState(false); const handleCopy = async () => { diff --git a/components/ui/WalletSetupModal.tsx b/components/ui/WalletSetupModal.tsx index 88b76b1..e51eb06 100644 --- a/components/ui/WalletSetupModal.tsx +++ b/components/ui/WalletSetupModal.tsx @@ -16,7 +16,6 @@ import { type Hex } from "viem"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; import { useThemeColor } from "@/hooks/useThemeColor"; -import { useTheme } from "@/contexts/ThemeContext"; import { BASE_SEPOLIA_TESTNET } from "@/constants/general.constants"; import { useKokio } from "@/hooks/useKokio"; import { useToast } from "@/contexts/ToastContext"; @@ -39,7 +38,7 @@ const formatWalletAddress = ( return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; }; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlay, @@ -215,8 +214,6 @@ const WalletSetupModal: React.FC = ({ onClose, onContinue, }) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const [isLoading, setIsLoading] = useState(false); const [showRecovery, setShowRecovery] = useState(false); const [showRetry, setShowRetry] = useState(false); diff --git a/constants/Colors.ts b/constants/Colors.ts index 1f208fc..9999537 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -1,42 +1,41 @@ -// styles.ts import { StyleSheet } from "react-native"; const tintColorLight = "#2A8FA0"; export const Colors = { light: { - text: "#1A3D4F", // deep navy - background: "#A8D8E8", // sky blue — app bg + text: "#1A3D4F", + background: "#A8D8E8", tint: tintColorLight, icon: "#2A8FA0", tabIconDefault: "#7ABCCC", tabIconSelected: tintColorLight, - headerText: "#4A8090", - foreground: "#4A8090", // slate — secondary text - card: "#FFFFFF", // white — main cards + headerText: "#1A3D4F", + foreground: "#4A8090", + card: "#FFFFFF", cardForeground: "#1A3D4F", popover: "#FFFFFF", popoverForeground: "#1A3D4F", - primary: "#2A8FA0", // deep teal + primary: "#2A8FA0", primaryForeground: "#FFFFFF", - secondary: "#E8614A", // coral — CTA + secondary: "#E8614A", secondaryForeground: "#FFFFFF", - muted: "#C5E8F2", // sky muted - mutedForeground: "#7ABCCC", - accent: "#2A8FA0", // deep teal — icons/active + muted: "#8AB8C7", + mutedForeground: "#4A8090", + accent: "#2A8FA0", accentForeground: "#FFFFFF", destructive: "#FF453A", destructiveForeground: "#FFFFFF", - border: "#C5E8F2", - input: "#EEF8FC", // cloud white — inputs + border: "#9BCBD9", + input: "#EEF8FC", ring: "#2A8FA0", inactive: "#9BA1A6", }, dark: { - text: "white", - background: "#000", - tint: "#fff", + text: "#F2F2F7", + background: "#000000", + tint: "#FFFFFF", headerText: "#9BA1A6", icon: "#9BA1A6", tabIconDefault: "#9BA1A6", @@ -45,7 +44,6 @@ export const Colors = { secondaryBackground: "#242427", inactive: "#777777", - // background: '#ffffff', foreground: "#AEAEB2", card: "#FFD60A", @@ -92,16 +90,9 @@ const EXTRA_TOKENS = { overlayDark: "rgba(0, 0, 0, 0.7)", handle: "rgba(60, 60, 67, 0.2)", handleArrow: "rgba(60, 60, 67, 0.8)", - info: "#64D2FF", - link: "#4A9EFF", gradientDark: "#404040", - modalBackground: "rgba(60, 60, 60, 0.9)", - contentBackground: "rgba(100, 100, 100, 0.9)", - itemBackground: "#1c1c1e", warning: "#FF9500", pink: "#FF2D55", - systemBlue: "#007AFF", - sheetBackground: "rgba(37, 37, 37, 0.95)", }; const DARK_TOKENS = { @@ -109,13 +100,20 @@ const DARK_TOKENS = { ...EXTRA_TOKENS, background: "#242427", inputBackground: "#7676803D", - surface: "#1a1a1a", + surface: "#1C1C1E", surfaceElevated: "#2a2a2a", skeletonBase: "#5C5C61", skeletonHighlight: "#E0E0E0", - shopCta: "#FFAF01", // dark: keeps current goldenYellow - payButton: "#FFD60A", // dark: keeps current secondary - walletModalBackground: "rgba(60, 60, 60, 0.9)", // dark: same as modalBackground + shopCta: "#FFAF01", + payButton: "#FFD60A", + info: "#64D2FF", + link: "#4A9EFF", + systemBlue: "#0A84FF", + modalBackground: "rgba(28, 28, 30, 0.9)", + contentBackground: "rgba(36, 36, 39, 0.9)", + itemBackground: "#1C1C1E", + sheetBackground: "rgba(37, 37, 37, 0.95)", + walletModalBackground: "rgba(60, 60, 60, 0.9)", }; const LIGHT_TOKENS = { @@ -128,15 +126,17 @@ const LIGHT_TOKENS = { skeletonBase: "#C5E8F2", skeletonHighlight: "#EEF8FC", destructive: "#FF453A", - secondaryBackground: "#FFFFFF", // white tab bar - goldenYellow: "#E8614A", // coral — no yellow in light theme - shopCta: "#7ABCCC", // light: muted sky blue - payButton: "#FFFFFF", // light: white + secondaryBackground: "#FFFFFF", + goldenYellow: "#E8614A", + shopCta: "#2A8FA0", + payButton: "#FFFFFF", cardForeground: "#1A3D4F", - highlight: "#E8614A", // coral active tab tint + highlight: "#E8614A", inactive: "#7ABCCC", link: "#2A8FA0", - gradientDark: "#FFFFFF", // wallet card gradient start: white → sky + info: "#065A76", + systemBlue: "#007AFF", + gradientDark: "#FFFFFF", modalBackground: "rgba(255, 255, 255, 0.95)", walletModalBackground: "rgba(168, 216, 232, 0.75)", contentBackground: "#FFFFFF", diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 5efeb73..a3f6309 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -22,7 +22,6 @@ import _toUpper from "lodash/toUpper"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; import { useThemeColor } from "@/hooks/useThemeColor"; -import { useTheme } from "@/contexts/ThemeContext"; import DetailItem from "@/components/ui/DetailItem"; import Checkbox from "@/components/ui/Checkbox"; import { Esim } from "@/components/ESIMItem"; @@ -118,7 +117,7 @@ const ExternalWalletCheckout = ({ return null; }; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1 }, scrollContent: { flex: 1, paddingHorizontal: 12 }, scrollContentContainer: { paddingBottom: 20 }, @@ -199,8 +198,6 @@ function esimDocToDisplayItem(doc: ESimDocument): Esim { } const Checkout = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const { item: eSimDetails } = useLocalSearchParams(); const eSimItem: Esim = React.useMemo(() => { diff --git a/screens/checkout/components/radioLabels.tsx b/screens/checkout/components/radioLabels.tsx index 1baec01..6e63898 100644 --- a/screens/checkout/components/radioLabels.tsx +++ b/screens/checkout/components/radioLabels.tsx @@ -1,11 +1,10 @@ -import React, { useMemo } from "react"; +import React from "react"; import { Image, StyleSheet, View } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; -import { useTheme } from "@/contexts/ThemeContext"; -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ labelContainer: { flex: 1, marginLeft: 8, @@ -34,8 +33,6 @@ const createStyles = () => StyleSheet.create({ }); const ESimWallet = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( Device Wallet @@ -47,8 +44,6 @@ const ESimWallet = () => { }; const CreditCard = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( Credit Card @@ -61,8 +56,6 @@ const CreditCard = () => { }; const ApplePay = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( Apple Pay @@ -75,8 +68,6 @@ const ApplePay = () => { }; const ExternalWallet = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( External Wallet @@ -89,8 +80,6 @@ const ExternalWallet = () => { }; const ExternalWalletBrowser = () => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); return ( External Wallet (via Moonpay) diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index dc95262..03359b7 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useMemo, useCallback } from "react"; +import React, { useRef, useState, useCallback } from "react"; import { Linking, Platform, @@ -93,32 +93,7 @@ const warningStyles = StyleSheet.create({ }, }); -const TextWithCopy = ({ label, text }: {label: string, text: string}) => { - const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); - const handleCopyQRData = async () => { - try { - await Clipboard.setStringAsync(text); - } catch (error) { - logger.error('CLIPBOARD_COPY_FAILED', { error }); - } - }; - return ( - - {label} - - - {text} - - - - - - - ); -}; - -const createStyles = () => StyleSheet.create({ +const styles = StyleSheet.create({ container: { flex: 1, }, @@ -245,9 +220,31 @@ const createStyles = () => StyleSheet.create({ }, }); +const TextWithCopy = ({ label, text }: {label: string, text: string}) => { + const handleCopyQRData = async () => { + try { + await Clipboard.setStringAsync(text); + } catch (error) { + logger.error('CLIPBOARD_COPY_FAILED', { error }); + } + }; + return ( + + {label} + + + {text} + + + + + + + ); +}; + const EsimInstallation = () => { const { isDark } = useTheme(); - const styles = useMemo(createStyles, [isDark]); const { qrcode, appleInstallationUrl: rawAppleUrl } = useLocalSearchParams(); const appleInstallationUrl = Array.isArray(rawAppleUrl) ? rawAppleUrl[0] : (rawAppleUrl ?? ""); const router = useRouter(); From f6b3b9836a267fc1a4f721b2f8ed6b5df7b06a2b Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 19:17:48 +0530 Subject: [PATCH 40/54] read ABI and registry address directly from kokio-sdk --- providers/kokioProvider.tsx | 3 +- utils/wallet/checkWalletDeployed.ts | 55 ++++------------------------- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index bf57080..3482d8c 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -283,10 +283,11 @@ export const KokioProvider: React.FC = ({ children }) => { !kokio.userWallet ) { try { + const KokioConstants = await kokio.sdk.constants; const deployed = await checkWalletDeployed( kokio.deviceWalletAddress, kokio.sdk.viemWalletClient, - kokio.sdk.constants?.factoryAddresses?.REGISTRY as Address, + KokioConstants.factoryAddresses.REGISTRY as Address, ); if (!deployed) { diff --git a/utils/wallet/checkWalletDeployed.ts b/utils/wallet/checkWalletDeployed.ts index 49023cc..07ddbfa 100644 --- a/utils/wallet/checkWalletDeployed.ts +++ b/utils/wallet/checkWalletDeployed.ts @@ -8,7 +8,6 @@ * 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. - * TODO: Remove this if the abis are exported via the SDK in the future. */ import { @@ -17,39 +16,9 @@ import { type WalletClient, type Address, } from 'viem'; -import { base, baseSepolia } from 'viem/chains'; +import { Registry } from 'kokio-sdk/abis'; import { logger } from '@/utils/logger'; -// ─── ABI fragment ───────────────────────────────────────────────────────────── -// Source: kokio-sdk src/abis/Registry.ts — isDeviceWalletValid only. - -const IS_DEVICE_WALLET_VALID_ABI = [ - { - inputs: [{ - internalType: 'address', - name: 'deviceWalletAddress', - type: 'address' - }], - name: 'isDeviceWalletValid', - outputs:[{ - internalType: 'bool', - name: 'valid', - type: 'bool' - }], - stateMutability: 'view', - type: 'function', - }, -] as const; - -// ─── Registry address map ───────────────────────────────────────────────────── -// Sourced: kokio-sdk src/logic/constants.ts -// TODO: Remove if available from SDK - -const REGISTRY_BY_CHAIN_ID: Record = { - [baseSepolia.id]: '0xCa447f5C75C57f6C59027304A5Fb5A09F0E005c9', - [base.id]: '0x', -}; - // ─── Public API ─────────────────────────────────────────────────────────────── /** @@ -70,33 +39,23 @@ const REGISTRY_BY_CHAIN_ID: Record = { export async function checkWalletDeployed( deviceWalletAddress: string, viemWalletClient: WalletClient, - registryAddress?: Address, + registryAddress: Address, ): Promise { try { - const chainId = await viemWalletClient.getChainId(); - const useRegistryAddress = registryAddress ?? REGISTRY_BY_CHAIN_ID[chainId]; - - if (!useRegistryAddress || useRegistryAddress === '0x') { - logger.debug('WALLET_DEPLOY_CHECK_SKIPPED', { - chainId, - reason: 'registry_not_deployed', - }); - return false; - } - - const chain = viemWalletClient.chain ?? baseSepolia; + 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: useRegistryAddress, - abi: IS_DEVICE_WALLET_VALID_ABI, + address: registryAddress, + abi: Registry, functionName: 'isDeviceWalletValid', args: [deviceWalletAddress as Address], - }); + }) as boolean; logger.debug('WALLET_DEPLOY_CHECK', { deployed: isValid }); return isValid; From 2ccfafd0b40d9026fab5e02f823333e0a3096e6d Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 21:03:21 +0530 Subject: [PATCH 41/54] add logger check --- .github/workflows/quality-gate.yml | 3 +++ 1 file changed, 3 insertions(+) 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. From 7f63d3bb13e1b134c005693a0ee5896f1f4fd795 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sat, 11 Jul 2026 21:03:46 +0530 Subject: [PATCH 42/54] fix lint warnings --- components/AuthenticationModal.tsx | 2 +- components/ESIMItem.tsx | 3 --- components/checkoutHeader/CheckoutHeader.tsx | 6 +++--- components/ui/CheckoutSuccessModal.tsx | 6 ------ screens/checkout/Checkout.tsx | 1 - 5 files changed, 4 insertions(+), 14 deletions(-) diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index 8125845..b5d9fcb 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -310,7 +310,7 @@ export function AuthenticationModal() { )} ), - [styles, mode] + [mode] ); return ( diff --git a/components/ESIMItem.tsx b/components/ESIMItem.tsx index e899386..5ee1324 100644 --- a/components/ESIMItem.tsx +++ b/components/ESIMItem.tsx @@ -116,9 +116,6 @@ const ESIMItem = ({ ), - // isDark is required here, useTheme() does not change the element on regional, global and homepage - // It only works on the local tab of the shop - // eslint-disable-next-line react-hooks/exhaustive-deps [item, showBuyButton, handleBuyCTAClick] ); diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index a6b51a5..bdc72db 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, View, Text, Dimensions, Platform, Pressable, ScrollView } from "react-native"; import { Ionicons } from "@expo/vector-icons"; @@ -256,11 +256,11 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { const combinedGesture = Gesture.Simultaneous(tapGesture, panGesture); - const handleBack = () => { + const handleBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); } - }; + }, [navigation]); const countryAndFlagWithGoBack = useMemo( () => ( diff --git a/components/ui/CheckoutSuccessModal.tsx b/components/ui/CheckoutSuccessModal.tsx index d731cb8..ef09920 100644 --- a/components/ui/CheckoutSuccessModal.tsx +++ b/components/ui/CheckoutSuccessModal.tsx @@ -156,8 +156,6 @@ const CheckoutSuccessModal: React.FC = ({ ), - // styles have their own memo watching for changes based on theme - // eslint-disable-next-line react-hooks/exhaustive-deps [] ); @@ -194,8 +192,6 @@ const CheckoutSuccessModal: React.FC = ({ )} ), - // styles have their own memo watching for changes based on theme - // eslint-disable-next-line react-hooks/exhaustive-deps [animatedIconStyle, textColor, variant, topupFromLabel, topupToLabel] ); @@ -210,8 +206,6 @@ const CheckoutSuccessModal: React.FC = ({ ), - // styles have their own memo watching for changes based on theme - // eslint-disable-next-line react-hooks/exhaustive-deps [onInstallESIM, onDone, variant] ); diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index a3f6309..827706f 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -235,7 +235,6 @@ const Checkout = () => { const radioButtons: RadioButtonProps[] = useMemo( () => createRadioButtons(selectedPaymentMethod, styles.buttonStyle), - // eslint-disable-next-line react-hooks/exhaustive-deps [selectedPaymentMethod], ); From da273d6bed710edd2458f0fadd8d50e7b80976c0 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sun, 12 Jul 2026 16:27:50 +0530 Subject: [PATCH 43/54] theme fixes --- app/(tabs)/(shop)/coverage.tsx | 10 ++- .../(wallet)/(contacts)/qrCodeScreen.tsx | 7 +- .../(wallet)/(contacts)/sendToContact.tsx | 7 +- app/(tabs)/orders.tsx | 9 ++- app/(tabs)/settings.tsx | 47 +++++++------ app/Offline.tsx | 7 +- app/coverage-modal.tsx | 14 ++-- app/esim-detail.tsx | 12 +++- app/wc-session.tsx | 7 +- components/AuthenticationModal.tsx | 5 +- components/ESIMItem.tsx | 8 ++- components/EsimItemSkeleton.tsx | 7 +- components/ServiceStatusBanner.tsx | 6 +- components/StepUpPromptModal.tsx | 7 +- components/ThemedText.tsx | 9 ++- components/amountInput/AmountInput.tsx | 7 +- components/amountInput/CheckoutInput.tsx | 7 +- components/checkoutHeader/CheckoutHeader.tsx | 9 ++- components/home/active-esim-scroll.tsx | 7 +- components/home/hero.tsx | 5 +- components/home/wallet.tsx | 7 +- components/ui/Avatar.tsx | 7 +- components/ui/Card.tsx | 6 +- components/ui/CheckoutSuccessModal.tsx | 3 +- components/ui/OrderFailureModal.tsx | 7 +- components/ui/WalletSetupModal.tsx | 5 +- constants/Colors.ts | 68 +++++++++---------- screens/checkout/Checkout.tsx | 5 +- screens/checkout/components/radioLabels.tsx | 15 +++- screens/esimInstallation/EsimInstallation.tsx | 53 ++++++++------- 30 files changed, 246 insertions(+), 127 deletions(-) diff --git a/app/(tabs)/(shop)/coverage.tsx b/app/(tabs)/(shop)/coverage.tsx index 4447542..92dd977 100644 --- a/app/(tabs)/(shop)/coverage.tsx +++ b/app/(tabs)/(shop)/coverage.tsx @@ -7,6 +7,7 @@ import { } from "react-native"; import { useLocalSearchParams } from "expo-router"; 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"; @@ -19,7 +20,8 @@ type CountryNetworkEntry = { const SEARCH_THRESHOLD = 3; -const styles = StyleSheet.create({ +const createStyles = () => + StyleSheet.create({ container: { flex: 1, paddingTop: 12, @@ -88,8 +90,10 @@ const styles = StyleSheet.create({ const CoverageRow = ({ entry, + styles, }: { entry: CountryNetworkEntry; + styles: ReturnType; }) => { const networks = entry.networks ?? []; return ( @@ -126,6 +130,8 @@ const CoverageRow = ({ }; export default function CoverageScreen() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const bg = useThemeColor({}, "background"); const { data: rawData } = useLocalSearchParams<{ data: string }>(); const [query, setQuery] = useState(""); @@ -166,7 +172,7 @@ export default function CoverageScreen() { i.toString()} - renderItem={({ item }) => } + renderItem={({ item }) => } style={styles.list} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" diff --git a/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx b/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx index 3a307f8..e2e9ece 100644 --- a/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx +++ b/app/(tabs)/(wallet)/(contacts)/qrCodeScreen.tsx @@ -1,12 +1,13 @@ import { ThemedText } from '@/components/ThemedText'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { router, useLocalSearchParams } from 'expo-router'; -import { useState, useEffect } from 'react'; +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 styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ container: { flex: 1, }, @@ -100,6 +101,8 @@ const styles = StyleSheet.create({ }); export default function QrCodeScreen() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const [permission, requestPermission] = useCameraPermissions(); const [permissionDenied, setPermissionDenied] = useState(false); const [scanned, setScanned] = useState(false); diff --git a/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx b/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx index 5fd101d..60f4b5b 100644 --- a/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx +++ b/app/(tabs)/(wallet)/(contacts)/sendToContact.tsx @@ -11,6 +11,7 @@ import _ from 'lodash'; 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 { @@ -42,7 +43,7 @@ interface Transaction { transactions: Transaction[]; } -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ contentContainer: { backgroundColor: Theme.colors.background, padding: 0, @@ -51,6 +52,8 @@ const styles = StyleSheet.create({ }) const SendToContact = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const params = useLocalSearchParams(); const [amount, setAmount] = useState("0"); const [token, setToken] = useState(null); @@ -58,8 +61,10 @@ const SendToContact = () => { const [isLoading,setIsLoading] = useState(false); const { showToast, showMessage } = useToast(); + const sheetRef = useRef(null); + const snapPoints = useMemo(() => ['96.5%', '97%'], []); const handleShowSheet = () => { sheetRef.current?.snapToIndex(1); // Snap to the first snap point (65%) diff --git a/app/(tabs)/orders.tsx b/app/(tabs)/orders.tsx index 3879944..42ca262 100644 --- a/app/(tabs)/orders.tsx +++ b/app/(tabs)/orders.tsx @@ -14,6 +14,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; 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"; @@ -60,7 +61,8 @@ function toDisplayItem(doc: ESimDocument): Esim { // ─── Styles ─────────────────────────────────────────────────────────────────── -const styles = StyleSheet.create({ +const createStyles = () => + StyleSheet.create({ container: { flex: 1, padding: 10, @@ -367,6 +369,9 @@ const OrderCard = ({ isExpanded: boolean; onToggle: () => void; }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); + // const router = useRouter(); const [showPurchaseDetails, setShowPurchaseDetails] = useState(false); const statusColor = colorForStatus(order.orderStatus); @@ -476,6 +481,8 @@ const OrderCard = ({ // ─── OrdersScreen ───────────────────────────────────────────────────────────── export default function OrdersScreen() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const router = useRouter(); const bg = useThemeColor({}, "background"); const { expandOrderId } = useLocalSearchParams<{ expandOrderId?: string }>(); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 5638beb..2878231 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { StyleSheet, FlatList, @@ -21,7 +21,7 @@ import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { SafeAreaView } from "react-native-safe-area-context"; import { logger } from "@/utils/logger"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ container: { flex: 1, padding: 10, @@ -49,9 +49,11 @@ const styles = StyleSheet.create({ flex: 1, }, iconLeft: { + color: Theme.colors.icon, marginRight: 16, }, iconRight: { + color: Theme.colors.icon, marginLeft: 16, }, aboutContainer: { @@ -73,9 +75,11 @@ const styles = 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 +128,10 @@ const styles = 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, @@ -143,7 +146,8 @@ const MenuItem = ({ action: (() => void) | undefined; disabled?: boolean; }) => { - useTheme(); + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( void }) => { - useTheme(); + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const handleLinkPress = useCallback(async (url: string) => { try { await openBrowserAsync(url); @@ -193,7 +198,7 @@ const AboutContent = ({ onClose }: { onClose: () => void }) => { About - + void }) => { }; const ContactContent = ({ onClose }: { onClose: () => void }) => { - useTheme(); + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( Contact Support - + @@ -253,7 +259,7 @@ const ContactContent = ({ onClose }: { onClose: () => void }) => { Email Us - contact@kokio.app + contact@kokio.app void }) => { Telegram - + @@ -279,21 +285,22 @@ export default function MenuScreen() { const [showAbout, setShowAbout] = useState(false); const [showContact, setShowContact] = useState(false); const bg = useThemeColor({}, "background"); + const styles = useMemo(createStyles, [isDark]); const menuItems = [ - { - id: "1", - title: "Contact", - iconLeft: "call-outline", - iconRight: "chevron-forward-outline", - disabled: !MENU_ITEM_ENABLED.CONTACT, - }, + //{ + // id: "1", + // title: "Contact", + // iconLeft: "call-outline", + // iconRight: "chevron-forward-outline", + // disabled: !MENU_ITEM_ENABLED.CONTACT, + //}, { id: "3", 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", diff --git a/app/Offline.tsx b/app/Offline.tsx index ed8e038..3ea7e8d 100644 --- a/app/Offline.tsx +++ b/app/Offline.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { View, Text, @@ -11,10 +11,11 @@ import _isNull from "lodash/isNull"; import NetInfo from "@react-native-community/netinfo"; import { ThemedText } from "@/components/ThemedText"; import { Colors, Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import { useToast } from "@/contexts/ToastContext"; import { setSkipNextOfflineRedirect } from "@/utils/offlineRedirectFlag"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ container: { flex: 1, backgroundColor: Theme.colors.background, @@ -78,6 +79,8 @@ const styles = StyleSheet.create({ }); const OfflineScreen: React.FC = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const router = useRouter(); const { showMessage } = useToast(); diff --git a/app/coverage-modal.tsx b/app/coverage-modal.tsx index 053ddce..ba150b7 100644 --- a/app/coverage-modal.tsx +++ b/app/coverage-modal.tsx @@ -11,6 +11,7 @@ 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"; @@ -29,7 +30,8 @@ const SEARCH_THRESHOLD = 3; // ─── Styles ─────────────────────────────────────────────────────────────────── -const styles = StyleSheet.create({ +const createStyles = () => + StyleSheet.create({ safeArea: { flex: 1, }, @@ -120,8 +122,10 @@ const styles = StyleSheet.create({ const CoverageRow = ({ entry, + styles, }: { entry: CountryNetworkEntry; + styles: ReturnType; }) => { const networks = entry.networks ?? []; return ( @@ -157,8 +161,10 @@ const CoverageRow = ({ // ─── Screen ─────────────────────────────────────────────────────────────────── export default function CoverageModal() { - const bg = useThemeColor({}, "background"); - const router = useRouter(); + 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(""); @@ -223,7 +229,7 @@ export default function CoverageModal() { data={filtered} keyExtractor={(_, i) => i.toString()} renderItem={({ item }) => ( - + )} style={styles.list} showsVerticalScrollIndicator={false} diff --git a/app/esim-detail.tsx b/app/esim-detail.tsx index 8c10b23..9f18ef9 100644 --- a/app/esim-detail.tsx +++ b/app/esim-detail.tsx @@ -17,6 +17,7 @@ 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"; @@ -76,7 +77,8 @@ const BUNDLE_COLOR: Record = { // ─── Styles ─────────────────────────────────────────────────────────────────── -const styles = StyleSheet.create({ +const createStyles = () => + StyleSheet.create({ safeArea: { flex: 1 }, header: { flexDirection: "row", @@ -237,6 +239,7 @@ const styles = StyleSheet.create({ // ─── Sub-components ─────────────────────────────────────────────────────────── const Chip = ({ label, color }: { label: string; color: string }) => { + const styles = useMemo(createStyles, []); return ( {label} @@ -253,6 +256,7 @@ const CopyRow = ({ value: string; last?: boolean; }) => { + const styles = useMemo(createStyles, []); const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -285,8 +289,10 @@ const CopyRow = ({ // ─── Screen ─────────────────────────────────────────────────────────────────── export default function EsimDetailScreen() { - const bg = useThemeColor({}, "background"); - const router = useRouter(); + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); + const bg = useThemeColor({}, "background"); + const router = useRouter(); const { esimId } = useLocalSearchParams<{ esimId: string }>(); diff --git a/app/wc-session.tsx b/app/wc-session.tsx index bb8eb49..d9877bd 100644 --- a/app/wc-session.tsx +++ b/app/wc-session.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { ActivityIndicator, Image, @@ -9,6 +9,7 @@ import { import { useRouter } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import { useKokio } from "@/hooks/useKokio"; import { Config } from "@/appKeys"; import { @@ -18,7 +19,7 @@ import { } from "@/utils/walletconnect/signClient"; import { logger } from "@/utils/logger"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ container: { flex: 1, backgroundColor: Theme.colors.background, @@ -96,6 +97,8 @@ const styles = StyleSheet.create({ }); export default function WcSessionScreen() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const router = useRouter(); const { kokio } = useKokio(); const [loading, setLoading] = useState(false); diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index b5d9fcb..57b545b 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -24,7 +24,8 @@ import { logger } from '@/utils/logger'; type AuthMode = "choice" | "authenticating" | "error"; -const styles = StyleSheet.create({ +const createStyles = () => + StyleSheet.create({ kokioImage: { height: 60, marginTop: 10, @@ -35,6 +36,7 @@ const styles = StyleSheet.create({ fontWeight: "300", fontFamily: "Lexend-Light", marginTop: 32, + paddingTop: 12, }, authSubtext: { fontSize: 13, @@ -127,6 +129,7 @@ const styles = StyleSheet.create({ export function AuthenticationModal() { const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const [mode, setMode] = useState("choice"); const sheetRef = useRef(null); diff --git a/components/ESIMItem.tsx b/components/ESIMItem.tsx index 5ee1324..c914656 100644 --- a/components/ESIMItem.tsx +++ b/components/ESIMItem.tsx @@ -3,6 +3,7 @@ import { router } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import CountryFlag from "@/components/ui/CountryFlag"; import DetailItem from "./ui/DetailItem"; @@ -42,6 +43,8 @@ const ESIMItem = ({ containerStyle?: object; onPress?: () => void; }) => { + const { isDark } = useTheme(); + const handleBuyCTAClick = useCallback( (id: string) => () => { router.navigate({ @@ -116,7 +119,10 @@ const ESIMItem = ({ ), - [item, showBuyButton, handleBuyCTAClick] + // isDark is required here, useTheme() does not change the element on regional, global and homepage + // It only works on the local tab of the shop + // eslint-disable-next-line react-hooks/exhaustive-deps + [item, showBuyButton, handleBuyCTAClick, isDark] ); if (onPress) { diff --git a/components/EsimItemSkeleton.tsx b/components/EsimItemSkeleton.tsx index 07c32aa..1ac2b4c 100644 --- a/components/EsimItemSkeleton.tsx +++ b/components/EsimItemSkeleton.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { View, StyleSheet } from "react-native"; import Animated, { useSharedValue, @@ -8,8 +8,9 @@ import Animated, { withSequence, } from "react-native-reanimated"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ esimItemContainer: { marginTop: Theme.spacing.lg, }, @@ -63,6 +64,8 @@ const EsimItemSkeleton = ({ }: { containerStyle?: object; }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const opacity = useSharedValue(0.3); // Create a pulsing animation diff --git a/components/ServiceStatusBanner.tsx b/components/ServiceStatusBanner.tsx index 234997a..0c3feb9 100644 --- a/components/ServiceStatusBanner.tsx +++ b/components/ServiceStatusBanner.tsx @@ -1,11 +1,13 @@ +import { useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ThemedText } from '@/components/ThemedText'; import { useBffHealth } from '@/hooks/useBffHealth'; import { Theme } from '@/constants/Colors'; +import { useTheme } from '@/contexts/ThemeContext'; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ banner: { backgroundColor: Theme.colors.warning, flexDirection: 'row', @@ -23,6 +25,8 @@ const styles = StyleSheet.create({ }); export function ServiceStatusBanner() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const { isHealthy } = useBffHealth(); const insets = useSafeAreaInsets(); diff --git a/components/StepUpPromptModal.tsx b/components/StepUpPromptModal.tsx index 9774b13..53877e5 100644 --- a/components/StepUpPromptModal.tsx +++ b/components/StepUpPromptModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Modal, @@ -10,8 +10,9 @@ import { } from "react-native"; import { useAuthRelay } from "@/hooks/useAuthRelayer"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlayMedium, @@ -124,6 +125,8 @@ function friendlyOperation(raw: string | undefined): string { } export function StepUpPromptModal() { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const { stepUpVisible, stepUpHint, stepUpError, stepUp, dismissStepUp } = useAuthRelay(); const [loading, setLoading] = useState(false); diff --git a/components/ThemedText.tsx b/components/ThemedText.tsx index d064773..55f76f4 100644 --- a/components/ThemedText.tsx +++ b/components/ThemedText.tsx @@ -7,7 +7,7 @@ export type ThemedTextProps = TextProps & { variant?: "xsm" | "xsmb" | "sm" | "smb" | "normal" | "xl" | "xxl"; className?: string; bold?: boolean; - light?: boolean; + light?:boolean lightColor?: string; darkColor?: string; }; @@ -28,8 +28,11 @@ export function ThemedText({ return ( StyleSheet.create({ labelText: { color: Theme.colors.foreground, fontSize: 14, @@ -42,6 +43,8 @@ const AmountInput = ({ value: number | null; onChangeValue: (num: number | null) => void; }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( diff --git a/components/amountInput/CheckoutInput.tsx b/components/amountInput/CheckoutInput.tsx index 9bdc01b..0dce6f4 100644 --- a/components/amountInput/CheckoutInput.tsx +++ b/components/amountInput/CheckoutInput.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { TextInput, StyleSheet, @@ -9,13 +9,14 @@ import { } from "react-native"; import { ThemedText } from "../ThemedText"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; interface CheckoutInputProps extends TextInputProps { label: string; containerStyle?: StyleProp; } -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ blurContainer: { borderRadius: 20, overflow: "hidden", @@ -50,6 +51,8 @@ const CheckoutInput: React.FC = ({ value, ...props }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( {label} diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index bdc72db..16b6f30 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -15,13 +15,14 @@ import Animated, { } from "react-native-reanimated"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import { ESIM_EXTRA_DETAILS } from "@/constants/checkout.constants"; import CountryFlag from "@/components/ui/CountryFlag"; import DetailItem from "../ui/DetailItem"; const PILL_ROW_HEIGHT = 40; -const HEADER_MIN_HEIGHT = (Platform.OS === "android" ? 150 : 200) + PILL_ROW_HEIGHT; +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; @@ -35,6 +36,8 @@ const ExpandableContent = ({ onNetworkPress: () => void; onContentSizeChange?: (w: number, h: number) => void; }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const isMultiCountry = eSimItem?.coverageType !== "LOCAL"; return ( @@ -103,7 +106,7 @@ const ExpandableContent = ({ ); }; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ header: { borderBottomLeftRadius: 16, borderBottomRightRadius: 16, @@ -173,6 +176,8 @@ const styles = StyleSheet.create({ }); const CheckoutHeader = ({ eSimDetails = {} }: any) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const insets = useSafeAreaInsets(); const computedHeaderHeight = useMemo(() => { diff --git a/components/home/active-esim-scroll.tsx b/components/home/active-esim-scroll.tsx index 4b53a75..28fdae8 100644 --- a/components/home/active-esim-scroll.tsx +++ b/components/home/active-esim-scroll.tsx @@ -4,6 +4,7 @@ import { router } from "expo-router"; import _isEmpty from "lodash/isEmpty"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import { useEsims } from "@/hooks/useDeviceEsims"; import type { ESimDocument, PlanHistoryEntry } from "@/utils/bff/esim"; import type { Esim } from "@/components/ESIMItem"; @@ -21,7 +22,8 @@ const SPACING = 8; // ─── Styles ─────────────────────────────────────────────────────────────────── -const styles = StyleSheet.create({ +const createStyles = () => + StyleSheet.create({ container: { marginVertical: 12, }, @@ -95,6 +97,9 @@ function toDisplayItem(doc: ESimDocument): Esim { // No props — self-fetching via useEsims(). const ActiveESIMsScroll = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); + const { esims, isLoading } = useEsims(); const activeEsims = useMemo( diff --git a/components/home/hero.tsx b/components/home/hero.tsx index c278b68..f2ef8f6 100644 --- a/components/home/hero.tsx +++ b/components/home/hero.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { View, Text, @@ -14,7 +14,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { Card, CardFooter } from "../ui/Card"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ backgroundImageContainer: { overflow: "hidden", width: "100%", @@ -68,6 +68,7 @@ const styles = StyleSheet.create({ const Hero = () => { const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const handleShopCTAClick = useCallback(() => { router.navigate("/(tabs)/(shop)"); }, []); diff --git a/components/home/wallet.tsx b/components/home/wallet.tsx index 59e9484..66e71bd 100644 --- a/components/home/wallet.tsx +++ b/components/home/wallet.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { StyleSheet, Image, @@ -13,6 +13,7 @@ import { LinearGradient } from "expo-linear-gradient"; import * as Linking from "expo-linking"; import { MaterialIcons, Ionicons } from "@expo/vector-icons"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import { BASE_SEPOLIA_TESTNET } from "@/constants/general.constants"; import { ThemedView } from "../ThemedView"; import { ThemedText } from "../ThemedText"; @@ -33,7 +34,7 @@ const shortenId = ( return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; }; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ headingText: { fontSize: 16, paddingLeft: 20, @@ -134,6 +135,8 @@ const styles = StyleSheet.create({ }); const Wallet = ({ balance, walletId, isWalletAdded, onSetupWallet }: WalletProps) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const handleAddressPress = async () => { if (walletId) { const url = `${BASE_SEPOLIA_TESTNET}/${walletId}`; diff --git a/components/ui/Avatar.tsx b/components/ui/Avatar.tsx index e1ff0f9..072c221 100644 --- a/components/ui/Avatar.tsx +++ b/components/ui/Avatar.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { View, Text, @@ -7,8 +7,9 @@ import { ImageSourcePropType, } from "react-native"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ container: { justifyContent: "center", alignItems: "center", @@ -30,6 +31,8 @@ const Avatar = ({ name: string; size?: number; }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const twoLetters = name ? name.charAt(0).toUpperCase() + (name.charAt(1) || "").toLowerCase() : "NA"; diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx index d442545..7afe25b 100644 --- a/components/ui/Card.tsx +++ b/components/ui/Card.tsx @@ -3,11 +3,13 @@ import { View as DefaultView, StyleSheet, } from "react-native"; +import { useMemo } from "react"; import type { ThemedTextProps } from "@/components/ThemedText"; import type { ThemedViewProps } from "@/components/ThemedView"; import { Theme } from "@/constants/Colors"; import { useThemeColor } from "@/hooks/useThemeColor"; +import { useTheme } from "@/contexts/ThemeContext"; // Static layout styles — no Theme.colors, safe at module level const staticStyles = StyleSheet.create({ @@ -34,7 +36,7 @@ const staticStyles = StyleSheet.create({ }, }); -const cardStyle = StyleSheet.create({ +const createCardStyle = () => StyleSheet.create({ card: { width: "100%", backgroundColor: Theme.colors.card, @@ -44,6 +46,8 @@ const cardStyle = StyleSheet.create({ function Card(props: ThemedViewProps) { const { style, lightColor, darkColor, children, ...otherProps } = props; + const { isDark } = useTheme(); + const cardStyle = useMemo(createCardStyle, [isDark]); const backgroundColor = useThemeColor( { light: lightColor, dark: darkColor }, "card" diff --git a/components/ui/CheckoutSuccessModal.tsx b/components/ui/CheckoutSuccessModal.tsx index ef09920..9faa5f7 100644 --- a/components/ui/CheckoutSuccessModal.tsx +++ b/components/ui/CheckoutSuccessModal.tsx @@ -33,7 +33,7 @@ interface CheckoutSuccessModalProps { topupToLabel?: string; } -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlay, @@ -119,6 +119,7 @@ const CheckoutSuccessModal: React.FC = ({ topupToLabel, }) => { const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const textColor = useThemeColor({}, "text"); const scale = useSharedValue(0); diff --git a/components/ui/OrderFailureModal.tsx b/components/ui/OrderFailureModal.tsx index ab107e3..ff99c85 100644 --- a/components/ui/OrderFailureModal.tsx +++ b/components/ui/OrderFailureModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Modal, StyleSheet, TouchableOpacity, View } from "react-native"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import * as Clipboard from "expo-clipboard"; @@ -6,6 +6,7 @@ import { router } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; import { labelForStatus } from "@/utils/orderStatus"; interface OrderFailureModalProps { @@ -24,7 +25,7 @@ const REASON_LABELS: Record = { WALLET_REGISTRATION_FAILED:'Device wallet registration failed.', }; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlay, @@ -117,6 +118,8 @@ const OrderFailureModal: React.FC = ({ manualReviewReason, onDismiss, }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const [copied, setCopied] = useState(false); const handleCopy = async () => { diff --git a/components/ui/WalletSetupModal.tsx b/components/ui/WalletSetupModal.tsx index e51eb06..88b76b1 100644 --- a/components/ui/WalletSetupModal.tsx +++ b/components/ui/WalletSetupModal.tsx @@ -16,6 +16,7 @@ import { type Hex } from "viem"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; import { useThemeColor } from "@/hooks/useThemeColor"; +import { useTheme } from "@/contexts/ThemeContext"; import { BASE_SEPOLIA_TESTNET } from "@/constants/general.constants"; import { useKokio } from "@/hooks/useKokio"; import { useToast } from "@/contexts/ToastContext"; @@ -38,7 +39,7 @@ const formatWalletAddress = ( return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; }; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ overlay: { flex: 1, backgroundColor: Theme.colors.overlay, @@ -214,6 +215,8 @@ const WalletSetupModal: React.FC = ({ onClose, onContinue, }) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const [isLoading, setIsLoading] = useState(false); const [showRecovery, setShowRecovery] = useState(false); const [showRetry, setShowRetry] = useState(false); diff --git a/constants/Colors.ts b/constants/Colors.ts index 9999537..1f208fc 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -1,41 +1,42 @@ +// styles.ts import { StyleSheet } from "react-native"; const tintColorLight = "#2A8FA0"; export const Colors = { light: { - text: "#1A3D4F", - background: "#A8D8E8", + text: "#1A3D4F", // deep navy + background: "#A8D8E8", // sky blue — app bg tint: tintColorLight, icon: "#2A8FA0", tabIconDefault: "#7ABCCC", tabIconSelected: tintColorLight, - headerText: "#1A3D4F", - foreground: "#4A8090", - card: "#FFFFFF", + headerText: "#4A8090", + foreground: "#4A8090", // slate — secondary text + card: "#FFFFFF", // white — main cards cardForeground: "#1A3D4F", popover: "#FFFFFF", popoverForeground: "#1A3D4F", - primary: "#2A8FA0", + primary: "#2A8FA0", // deep teal primaryForeground: "#FFFFFF", - secondary: "#E8614A", + secondary: "#E8614A", // coral — CTA secondaryForeground: "#FFFFFF", - muted: "#8AB8C7", - mutedForeground: "#4A8090", - accent: "#2A8FA0", + muted: "#C5E8F2", // sky muted + mutedForeground: "#7ABCCC", + accent: "#2A8FA0", // deep teal — icons/active accentForeground: "#FFFFFF", destructive: "#FF453A", destructiveForeground: "#FFFFFF", - border: "#9BCBD9", - input: "#EEF8FC", + border: "#C5E8F2", + input: "#EEF8FC", // cloud white — inputs ring: "#2A8FA0", inactive: "#9BA1A6", }, dark: { - text: "#F2F2F7", - background: "#000000", - tint: "#FFFFFF", + text: "white", + background: "#000", + tint: "#fff", headerText: "#9BA1A6", icon: "#9BA1A6", tabIconDefault: "#9BA1A6", @@ -44,6 +45,7 @@ export const Colors = { secondaryBackground: "#242427", inactive: "#777777", + // background: '#ffffff', foreground: "#AEAEB2", card: "#FFD60A", @@ -90,9 +92,16 @@ const EXTRA_TOKENS = { overlayDark: "rgba(0, 0, 0, 0.7)", handle: "rgba(60, 60, 67, 0.2)", handleArrow: "rgba(60, 60, 67, 0.8)", + info: "#64D2FF", + link: "#4A9EFF", gradientDark: "#404040", + modalBackground: "rgba(60, 60, 60, 0.9)", + contentBackground: "rgba(100, 100, 100, 0.9)", + itemBackground: "#1c1c1e", warning: "#FF9500", pink: "#FF2D55", + systemBlue: "#007AFF", + sheetBackground: "rgba(37, 37, 37, 0.95)", }; const DARK_TOKENS = { @@ -100,20 +109,13 @@ const DARK_TOKENS = { ...EXTRA_TOKENS, background: "#242427", inputBackground: "#7676803D", - surface: "#1C1C1E", + surface: "#1a1a1a", surfaceElevated: "#2a2a2a", skeletonBase: "#5C5C61", skeletonHighlight: "#E0E0E0", - shopCta: "#FFAF01", - payButton: "#FFD60A", - info: "#64D2FF", - link: "#4A9EFF", - systemBlue: "#0A84FF", - modalBackground: "rgba(28, 28, 30, 0.9)", - contentBackground: "rgba(36, 36, 39, 0.9)", - itemBackground: "#1C1C1E", - sheetBackground: "rgba(37, 37, 37, 0.95)", - walletModalBackground: "rgba(60, 60, 60, 0.9)", + shopCta: "#FFAF01", // dark: keeps current goldenYellow + payButton: "#FFD60A", // dark: keeps current secondary + walletModalBackground: "rgba(60, 60, 60, 0.9)", // dark: same as modalBackground }; const LIGHT_TOKENS = { @@ -126,17 +128,15 @@ const LIGHT_TOKENS = { skeletonBase: "#C5E8F2", skeletonHighlight: "#EEF8FC", destructive: "#FF453A", - secondaryBackground: "#FFFFFF", - goldenYellow: "#E8614A", - shopCta: "#2A8FA0", - payButton: "#FFFFFF", + secondaryBackground: "#FFFFFF", // white tab bar + goldenYellow: "#E8614A", // coral — no yellow in light theme + shopCta: "#7ABCCC", // light: muted sky blue + payButton: "#FFFFFF", // light: white cardForeground: "#1A3D4F", - highlight: "#E8614A", + highlight: "#E8614A", // coral active tab tint inactive: "#7ABCCC", link: "#2A8FA0", - info: "#065A76", - systemBlue: "#007AFF", - gradientDark: "#FFFFFF", + gradientDark: "#FFFFFF", // wallet card gradient start: white → sky modalBackground: "rgba(255, 255, 255, 0.95)", walletModalBackground: "rgba(168, 216, 232, 0.75)", contentBackground: "#FFFFFF", diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 827706f..319ddd7 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -22,6 +22,7 @@ import _toUpper from "lodash/toUpper"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; import { useThemeColor } from "@/hooks/useThemeColor"; +import { useTheme } from "@/contexts/ThemeContext"; import DetailItem from "@/components/ui/DetailItem"; import Checkbox from "@/components/ui/Checkbox"; import { Esim } from "@/components/ESIMItem"; @@ -117,7 +118,7 @@ const ExternalWalletCheckout = ({ return null; }; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ container: { flex: 1 }, scrollContent: { flex: 1, paddingHorizontal: 12 }, scrollContentContainer: { paddingBottom: 20 }, @@ -198,6 +199,8 @@ function esimDocToDisplayItem(doc: ESimDocument): Esim { } const Checkout = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const { item: eSimDetails } = useLocalSearchParams(); const eSimItem: Esim = React.useMemo(() => { diff --git a/screens/checkout/components/radioLabels.tsx b/screens/checkout/components/radioLabels.tsx index 6e63898..1baec01 100644 --- a/screens/checkout/components/radioLabels.tsx +++ b/screens/checkout/components/radioLabels.tsx @@ -1,10 +1,11 @@ -import React from "react"; +import React, { useMemo } from "react"; import { Image, StyleSheet, View } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { Theme } from "@/constants/Colors"; +import { useTheme } from "@/contexts/ThemeContext"; -const styles = StyleSheet.create({ +const createStyles = () => StyleSheet.create({ labelContainer: { flex: 1, marginLeft: 8, @@ -33,6 +34,8 @@ const styles = StyleSheet.create({ }); const ESimWallet = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( Device Wallet @@ -44,6 +47,8 @@ const ESimWallet = () => { }; const CreditCard = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( Credit Card @@ -56,6 +61,8 @@ const CreditCard = () => { }; const ApplePay = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( Apple Pay @@ -68,6 +75,8 @@ const ApplePay = () => { }; const ExternalWallet = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( External Wallet @@ -80,6 +89,8 @@ const ExternalWallet = () => { }; const ExternalWalletBrowser = () => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); return ( External Wallet (via Moonpay) diff --git a/screens/esimInstallation/EsimInstallation.tsx b/screens/esimInstallation/EsimInstallation.tsx index 03359b7..dc95262 100644 --- a/screens/esimInstallation/EsimInstallation.tsx +++ b/screens/esimInstallation/EsimInstallation.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useCallback } from "react"; +import React, { useRef, useState, useMemo, useCallback } from "react"; import { Linking, Platform, @@ -93,7 +93,32 @@ const warningStyles = StyleSheet.create({ }, }); -const styles = StyleSheet.create({ +const TextWithCopy = ({ label, text }: {label: string, text: string}) => { + const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); + const handleCopyQRData = async () => { + try { + await Clipboard.setStringAsync(text); + } catch (error) { + logger.error('CLIPBOARD_COPY_FAILED', { error }); + } + }; + return ( + + {label} + + + {text} + + + + + + + ); +}; + +const createStyles = () => StyleSheet.create({ container: { flex: 1, }, @@ -220,31 +245,9 @@ const styles = StyleSheet.create({ }, }); -const TextWithCopy = ({ label, text }: {label: string, text: string}) => { - const handleCopyQRData = async () => { - try { - await Clipboard.setStringAsync(text); - } catch (error) { - logger.error('CLIPBOARD_COPY_FAILED', { error }); - } - }; - return ( - - {label} - - - {text} - - - - - - - ); -}; - const EsimInstallation = () => { const { isDark } = useTheme(); + const styles = useMemo(createStyles, [isDark]); const { qrcode, appleInstallationUrl: rawAppleUrl } = useLocalSearchParams(); const appleInstallationUrl = Array.isArray(rawAppleUrl) ? rawAppleUrl[0] : (rawAppleUrl ?? ""); const router = useRouter(); From 9fb08e3815dfb1fef1a75f68f2a66ee31d24644c Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sun, 12 Jul 2026 22:29:04 +0530 Subject: [PATCH 44/54] package updates --- package-lock.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index d38b69d..37f7e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7074,9 +7074,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "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" @@ -7284,9 +7284,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "funding": [ { "type": "opencollective", @@ -7304,9 +7304,9 @@ "license": "MIT", "dependencies": { "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -7453,9 +7453,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "funding": [ { "type": "opencollective", @@ -14247,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", @@ -15539,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", From f03ab932e397ee6c5b0c28403460ec0e1f078c7e Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Sun, 12 Jul 2026 22:29:31 +0530 Subject: [PATCH 45/54] fix lint warnings --- app/(tabs)/settings.tsx | 14 +++++++------- components/AuthenticationModal.tsx | 2 ++ components/checkoutHeader/CheckoutHeader.tsx | 4 ++++ components/ui/CheckoutSuccessModal.tsx | 6 ++++++ screens/checkout/Checkout.tsx | 2 ++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 2878231..a51c969 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -288,13 +288,13 @@ export default function MenuScreen() { const styles = useMemo(createStyles, [isDark]); const menuItems = [ - //{ - // id: "1", - // title: "Contact", - // iconLeft: "call-outline", - // iconRight: "chevron-forward-outline", - // disabled: !MENU_ITEM_ENABLED.CONTACT, - //}, + { + id: "1", + title: "Contact", + iconLeft: "call-outline", + iconRight: "chevron-forward-outline", + disabled: !MENU_ITEM_ENABLED.CONTACT, + }, { id: "3", title: "Privacy Policy", diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index 57b545b..26a6d41 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -313,6 +313,8 @@ export function AuthenticationModal() { )} ), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [mode] ); diff --git a/components/checkoutHeader/CheckoutHeader.tsx b/components/checkoutHeader/CheckoutHeader.tsx index 16b6f30..fc0578a 100644 --- a/components/checkoutHeader/CheckoutHeader.tsx +++ b/components/checkoutHeader/CheckoutHeader.tsx @@ -302,6 +302,8 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { )} ), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [handleBack, eSimItem] ); @@ -330,6 +332,8 @@ const CheckoutHeader = ({ eSimDetails = {} }: any) => { /> ), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [eSimItem] ); diff --git a/components/ui/CheckoutSuccessModal.tsx b/components/ui/CheckoutSuccessModal.tsx index 9faa5f7..7229c6b 100644 --- a/components/ui/CheckoutSuccessModal.tsx +++ b/components/ui/CheckoutSuccessModal.tsx @@ -157,6 +157,8 @@ const CheckoutSuccessModal: React.FC = ({ ), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [] ); @@ -193,6 +195,8 @@ const CheckoutSuccessModal: React.FC = ({ )} ), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [animatedIconStyle, textColor, variant, topupFromLabel, topupToLabel] ); @@ -207,6 +211,8 @@ const CheckoutSuccessModal: React.FC = ({ ), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [onInstallESIM, onDone, variant] ); diff --git a/screens/checkout/Checkout.tsx b/screens/checkout/Checkout.tsx index 319ddd7..1e93a46 100644 --- a/screens/checkout/Checkout.tsx +++ b/screens/checkout/Checkout.tsx @@ -238,6 +238,8 @@ const Checkout = () => { const radioButtons: RadioButtonProps[] = useMemo( () => createRadioButtons(selectedPaymentMethod, styles.buttonStyle), + // All missing dependencies are of style attributes which are in their on useMemo() call + // eslint-disable-next-line react-hooks/exhaustive-deps [selectedPaymentMethod], ); From 643b8fa4b30f2bbbe2e4cce8215c3dbc5f77c10d Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Mon, 13 Jul 2026 01:29:06 +0530 Subject: [PATCH 46/54] minor semver version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c4c638c..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" From 025279e18f713f95d8034d15a13b2aae239678f8 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Mon, 13 Jul 2026 04:33:26 +0530 Subject: [PATCH 47/54] split android and ios workflows --- .eas/README.md | 93 +++++++--------- ...tion.yml => deploy-production-android.yml} | 18 +-- .eas/workflows/deploy-production-ios.yml | 18 +++ .eas/workflows/deploy-staging-android.yml | 18 +++ ...loy-staging.yml => deploy-staging-ios.yml} | 18 +-- .eas/workflows/publish-producion-ota-ios.yml | 10 ++ .../publish-production-ota-android.yml | 10 ++ .eas/workflows/publish-production-ota.yml | 18 --- .../workflows/publish-staging-ota-android.yml | 10 ++ .eas/workflows/publish-staging-ota-ios.yml | 10 ++ .eas/workflows/publish-staging-ota.yml | 18 --- .github/workflows/route-eas-release.yml | 105 +++++++++++------- scripts/verify-ota-preflight.mjs | 33 ++++-- 13 files changed, 209 insertions(+), 170 deletions(-) rename .eas/workflows/{deploy-production.yml => deploy-production-android.yml} (50%) create mode 100644 .eas/workflows/deploy-production-ios.yml create mode 100644 .eas/workflows/deploy-staging-android.yml rename .eas/workflows/{deploy-staging.yml => deploy-staging-ios.yml} (52%) create mode 100644 .eas/workflows/publish-producion-ota-ios.yml create mode 100644 .eas/workflows/publish-production-ota-android.yml delete mode 100644 .eas/workflows/publish-production-ota.yml create mode 100644 .eas/workflows/publish-staging-ota-android.yml create mode 100644 .eas/workflows/publish-staging-ota-ios.yml delete mode 100644 .eas/workflows/publish-staging-ota.yml 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/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/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}.` ); From 191989992c5bcc34f8d8273c33481931beede92b Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Tue, 14 Jul 2026 00:05:12 +0530 Subject: [PATCH 48/54] update bff types per new spec --- utils/bff/generated/koKioBff.d.ts | 271 ++++++++++++++++++++++++++++-- 1 file changed, 258 insertions(+), 13 deletions(-) diff --git a/utils/bff/generated/koKioBff.d.ts b/utils/bff/generated/koKioBff.d.ts index 5fb99d7..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; @@ -2169,7 +2257,7 @@ 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"][]; }; @@ -2192,7 +2280,15 @@ export interface components { /** @description Per-eSIM usage-computation error message, or null on success. */ usageError: string | null; }; - EsimUsageResponse: components["schemas"]["ESimUsageResponse"]; + 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. @@ -2628,15 +2724,6 @@ export interface components { */ action: "denied" | "allowed"; }; - 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"][]; - }; }; responses: { /** @@ -2703,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: { /** @@ -3010,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"]; }; }; @@ -3458,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: { @@ -3618,6 +3857,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -3688,6 +3928,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -3755,6 +3996,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -3897,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: { @@ -4035,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: { @@ -4163,6 +4407,7 @@ export interface operations { }; }; 401: components["responses"]["DPoPAuthError"]; + 404: components["responses"]["AccountDeletedError"]; 500: components["responses"]["InternalServerError"]; }; }; From b422c05b9ada2a3e06291b9cd23d96f826fb6b04 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Tue, 14 Jul 2026 00:05:34 +0530 Subject: [PATCH 49/54] add account deletion feature --- app/(tabs)/settings.tsx | 20 ++- components/AuthenticationModal.tsx | 91 ++++++++--- components/DeleteAccountModal.tsx | 233 +++++++++++++++++++++++++++ components/PasskeyRemovalModal.tsx | 183 +++++++++++++++++++++ providers/authProvider.tsx | 42 ++++- providers/index.tsx | 4 +- providers/kokioProvider.tsx | 38 +++-- services/httpService.ts | 19 ++- utils/auth/accountDeleted.ts | 65 ++++++++ utils/auth/purgeAccountLocalState.ts | 57 +++++++ utils/bff/account.ts | 15 +- utils/bff/errors.ts | 4 + 12 files changed, 721 insertions(+), 50 deletions(-) create mode 100644 components/DeleteAccountModal.tsx create mode 100644 components/PasskeyRemovalModal.tsx create mode 100644 utils/auth/accountDeleted.ts create mode 100644 utils/auth/purgeAccountLocalState.ts diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index a51c969..5d71490 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -20,6 +20,7 @@ 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: { @@ -278,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]); @@ -323,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", @@ -358,6 +368,14 @@ export default function MenuScreen() { keyExtractor={(item) => item.id} style={styles.list} /> + setShowDeleteAccount(false)} + onConfirm={async () => { + await deleteAccount(); + setShowDeleteAccount(false); + }} + /> { 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", @@ -131,6 +141,7 @@ export function AuthenticationModal() { const { isDark } = useTheme(); const styles = useMemo(createStyles, [isDark]); const [mode, setMode] = useState("choice"); + const [accountDeleted, setAccountDeleted] = useState(isAccountDeletedCached()); const sheetRef = useRef(null); const { state, loginWithPasskey, signUpWithPasskey, recoverWithPasskey, clearError } = @@ -153,6 +164,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; @@ -335,33 +348,63 @@ 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 + ? "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" && ( 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/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/providers/authProvider.tsx b/providers/authProvider.tsx index e4b7dee..de75e65 100644 --- a/providers/authProvider.tsx +++ b/providers/authProvider.tsx @@ -9,15 +9,24 @@ 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 ───────────────────────────────────────────────────────── @@ -101,6 +110,7 @@ export interface AuthRelayProviderType { stepUpError: string; stepUp: () => Promise; dismissStepUp: () => void; + deleteAccount: () => Promise; } export const AuthRelayContext = createContext({ @@ -116,6 +126,7 @@ export const AuthRelayContext = createContext({ stepUpError: '', stepUp: async () => {}, dismissStepUp: () => {}, + deleteAccount: async () => {}, }); // ─── Provider ───────────────────────────────────────────────────────────────── @@ -131,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 @@ -155,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; @@ -198,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) }); @@ -251,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; @@ -271,7 +293,7 @@ export const AuthRelayProvider: React.FC = ({ await clearUsedHashes(); dispatch({ type: "REAUTHENTICATE" }); router.replace("/"); - }; + }, [router]); const clearError = () => { dispatch({ type: "CLEAR_ERROR" }); @@ -306,6 +328,17 @@ export const AuthRelayProvider: React.FC = ({ 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/index.tsx b/providers/index.tsx index 9d9d9b0..2b55b4a 100644 --- a/providers/index.tsx +++ b/providers/index.tsx @@ -23,7 +23,7 @@ const PERSISTED_KEYS: Set = new Set([DEVICE_ESIMS_KEY, DEVICE_ORDERS_KEY // ─── QueryClient ────────────────────────────────────────────────────────────── -const queryClient = new QueryClient({ +export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, @@ -35,7 +35,7 @@ const queryClient = new QueryClient({ // Single flat key in AsyncStorage. // The dehydrateOptions filter below ensures only PERSISTED_KEYS queries are written. -const asyncStoragePersister = createAsyncStoragePersister({ +export const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage, key: 'kokio.rq.cache', }); diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index 3482d8c..401b182 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -16,6 +16,7 @@ import { } from "@/utils/walletconnect/signClient"; import { logger } from "@/utils/logger"; import { checkWalletDeployed } from "@/utils/wallet/checkWalletDeployed"; +import { isAccountDeletedError } from "@/utils/bff/errors"; const extra = Constants.expoConfig?.extra as AppExtraConfig; @@ -460,21 +461,28 @@ export const KokioProvider: React.FC = ({ children }) => { await SecureStore.setItemAsync('credentialId', credentialId); dispatch({ type: 'SET_DEVICE_WALLET_ADDRESS', payload: deviceWalletAddress }); - 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. + 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 = () => { diff --git a/services/httpService.ts b/services/httpService.ts index c199d98..d637f59 100644 --- a/services/httpService.ts +++ b/services/httpService.ts @@ -10,6 +10,8 @@ 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. @@ -32,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 @@ -178,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/utils/auth/accountDeleted.ts b/utils/auth/accountDeleted.ts new file mode 100644 index 0000000..cee1438 --- /dev/null +++ b/utils/auth/accountDeleted.ts @@ -0,0 +1,65 @@ +/** + * 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; + } + 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/purgeAccountLocalState.ts b/utils/auth/purgeAccountLocalState.ts new file mode 100644 index 0000000..d90c972 --- /dev/null +++ b/utils/auth/purgeAccountLocalState.ts @@ -0,0 +1,57 @@ +/** + * 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 { + deviceUID = await SecureStore.getItemAsync('deviceUID'); + } catch { + /* best-effort */ + } + await Promise.all( + SECURE_KEYS.map((k) => SecureStore.deleteItemAsync(k).catch(() => {})), + ); + + if (deviceUID) { + await Promise.all([ + AsyncStorage.removeItem(`purchasedESIMs-${deviceUID}`).catch(() => {}), + AsyncStorage.removeItem(`userWallet-${deviceUID}`).catch(() => {}), + AsyncStorage.removeItem(`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/bff/account.ts b/utils/bff/account.ts index 0a51cf4..45fad2f 100644 --- a/utils/bff/account.ts +++ b/utils/bff/account.ts @@ -6,11 +6,16 @@ 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 (5 min -// recency window); call this immediately after a passkey login/recovery so that -// ceremony's auth_time satisfies it without a second biometric prompt. +/** + * 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/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(). From 7c8ae043dab8912a3a8eb0c67ed775da0d2bfb77 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Tue, 14 Jul 2026 03:22:12 +0530 Subject: [PATCH 50/54] show error message for non-existent accounts on Existing User button --- components/AuthenticationModal.tsx | 44 ++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index 01a4ba2..cf10276 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -142,12 +142,18 @@ export function AuthenticationModal() { 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 @@ -208,7 +214,7 @@ export function AuthenticationModal() { ); const handleNewUser = useCallback(async () => { - clearError(); + setLocalError(); setMode("authenticating"); let succeeded = false; try { @@ -231,10 +237,10 @@ export function AuthenticationModal() { } finally { if (!succeeded) setMode("error"); } - }, [signUpWithPasskey, setupKokioRegistration, clearError]); + }, [signUpWithPasskey, setupKokioRegistration, setLocalError]); const handleExistingUser = useCallback(async () => { - clearError(); + setLocalError(); setMode("authenticating"); let succeeded = false; try { @@ -273,6 +279,24 @@ export function AuthenticationModal() { ); 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) { @@ -286,7 +310,7 @@ export function AuthenticationModal() { kokio, setupKokioRecovery, clearKokioUser, - clearError, + resetErrors, ]); useEffect(() => { @@ -297,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 @@ -381,6 +405,8 @@ export function AuthenticationModal() { {mode === "authenticating" ? "Verifying your identity…" + : isReturningUser === null + ? "Checking this device…" : isReturningUser ? "Log in to continue" : "Choose how to get started"} @@ -407,14 +433,14 @@ export function AuthenticationModal() { )} - {!!state.error && mode === "error" && ( - {state.error} + {mode === "error" && !!(localError || state.error) && ( + {localError || state.error} )} { - clearError(); + resetErrors(); setMode("choice"); sheetRef.current?.close({ duration: 250, From f721d2d73bf40227205552c5d83101ec20ed4080 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Tue, 14 Jul 2026 03:23:00 +0530 Subject: [PATCH 51/54] clear wallet and sdk state on account deletion --- providers/kokioProvider.tsx | 18 ++++++++++++++++++ utils/auth/accountDeleted.ts | 1 + utils/auth/purgeAccountLocalState.ts | 6 +++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/providers/kokioProvider.tsx b/providers/kokioProvider.tsx index 401b182..864c255 100644 --- a/providers/kokioProvider.tsx +++ b/providers/kokioProvider.tsx @@ -16,6 +16,7 @@ import { } 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; @@ -250,6 +251,23 @@ export const KokioProvider: React.FC = ({ children }) => { fetchUserData(); }, []); + /** + * 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 ─────────────────────────── * diff --git a/utils/auth/accountDeleted.ts b/utils/auth/accountDeleted.ts index cee1438..f0a6bd5 100644 --- a/utils/auth/accountDeleted.ts +++ b/utils/auth/accountDeleted.ts @@ -40,6 +40,7 @@ export async function hydrateAccountDeleted(): Promise { } catch { _cached = false; } + listeners.forEach((fn) => fn(_cached)); return _cached; } diff --git a/utils/auth/purgeAccountLocalState.ts b/utils/auth/purgeAccountLocalState.ts index d90c972..bda8de8 100644 --- a/utils/auth/purgeAccountLocalState.ts +++ b/utils/auth/purgeAccountLocalState.ts @@ -29,7 +29,11 @@ const SECURE_KEYS = [ export async function purgeAccountLocalState(): Promise { let deviceUID: string | null = null; try { - deviceUID = await SecureStore.getItemAsync('deviceUID'); + 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 */ } From 4d3c2a69c5b1bee327eb97d8c784208c128778cd Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Wed, 15 Jul 2026 00:20:47 +0530 Subject: [PATCH 52/54] update permissions --- android/app/build.gradle | 8 +++++++- android/app/src/main/AndroidManifest.xml | 5 ++--- 2 files changed, 9 insertions(+), 4 deletions(-) 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 + From adb666288fdbd5bdce43d1154354e8c344106680 Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Wed, 15 Jul 2026 00:56:46 +0530 Subject: [PATCH 53/54] bugfix: delete userWallet+userData correctly from secure store --- utils/auth/purgeAccountLocalState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/auth/purgeAccountLocalState.ts b/utils/auth/purgeAccountLocalState.ts index bda8de8..8cfd974 100644 --- a/utils/auth/purgeAccountLocalState.ts +++ b/utils/auth/purgeAccountLocalState.ts @@ -44,8 +44,8 @@ export async function purgeAccountLocalState(): Promise { if (deviceUID) { await Promise.all([ AsyncStorage.removeItem(`purchasedESIMs-${deviceUID}`).catch(() => {}), - AsyncStorage.removeItem(`userWallet-${deviceUID}`).catch(() => {}), - AsyncStorage.removeItem(`userData-${deviceUID}`).catch(() => {}), + SecureStore.deleteItemAsync(`userWallet-${deviceUID}`).catch(() => {}), + SecureStore.deleteItemAsync(`userData-${deviceUID}`).catch(() => {}), ]); } try { From 78184868567adc659d5c74ef8878edcc3d4b414b Mon Sep 17 00:00:00 2001 From: GuyPhy Date: Wed, 15 Jul 2026 01:13:52 +0530 Subject: [PATCH 54/54] fix type errors --- components/AuthenticationModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/AuthenticationModal.tsx b/components/AuthenticationModal.tsx index cf10276..a295054 100644 --- a/components/AuthenticationModal.tsx +++ b/components/AuthenticationModal.tsx @@ -214,7 +214,7 @@ export function AuthenticationModal() { ); const handleNewUser = useCallback(async () => { - setLocalError(); + resetErrors(); setMode("authenticating"); let succeeded = false; try { @@ -237,10 +237,10 @@ export function AuthenticationModal() { } finally { if (!succeeded) setMode("error"); } - }, [signUpWithPasskey, setupKokioRegistration, setLocalError]); + }, [signUpWithPasskey, setupKokioRegistration, resetErrors]); const handleExistingUser = useCallback(async () => { - setLocalError(); + resetErrors(); setMode("authenticating"); let succeeded = false; try { @@ -258,7 +258,7 @@ export function AuthenticationModal() { } else if (result === "no-credential") { // Passkey deleted — fall back to recovery await clearKokioUser(); - clearError(); + resetErrors(); const recovered = await recoverWithPasskey(); if (recovered) { await setupKokioRecovery(