Skip to content

✨ app: implement manteca ramp#704

Open
franm91 wants to merge 1 commit intomainfrom
ramp
Open

✨ app: implement manteca ramp#704
franm91 wants to merge 1 commit intomainfrom
ramp

Conversation

@franm91
Copy link
Member

@franm91 franm91 commented Jan 30, 2026

closes #470


Open with Devin

Summary by CodeRabbit

Release Notes

  • New Features

    • Added fiat deposit flow enabling users to transfer funds from bank accounts to receive USDC
    • Added KYC verification process for fiat deposits
    • Added deposit details display with account information, fees, and delivery times
    • Added support for ARS, BRL, and USD currencies
  • UI/UX Improvements

    • Updated add funds flow with streamlined onboarding screens
    • Simplified deposit navigation
  • Localization

    • Added Spanish translations for fiat deposit feature

@changeset-bot
Copy link

changeset-bot bot commented Jan 30, 2026

🦋 Changeset detected

Latest commit: a81c0f4

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Jan 30, 2026

Walkthrough

This PR implements the manteca fiat ramp feature, enabling users to deposit ARS, BRL, and USD to receive on-chain USDC. It adds currency validation, multi-step KYC onboarding (via persona), account setup with ramp providers, and transaction status tracking across new routing screens, React Native components, server API utilities, and Spanish translations.

Changes

Cohort / File(s) Summary
Changeset Declaration
.changeset/shiny-points-eat.md
Declares a patch version bump for @exactly/mobile with the manteca ramp implementation.
Routing Setup
src/app/(main)/add-funds/_layout.tsx, src/app/(main)/add-funds/kyc.tsx, src/app/(main)/add-funds/onboard.tsx, src/app/(main)/add-funds/ramp.tsx, src/app/(main)/add-funds/status.tsx
Adds four new navigable screens (kyc, onboard, ramp, status) to the add-funds layout and creates re-export wrappers to expose component routes.
Core Fiat Onboarding Components
src/components/add-funds/AddFunds.tsx, src/components/add-funds/AddFiatButton.tsx
Integrates KYC status and ramp providers; renders fiat onboarding options per provider/currency and handles navigation based on onboarding status (NOT_STARTED, ONBOARDING, ACTIVE).
Multi-Step Flow Components
src/components/add-funds/KYC.tsx, src/components/add-funds/Onboard.tsx, src/components/add-funds/Ramp.tsx, src/components/add-funds/Status.tsx
Implements the four-step fiat deposit flow: KYC verification via persona, onboarding initiation, ramp account details display, and status confirmation/error states.
Disclaimer and Styling
src/components/add-funds/MantecaDisclaimer.tsx, src/components/add-funds/AddFundsOption.tsx, src/components/shared/InfoAlert.tsx
Adds manteca-specific disclaimer with provider/terms links; updates AddFundsOption from elevation to border styling; makes InfoAlert actionText optional with XStack layout refactor.
KYC and Ramp Utilities
src/utils/persona.ts, src/utils/server.ts, src/utils/currencies.ts
Extends KYC flow to support manteca with MantecaKYCResult type and startMantecaKYC function; adds ramp endpoints (getRampProviders, getRampQuote, startRampOnboarding) and currency validation (currencyMap, isValidCurrency).
Home and Navigation
src/components/home/Home.tsx, src/components/home/HomeActions.tsx
Updates Home.tsx queryFn for KYC to explicitly use "basic" scope; simplifies HomeActions.tsx deposit routing to always navigate to /add-funds (removing method-based branching).
Spanish Translations
src/i18n/es.json, cspell.json
Adds ~25 new Spanish translation keys for onboarding, verification, deposit details, delivery, fees, and manteca disclaimer terms; adds "sixalime" to spell-check words.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant AddFunds as AddFunds UI
    participant KYC as KYC Component
    participant Persona as Persona KYC
    participant Onboard as Onboard Component
    participant Ramp as Ramp Component
    participant Server as Server API
    participant Status as Status Component

    User->>AddFunds: Tap Fiat Option (ARS, BRL, USD)
    AddFunds->>KYC: Navigate to /add-funds/kyc
    
    User->>KYC: Tap "Continue verification"
    KYC->>Persona: startMantecaKYC()
    Persona->>User: Open Persona verification UI
    User->>Persona: Complete KYC (Face ID, etc.)
    Persona-->>KYC: MantecaKYCResult { status }
    
    alt KYC Result = "complete"
        KYC->>Server: startRampOnboarding({ provider: "manteca" })
        KYC->>Server: getKYCStatus("manteca") → countryCode
        KYC->>Server: getRampProviders(countryCode)
        
        alt Provider Status = "ACTIVE"
            KYC->>Ramp: Navigate to /add-funds/ramp
            Ramp->>Server: getRampQuote(manteca, currency)
            Ramp->>User: Display deposit account details
        else Provider Status = "ONBOARDING"
            KYC->>Onboard: Navigate to /add-funds/onboard
            Onboard->>User: Display waiting state
        else Provider Status = "error"
            KYC->>Status: Navigate to /add-funds/status?status=error
        end
    else KYC Result = "error" or "cancel"
        KYC->>Status: Navigate to /add-funds/status?status=error
        Status->>User: Display error & return to home
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • cruzdanilo
  • nfmelendez
  • dieguezguille
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '✨ app: implement manteca ramp' directly matches the PR's primary objective of implementing the manteca ramp feature for fiat onboarding.
Linked Issues check ✅ Passed The PR implements the manteca ramp integration with KYC flows, ramp provider integration, UI components, translations, and routing that align with the linked issue #470 objectives.
Out of Scope Changes check ✅ Passed All changes are in scope: new manteca ramp components, KYC flows, server utilities, i18n translations, routing, and related styling updates directly support the ramp implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ramp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link

Summary of Changes

Hello @franm91, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a significant new feature: the Manteca fiat on-ramp. It provides a comprehensive flow for users to add funds by converting fiat currencies into on-chain USDC. The changes encompass new UI screens for onboarding, KYC verification, status tracking, and the ramp details, along with the necessary backend integrations to support these operations.

Highlights

  • New Manteca Ramp Integration: Implemented the Manteca fiat on-ramp solution, enabling users to convert fiat currencies to on-chain USDC directly within the application.
  • Expanded Add Funds Flow: Introduced new dedicated screens for the add-funds process, including 'onboard', 'kyc', 'status', and 'ramp', providing a structured user journey.
  • Dynamic Fiat On-Ramp Options: Integrated a new AddFiatButton component to dynamically display available fiat on-ramp options based on user's country and provider status.
  • Enhanced KYC Management: Updated the KYC status retrieval and initiation logic to support distinct 'basic' and 'manteca' verification flows, leveraging the Persona SDK.
  • New SVG Assets: Added several new SVG image assets for various currencies (ARS, BRL, USD) and status indicators (denied, face-id) to enrich the UI.
  • Simplified Deposit Navigation: Streamlined the deposit action in HomeActions to directly navigate to the main add-funds screen, improving user experience.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View issue and 6 additional flags in Devin Review.

Open in Devin Review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new fiat on-ramp feature using 'Manteca' as a provider. This includes new screens for onboarding, KYC, and displaying ramp details, along with the necessary components, assets, and API utility functions. The changes are well-structured, but there are a few areas that could be improved for clarity, maintainability, and robustness. Specifically, the Persona KYC initiation logic is duplicated, some useQuery data flow is indirect, and depositInfo extraction could be cleaner.

Comment on lines +38 to +46
const { data: countryCode } = useQuery({
queryKey: ["user", "country"],
queryFn: async () => {
await getKYCStatus("basic", true);
return queryClient.getQueryData<string>(["user", "country"]) ?? "";
},
staleTime: (query) => (query.state.data ? Infinity : 0),
retry: false,
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This useQuery hook for countryCode has a confusing implementation. The queryFn calls getKYCStatus("basic", true), which has a side effect of setting the country code in the query cache via a response header, and then the queryFn returns a value read from that same cache. This indirect data flow is hard to follow. A clearer approach would be to refactor getKYCStatus to return the country code in its response body, allowing this query to directly return the value without relying on side effects.

Comment on lines +70 to +89
const beneficiaryName = depositInfo && "beneficiaryName" in depositInfo ? depositInfo.beneficiaryName : undefined;
const depositAddress =
depositInfo?.network === "ARG_FIAT_TRANSFER"
? depositInfo.cbu
: depositInfo?.network === "PIX"
? depositInfo.pixKey
: undefined;
const depositAlias = depositInfo?.network === "ARG_FIAT_TRANSFER" ? depositInfo.depositAlias : undefined;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to extract properties like beneficiaryName, depositAddress, and depositAlias from depositInfo is spread across multiple lines with optional chaining and in checks. This can be hard to follow and maintain. Since depositInfo appears to be a discriminated union based on the network property, using a switch statement or a helper function would make this logic cleaner, more robust, and easier to read.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is used in a single place a switch or helper would add indirection without real benefit.

@sentry
Copy link

sentry bot commented Jan 30, 2026

Codecov Report

❌ Patch coverage is 8.65385% with 95 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.26%. Comparing base (9736dfd) to head (a81c0f4).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/utils/persona.ts 0.00% 60 Missing ⚠️
src/utils/server.ts 21.05% 30 Missing ⚠️
src/components/shared/InfoAlert.tsx 0.00% 4 Missing ⚠️
src/components/home/HomeActions.tsx 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #704      +/-   ##
==========================================
- Coverage   68.12%   67.26%   -0.87%     
==========================================
  Files         206      206              
  Lines        6780     6867      +87     
  Branches     2114     2127      +13     
==========================================
  Hits         4619     4619              
- Misses       1980     2067      +87     
  Partials      181      181              
Flag Coverage Δ
e2e 67.26% <8.65%> (+14.41%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFiatButton.tsx`:
- Around line 10-13: The AddFiatButtonProperties type declares currency as a
plain string which weakens type safety against currencyMap keys; import the
Currency type from ../../utils/currencies and change the property signature in
AddFiatButtonProperties from currency: string to currency: Currency so the
component's currency prop is strongly typed to the known currency keys (update
any usages of AddFiatButtonProperties/AddFiatButton to satisfy the new type if
necessary).

In `@src/components/add-funds/AddFunds.tsx`:
- Line 113: In AddFunds.tsx replace the hardcoded gap={16} on the YStack with
the appropriate design token (e.g., the app's spacing token such as
spacing.medium or $4) used across the codebase; update the YStack prop to use
that token (and import the token if needed) so styling relies on predefined
design tokens rather than a raw number, then run the linter/formatter to ensure
the prop type matches the token format.
- Around line 107-111: In the AddFunds component JSX where the fiat options are
rendered (the block using countryCode and isPending to decide between the
Skeleton and options), change the condition from countryCode && isPending to
show the skeleton when either the providers are loading or the country code is
not yet available (e.g., use isPending || !countryCode or !countryCode ||
isPending) so the loading skeleton is displayed while countryCode is being
fetched as well as while providers are loading.

In `@src/components/add-funds/Kyc.tsx`:
- Around line 99-101: The FaceId SVG is receiving string percentages for
dimensions which don't work in React Native SVG; update the FaceId usage in
Kyc.tsx (the View + FaceId block) to pass numeric width/height props (e.g.,
width={200} height={200} or width={iconSize} height={iconSize}) or remove those
props and control sizing via the parent View/style (flex or explicit numeric
size computed with Dimensions) so the FaceId component gets numeric literals
rather than "100%".
- Around line 79-80: The two JSX View elements are using hardcoded gap={20};
replace those with the project design token for that spacing (e.g.,
gap={tokens.spacing.md} or gap={tokens.space[20]}) by importing the
tokens/spacing object from your design tokens module and using the appropriate
token value (ensure the token name matches the project's token API and types),
updating both occurrences of gap={20} on the View components.
- Around line 54-66: The code assumes providers.providers.manteca exists and
reads its status directly; add a defensive null/undefined check for
providers.providers.manteca (after the queryClient.fetchQuery/getRampProviders
call) before accessing manteca.status and set newStatus to a safe fallback
(e.g., "error" or "UNAVAILABLE") when manteca is missing, then call
router.replace accordingly (ensure logic around newStatus, router.replace, and
the existing branches for "ACTIVE"/"ONBOARDING" handle the fallback case).

In `@src/components/add-funds/Onboard.tsx`:
- Around line 26-96: The Onboard component contains routing, mutations and
server-state logic (useMutation with mutationFn, getKYCStatus handling,
completeOnboarding, queryClient calls, router.push/replace) and must be
refactored into a smart screen in src/app/**; extract all side-effect logic
(handleOnboarding mutation, completeOnboarding function,
queryClient.fetch/invalidate, getKYCStatus, startRampOnboarding, router
navigation and error handling) into a container/screen component and convert
this file into a pure presentational component that accepts props like currency,
validCurrency, CurrencyImage, isPending, onContinue (callback) and onNavigate
(or expose status callbacks). Keep only UI rendering and call props.onContinue
from handleContinue; remove any direct use of router, queryClient, useMutation,
and reportError from this component. Ensure function names referenced (Onboard,
completeOnboarding, handleOnboarding, handleContinue) are implemented/used in
the new smart screen and the UI component only receives data and callbacks via
props.
- Around line 34-57: The inline multi-line mutationFn passed to useMutation
(alias handleOnboarding) contains complex logic (calls to getKYCStatus,
router.push/replace and completeOnboarding) and should be extracted into a named
async function (e.g., async function performRampOnboarding(currency) { ... })
declared outside the inline prop so the handler is stable and testable; replace
mutationFn: async () => { ... } with mutationFn: performRampOnboarding (or () =>
performRampOnboarding(currency)) and move the existing logic (including the
APIError catch, kycCode calculation, router.push/replace and completeOnboarding
calls) into that function. Do the same refactor for the other multi-line
callbacks noted (the handler around completeOnboarding / status flow referenced
later) so they become named functions instead of inline multi-line arrow
functions.
- Around line 1-24: The React default import is only used for the React.FC type
in the currencyImages declaration; change the import to a type-only import and
update the type usage to use a type import (e.g., import type { FC } from
"react") and then type currencyImages as Record<string, FC<{ height: string;
width: string }>> so the runtime bundle does not include React unnecessarily;
update the top import line accordingly and remove the unused default React
import.
- Around line 100-123: Replace hardcoded numeric spacing and longhand padding
with Tamagui design tokens and shorthand props: change gap={20} and gap={10} to
token-based values (e.g. gap="$s6" or "$s2" as appropriate) on the View and
inner row, replace padding="$s4" longhand on YStack with the shorthand p="$s4",
and prefer shorthands like px, py, m, mt, etc., on components such as View,
ScrollView, YStack, Pressable and the container wrapping CurrencyImage; keep
existing behavior for router.back()/router.replace() and retain the
ArrowLeft/Pressable structure while only updating spacing props to use the
tokens from tamagui.config.ts.
- Around line 30-72: Move the top-level countryCode read into the
completeOnboarding function to avoid pinning an empty-string cache key; remove
the module-scope const countryCode and instead call
queryClient.getQueryData<string>(["user","country"]) (or similar) inside
completeOnboarding before you call queryClient.fetchQuery({ queryKey:
["ramp","providers", countryCode], ... }) so the providers query is only keyed
with the actual country value (and not a default ""), and apply the same change
to the analogous code in Kyc.tsx.

In `@src/components/add-funds/Ramp.tsx`:
- Around line 181-183: The X icon inside Button.Icon is missing an explicit size
prop and should match the other icons in this component for visual consistency;
update the X component in the Button.Icon block (the X element) to include the
same size prop used by the other icons in this file (e.g., size=16 or the
project's standard icon size) so all icons in the Ramp/Add-funds component
render at the same dimensions.
- Around line 90-91: Replace hardcoded numeric gaps in the Ramp component's JSX
(the View elements using gap={20}) with the app's design tokens: locate the View
elements in Ramp.tsx (props gap={20}) and change them to use the shared spacing
token (e.g., spacing.X or tokens.space.xxx — whatever the project's spacing
token export is) so all gap props reference the token constant; update both
occurrences mentioned and any other hardcoded gap values in this file to use the
same token import.
- Around line 107-109: The Info Pressable in Ramp.tsx is missing an onPress
handler, so either remove the Pressable or add an onPress that mirrors the
behavior used elsewhere (e.g., the help/article opener in AddFunds.tsx); locate
the Pressable wrapping the Info icon and implement an onPress prop that opens
the appropriate help modal or navigator route (or call the same helper function
used by AddFunds), and keep hitSlop={15} if retaining the button for consistent
touch target.
- Around line 104-106: Replace the hardcoded style props on the Text component
(currently fontSize={15} and fontWeight="bold") with the design-system token
props used elsewhere (e.g., use the Text variant/size prop like subHeadline and
the emphasis prop like emphasized or the component's weight token) so styling
comes from predefined tokens; update the Text invocation in the Ramp component
to remove fontSize/fontWeight and pass the appropriate predefined props
(subHeadline and emphasized) to match the design system.

In `@src/components/add-funds/Status.tsx`:
- Around line 43-45: The SVG components FaceId and Denied are being passed
percentage strings for width/height which SVG in React Native doesn't support;
update the JSX in the View that renders these icons (the conditional using
isOnboarding) to pass numeric width and height values (e.g., width={...}
height={...}) or compute numbers from the container/screen dimensions before
passing them; ensure the values are numeric literals or variables (not strings)
so FaceId and Denied receive valid numeric props.
- Around line 38-40: Replace the hardcoded gap={20} uses in the Status
component's View elements with the appropriate spacing design token from
tamagui.config.ts (e.g., the spacing token equivalent like "$4" or "space-4");
update both the outer View (the fullScreen padded container) and the inner View
(the child with gap) to use that token and, if required by your setup,
import/consume the token or theme so the component uses the predefined spacing
value rather than a raw number.

In `@src/utils/persona.ts`:
- Around line 117-205: startMantecaKYC duplicates large portions of startKYC
(pagehide listener, abort handling, Persona client/inquiry setup); extract
shared behavior into small helpers to remove duplication. Create a helper like
setupPagehideAbort(controller) that installs/removes the pagehide listener and
re-use it in both startMantecaKYC and startKYC; extract a common
attachAbortHandler(controller.signal, onAbort) pattern and a factory that wires
Persona callbacks (for Client and Inquiry) to shared onComplete/onCancel/onError
logic (referencing Client, Inquiry, onAbort, controller.signal,
queryClient.invalidateQueries and reportError) so both functions call the shared
helpers and only implement the KYC-specific return/resolve logic.

In `@src/utils/server.ts`:
- Around line 170-171: The error parsing uses a TypeScript type assertion
((await response.json()) as { code: string }) which bypasses runtime validation;
define a valibot schema (e.g., APIErrorResponse = object({ code: string() }))
and replace the assertions by calling parse(APIErrorResponse, await
response.json()) and destructuring the returned { code } before throwing
APIError; import object, string, and parse from valibot and apply this change
consistently for the locations that construct APIError (the occurrences around
the current response handling code and the other mentioned spots).
- Line 176: Remove the redundant type redeclaration that shadows the imported
RampProvider: delete the line exporting "export type RampProvider = (typeof
RampProvider)[number];" so the imported RampProvider from
`@exactly/server/utils/ramps/shared` is used; if you intended to also re-export a
runtime value, instead export the imported symbol (e.g., export { RampProvider
}) rather than redefining its type.

Comment on lines +10 to +13
type AddFiatButtonProperties = {
currency: string;
status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING";
};
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider using the Currency type for stronger typing.

The currency prop is typed as string, but the component uses currencyMap which has specific keys. Using the Currency type from ../../utils/currencies would provide better type safety.

♻️ Suggested improvement
+import { currencyMap, type Currency } from "../../utils/currencies";
-import { currencyMap } from "../../utils/currencies";

 type AddFiatButtonProperties = {
-  currency: string;
+  currency: Currency | string;
   status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING";
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type AddFiatButtonProperties = {
currency: string;
status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING";
};
import { currencyMap, type Currency } from "../../utils/currencies";
type AddFiatButtonProperties = {
currency: Currency | string;
status: "ACTIVE" | "NOT_AVAILABLE" | "NOT_STARTED" | "ONBOARDING";
};
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFiatButton.tsx` around lines 10 - 13, The
AddFiatButtonProperties type declares currency as a plain string which weakens
type safety against currencyMap keys; import the Currency type from
../../utils/currencies and change the property signature in
AddFiatButtonProperties from currency: string to currency: Currency so the
component's currency prop is strongly typed to the known currency keys (update
any usages of AddFiatButtonProperties/AddFiatButton to satisfy the new type if
necessary).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server sends this as string[], the server might support currencies the client doesn't know about yet. If that happens, we have a fallback.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, thank you for the clarification! That makes perfect sense—keeping currency as string allows the client to gracefully handle new currencies from the server that haven't been added to currencyMap yet, using the 💰 fallback. This is a good forward-compatibility design. My suggestion was off the mark given this requirement.


🧠 Learnings used
Learnt from: dieguezguille
Repo: exactly/exa PR: 633
File: src/components/card/CardPIN.tsx:99-104
Timestamp: 2026-01-13T16:45:03.207Z
Learning: In this codebase, avoid using spread syntax on strings (e.g., `[...string]`) as it triggers the `typescript-eslint/no-misused-spread` ESLint rule. Use `Array.from()` or other alternatives instead.

Comment on lines 54 to 73
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});
const newStatus = providers.providers.manteca.status;
if (newStatus === "ACTIVE") {
router.replace({ pathname: "/add-funds/ramp", params: { currency } });
} else if (newStatus === "ONBOARDING") {
router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } });
} else {
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add null check for manteca provider to prevent runtime error.

providers.providers.manteca is accessed without checking if the manteca key exists. If the provider response doesn't include manteca (e.g., due to country restrictions), this will throw a runtime error.

🛡️ Add defensive check
       const providers = await queryClient.fetchQuery({
         queryKey: ["ramp", "providers", countryCode],
         queryFn: () => getRampProviders(countryCode),
         staleTime: 0,
       });
-      const newStatus = providers.providers.manteca.status;
+      const mantecaProvider = providers.providers.manteca;
+      if (!mantecaProvider) {
+        router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
+        return;
+      }
+      const newStatus = mantecaProvider.status;
       if (newStatus === "ACTIVE") {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});
const newStatus = providers.providers.manteca.status;
if (newStatus === "ACTIVE") {
router.replace({ pathname: "/add-funds/ramp", params: { currency } });
} else if (newStatus === "ONBOARDING") {
router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } });
} else {
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});
const mantecaProvider = providers.providers.manteca;
if (!mantecaProvider) {
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
return;
}
const newStatus = mantecaProvider.status;
if (newStatus === "ACTIVE") {
router.replace({ pathname: "/add-funds/ramp", params: { currency } });
} else if (newStatus === "ONBOARDING") {
router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } });
} else {
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 54 - 66, The code assumes
providers.providers.manteca exists and reads its status directly; add a
defensive null/undefined check for providers.providers.manteca (after the
queryClient.fetchQuery/getRampProviders call) before accessing manteca.status
and set newStatus to a safe fallback (e.g., "error" or "UNAVAILABLE") when
manteca is missing, then call router.replace accordingly (ensure logic around
newStatus, router.replace, and the existing branches for "ACTIVE"/"ONBOARDING"
handle the fallback case).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If manteca doesn't exist the current code catch the error and redirects to the error page

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@franm91, let me verify the complete error handling in the completeOnboarding function to confirm your point.


🏁 Script executed:

#!/bin/bash
# View the complete completeOnboarding function including the catch block
sed -n '47,80p' src/components/add-funds/Kyc.tsx

Length of output: 1157


@franm91, you're absolutely right! I can see the catch block at lines 68-71 that properly handles any errors, including the case where providers.providers.manteca might be undefined. The error would be caught, reported via reportError(error), and the user would be redirected to the error status page.

My apologies for the incorrect review comment. The error handling is already properly implemented. 👍


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Learnt from: mainqueg
Repo: exactly/exa PR: 680
File: server/api/ramp.ts:157-168
Timestamp: 2026-01-27T14:36:26.450Z
Learning: In server/api/ramp.ts, the error handling pattern intentionally returns 4xx responses only for explicitly handled cases (e.g., MantecaErrorCodes.NOT_SUPPORTED_CURRENCY). Other error codes from MantecaErrorCodes should be re-thrown and result in 5xx errors, not automatically mapped to 400 responses.

Learnt from: mainqueg
Repo: exactly/exa PR: 637
File: server/hooks/persona.ts:186-195
Timestamp: 2026-01-15T19:06:45.697Z
Learning: In server/hooks/persona.ts, the webhook handler has intentionally different error handling for Manteca vs Panda flows: the Manteca path (payload.template === "manteca") lets addDocument errors propagate to enable webhook retries, while the Panda path uses .catch() to swallow addDocument errors and prevent retries since the user creation is already committed.

Comment on lines +38 to +41
<View gap={20} fullScreen padded>
<ScrollView flex={1}>
<View flex={1} gap={20}>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use design tokens for gap values.

The gap={20} values should use predefined design tokens from tamagui.config.ts instead of hardcoded numbers.

As per coding guidelines: "All styling must use predefined design tokens from tamagui.config.ts. Never use hardcoded values for colors, spacing, fonts, etc."

♻️ Suggested fix
-      <View gap={20} fullScreen padded>
-        <ScrollView flex={1}>
-          <View flex={1} gap={20}>
+      <View gap="$s5" fullScreen padded>
+        <ScrollView flex={1}>
+          <View flex={1} gap="$s5">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<View gap={20} fullScreen padded>
<ScrollView flex={1}>
<View flex={1} gap={20}>
<View gap="$s5" fullScreen padded>
<ScrollView flex={1}>
<View flex={1} gap="$s5">
🤖 Prompt for AI Agents
In `@src/components/add-funds/Status.tsx` around lines 38 - 40, Replace the
hardcoded gap={20} uses in the Status component's View elements with the
appropriate spacing design token from tamagui.config.ts (e.g., the spacing token
equivalent like "$4" or "space-4"); update both the outer View (the fullScreen
padded container) and the inner View (the child with gap) to use that token and,
if required by your setup, import/consume the token or theme so the component
uses the predefined spacing value rather than a raw number.

Comment on lines +43 to +46
<View width="100%" aspectRatio={1} justifyContent="center" alignItems="center">
{isOnboarding ? <FaceId width="100%" height="100%" /> : <Denied width="100%" height="100%" />}
</View>
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SVG components require numeric dimensions, not string percentages.

Using width="100%" and height="100%" on SVG components won't work as expected in React Native. SVG props don't resolve percentage strings. Pass numeric values or use the parent container's dimensions.

Based on learnings: "In React Native using SVG components, pass numeric literals for width and height (width={32}, height={32}) rather than design token strings."

🛠️ Suggested fix
               <View width="100%" aspectRatio={1} justifyContent="center" alignItems="center">
-                  {isOnboarding ? <FaceId width="100%" height="100%" /> : <Denied width="100%" height="100%" />}
+                  {isOnboarding ? <FaceId width={200} height={200} /> : <Denied width={200} height={200} />}
               </View>

Alternatively, compute dimensions dynamically from the container or screen dimensions.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<View width="100%" aspectRatio={1} justifyContent="center" alignItems="center">
{isOnboarding ? <FaceId width="100%" height="100%" /> : <Denied width="100%" height="100%" />}
</View>
<View width="100%" aspectRatio={1} justifyContent="center" alignItems="center">
{isOnboarding ? <FaceId width={200} height={200} /> : <Denied width={200} height={200} />}
</View>
🤖 Prompt for AI Agents
In `@src/components/add-funds/Status.tsx` around lines 43 - 45, The SVG components
FaceId and Denied are being passed percentage strings for width/height which SVG
in React Native doesn't support; update the JSX in the View that renders these
icons (the conditional using isOnboarding) to pass numeric width and height
values (e.g., width={...} height={...}) or compute numbers from the
container/screen dimensions before passing them; ensure the values are numeric
literals or variables (not strings) so FaceId and Denied receive valid numeric
props.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessary, the same pattern we use across many svgs

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, you're absolutely right, and I apologize for the incorrect flag. I have a learning that confirms this pattern: in your React Native codebase, using width="100%" and height="100%" as string props on SVG components is the standard approach. These SVGs are placed inside parent View components with aspectRatio set (as seen here with aspectRatio={1}), which controls the actual sizing while the SVG fills the available space with percentage dimensions.

This pattern works correctly and is consistent with your codebase conventions. Thank you for the clarification!


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Kyc.tsx:99-101
Timestamp: 2026-02-04T17:53:04.727Z
Learning: In the exactly/exa React Native codebase, using `width="100%"` and `height="100%"` as string props on SVG components is the standard pattern. These SVGs are typically placed inside parent View components with `aspectRatio` set, which controls the actual sizing while the SVG fills the available space with percentage dimensions.

Learnt from: franm91
Repo: exactly/exa PR: 614
File: src/components/benefits/BenefitSheet.tsx:79-84
Timestamp: 2026-01-05T21:35:46.251Z
Learning: In React Native, SVG components (like those used for logos) expect numeric values for width and height props (e.g., width={32} height={32}), not design token strings. This is a valid exception to the design token rule.

Learnt from: franm91
Repo: exactly/exa PR: 614
File: src/components/benefits/BenefitsSection.tsx:101-122
Timestamp: 2026-01-05T22:05:08.040Z
Learning: The Carousel component from react-native-reanimated-carousel requires numeric values for width and height props (e.g., width={300} height={160}), not design token strings. This is a valid exception to the design token rule.

Comment on lines +117 to +205
export function startMantecaKYC() {
if (current && !current.controller.signal.aborted && current.type === "manteca") return current.promise;

current?.controller.abort(new Error("persona inquiry aborted"));
const controller = new AbortController();

const promise = (async () => {
const { signal } = controller;

if (Platform.OS === "web") {
const onPageHide = () => controller.abort(new Error("page unloaded"));
globalThis.addEventListener("pagehide", onPageHide);
signal.addEventListener("abort", () => globalThis.removeEventListener("pagehide", onPageHide), { once: true });
}

if (Platform.OS === "web") {
const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([
import("persona"),
getKYCTokens("manteca", await getRedirectURI()),
]);
if (signal.aborted) throw signal.reason;

return new Promise<MantecaKYCResult>((resolve, reject) => {
const onAbort = () => {
client.destroy();
reject(new Error("persona inquiry aborted", { cause: signal.reason }));
};
const client = new Client({
inquiryId,
sessionToken,
environment: environment as "production" | "sandbox",
onReady: () => client.open(),
onComplete: () => {
signal.removeEventListener("abort", onAbort);
client.destroy();
queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
resolve({ status: "complete" });
},
onCancel: () => {
signal.removeEventListener("abort", onAbort);
client.destroy();
queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
resolve({ status: "cancel" });
},
onError: (error) => {
signal.removeEventListener("abort", onAbort);
client.destroy();
reportError(error);
resolve({ status: "error" });
},
});
signal.addEventListener("abort", onAbort, { once: true });
});
}

const { inquiryId, sessionToken } = await getKYCTokens("manteca", await getRedirectURI());
if (signal.aborted) throw signal.reason;

const { Inquiry } = await import("react-native-persona");
return new Promise<MantecaKYCResult>((resolve, reject) => {
const onAbort = () => reject(new Error("persona inquiry aborted", { cause: signal.reason }));
signal.addEventListener("abort", onAbort, { once: true });
Inquiry.fromInquiry(inquiryId)
.sessionToken(sessionToken)
.onCanceled(() => {
signal.removeEventListener("abort", onAbort);
queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
resolve({ status: "cancel" });
})
.onComplete(() => {
signal.removeEventListener("abort", onAbort);
queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
resolve({ status: "complete" });
})
.onError((error) => {
signal.removeEventListener("abort", onAbort);
reportError(error);
resolve({ status: "error" });
})
.build()
.start();
});
})().finally(() => {
if (current?.controller === controller) current = undefined;
});

current = { type: "manteca", controller, promise };
return promise;
}
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting shared logic to reduce duplication.

startMantecaKYC duplicates ~80 lines from startKYC. While the return types and post-completion behavior differ intentionally, the following patterns are identical and could be extracted:

  1. Pagehide listener setup (lines 126-130 mirror 33-36)
  2. Abort signal handling pattern
  3. Persona Client/Inquiry builder configuration

This would make future maintenance easier and reduce the risk of divergence bugs.

♻️ Example helper extraction
// helper for pagehide cleanup
function setupPagehideAbort(controller: AbortController) {
  if (Platform.OS !== "web") return;
  const onPageHide = () => controller.abort(new Error("page unloaded"));
  globalThis.addEventListener("pagehide", onPageHide);
  controller.signal.addEventListener(
    "abort",
    () => globalThis.removeEventListener("pagehide", onPageHide),
    { once: true }
  );
}

Similar helpers could wrap the Persona Client/Inquiry setup with callbacks for onComplete/onCancel/onError.

🤖 Prompt for AI Agents
In `@src/utils/persona.ts` around lines 117 - 205, startMantecaKYC duplicates
large portions of startKYC (pagehide listener, abort handling, Persona
client/inquiry setup); extract shared behavior into small helpers to remove
duplication. Create a helper like setupPagehideAbort(controller) that
installs/removes the pagehide listener and re-use it in both startMantecaKYC and
startKYC; extract a common attachAbortHandler(controller.signal, onAbort)
pattern and a factory that wires Persona callbacks (for Client and Inquiry) to
shared onComplete/onCancel/onError logic (referencing Client, Inquiry, onAbort,
controller.signal, queryClient.invalidateQueries and reportError) so both
functions call the shared helpers and only implement the KYC-specific
return/resolve logic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggested helpers would need to handle different return types and error handling, which would make them complex and difficult to understand.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, that's a fair point. Keeping each KYC flow self-contained does make the distinct behavior (return types, status resolution, query invalidation) more explicit and easier to follow. Thanks for the clarification! 👍


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: mainqueg
Repo: exactly/exa PR: 637
File: server/hooks/persona.ts:186-195
Timestamp: 2026-01-15T19:06:45.697Z
Learning: In server/hooks/persona.ts, the webhook handler has intentionally different error handling for Manteca vs Panda flows: the Manteca path (payload.template === "manteca") lets addDocument errors propagate to enable webhook retries, while the Panda path uses .catch() to swallow addDocument errors and prevent retries since the user creation is already committed.

Learnt from: cruzdanilo
Repo: exactly/exa PR: 653
File: src/utils/persona.ts:42-47
Timestamp: 2026-01-22T19:39:06.502Z
Learning: In `src/utils/persona.ts`, when using `Inquiry.fromInquiry(inquiryId)` to resume a server-created inquiry, the `.environment()` call is not needed because the environment was already set when the inquiry was created on the server. The `.environment()` call is only necessary when creating a new inquiry client-side using methods like `.fromTemplate()`.

Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Comment on lines 170 to 171
const { code } = (await response.json()) as { code: string };
throw new APIError(response.status, code);
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Type assertion bypasses runtime validation.

Using as { code: string } bypasses valibot's runtime validation. Consider defining a schema for API error responses and parsing with it for consistent error handling.

♻️ Proposed pattern
import { object, string, parse } from "valibot";

const APIErrorResponse = object({ code: string() });

// Then in error handling:
const { code } = parse(APIErrorResponse, await response.json());

This pattern could be applied consistently across lines 170, 186, 283, 295, and 309.

🤖 Prompt for AI Agents
In `@src/utils/server.ts` around lines 170 - 171, The error parsing uses a
TypeScript type assertion ((await response.json()) as { code: string }) which
bypasses runtime validation; define a valibot schema (e.g., APIErrorResponse =
object({ code: string() })) and replace the assertions by calling
parse(APIErrorResponse, await response.json()) and destructuring the returned {
code } before throwing APIError; import object, string, and parse from valibot
and apply this change consistently for the locations that construct APIError
(the occurrences around the current response handling code and the other
mentioned spots).

Copy link
Member Author

@franm91 franm91 Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will remove the assertion

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, understood! I'll skip this suggestion. Feel free to reach out if you need anything else.


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: CR
Repo: exactly/exa PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-03T01:18:33.411Z
Learning: Applies to **/*.{ts,tsx,sol} : leverage typescript, valibot, and solidity types to catch errors at compile time, not run time.

Learnt from: dieguezguille
Repo: exactly/exa PR: 683
File: src/utils/persona.ts:0-0
Timestamp: 2026-01-27T14:17:07.086Z
Learning: In `src/utils/persona.ts`, the type cast `(await sdk.context) as unknown as undefined | { client: { appUrl?: string } }` is intentional to support a custom `appUrl` property added to the miniapp implementation that is not documented or typed in the official `farcaster/miniapp-sdk`.

Learnt from: mainqueg
Repo: exactly/exa PR: 680
File: server/api/ramp.ts:157-168
Timestamp: 2026-01-27T14:36:26.450Z
Learning: In server/api/ramp.ts, the error handling pattern intentionally returns 4xx responses only for explicitly handled cases (e.g., MantecaErrorCodes.NOT_SUPPORTED_CURRENCY). Other error codes from MantecaErrorCodes should be re-thrown and result in 5xx errors, not automatically mapped to 400 responses.

return response.json();
}

export type RampProvider = (typeof RampProvider)[number];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove redundant type redeclaration.

RampProvider is already imported from @exactly/server/utils/ramps/shared on line 20. This line redeclares it, which shadows the import and triggers the static analysis error.

🐛 Proposed fix
-export type RampProvider = (typeof RampProvider)[number];
+export type { RampProvider };

Or if you need to re-export the runtime value as well:

+export { RampProvider };
🧰 Tools
🪛 Biome (2.3.13)

[error] 176-176: Shouldn't redeclare 'RampProvider'. Consider to delete it or rename it.

'RampProvider' is defined here:

(lint/suspicious/noRedeclare)

🤖 Prompt for AI Agents
In `@src/utils/server.ts` at line 176, Remove the redundant type redeclaration
that shadows the imported RampProvider: delete the line exporting "export type
RampProvider = (typeof RampProvider)[number];" so the imported RampProvider from
`@exactly/server/utils/ramps/shared` is used; if you intended to also re-export a
runtime value, instead export the imported symbol (e.g., export { RampProvider
}) rather than redefining its type.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/add-funds/AddFunds.tsx (1)

57-58: 🧹 Nitpick | 🔵 Trivial

Use design tokens for gap values.

gap={20} should use a design token instead of a hardcoded number.

As per coding guidelines: "All styling must use predefined design tokens."

♻️ Suggested fix
-        <View gap={20} fullScreen padded>
-          <YStack gap={20}>
+        <View gap="$s5" fullScreen padded>
+          <YStack gap="$s5">
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFunds.tsx`:
- Around line 38-46: The current useQuery implementation relies on getKYCStatus
as a side-effect to populate queryClient and then reads back via
queryClient.getQueryData, which is indirect and returns "" on failure (truthy)
so downstream providers query can enable incorrectly; change the query to return
the country code directly (have queryFn call an API that returns country or call
getKYCStatus and return its country value) or use the useQuery `select` option
to extract the country from the getKYCStatus response instead of reading
queryClient; also ensure the queryFn returns undefined (not empty string) when
the country is absent or getKYCStatus throws so the providers query remains
disabled.

In `@src/components/add-funds/Kyc.tsx`:
- Around line 23-71: The Kyc component currently contains routing, mutations and
cache reads (useMutation handling via handleContinue, completeOnboarding,
startMantecaKYC, startRampOnboarding, queryClient and router usage) and must be
converted to a dumb presentational component; extract all side-effect and
stateful logic into a new screen/container (e.g., KycContainer) which owns the
mutation (mutationKey ["kyc","complete","manteca"]), completeOnboarding flow,
queryClient interactions (invalidateQueries/fetchQuery), router.replace calls
and error reporting (reportError), and pass only props into the Kyc UI component
(e.g., isPending, onContinue callback, validCurrency, currency, countryCode) so
src/components/Kyc.tsx becomes purely presentational and receives callbacks/data
via props.
- Around line 30-43: The inline multi-line mutationFn passed to useMutation and
the multi-line Pressable onPress handler should be moved into named function
declarations for stability and readability: extract the async mutation body into
a function like function handleMantecaKyc() that calls startMantecaKYC(), checks
result.status ("cancel" / "error") and calls router.replace or
completeOnboarding() as currently implemented, then pass that function as
mutationFn to useMutation; likewise extract the Pressable handler into a named
function (e.g., function onPressContinue()) and reference it in the Pressable.
Ensure you keep existing symbols (startMantecaKYC, completeOnboarding,
router.replace, mutateAsync/handleContinue) and behavior unchanged while
replacing inline arrow bodies with these function declarations.

In `@src/components/add-funds/Onboard.tsx`:
- Line 24: The currencyImages map currently types SVG components as React.FC<{
height: string; width: string }> and the ARS, BRL, USD components are being
passed string percentages like "100%"; change the prop type to React.FC<{
height: number; width: number }>, update any usages of
currencyImages/ARS/BRL/USD to compute and pass numeric pixel values (e.g.,
containerHeight or parsed values) instead of "100%", and adjust places
referenced around the same usage (lines ~120-121) to pass numbers so the SVG
components receive numeric dimensions.

In `@src/components/add-funds/Ramp.tsx`:
- Line 92: Replace the hardcoded numeric gap value on the JSX View inside the
Ramp component: locate the View element with props flexDirection="row" gap={10}
justifyContent="space-between" alignItems="center" and change gap to use the
design token (for example gap="$s3") so all styling uses predefined design
tokens instead of raw numbers.

In `@src/utils/currencies.ts`:
- Around line 1-12: Replace the manual Currency type and import with valibot's
InferInput: import InferInput from valibot and derive Currency from the already
defined CurrencySchema (e.g., type Currency = InferInput<typeof CurrencySchema>)
instead of using keyof typeof currencyMap; keep currencyMap and CurrencySchema
(picklist) as-is so the schema is the single source of truth and the derived
Currency type stays in sync with CurrencySchema and currencyMap.

Comment on lines +38 to +46
const { data: countryCode } = useQuery({
queryKey: ["user", "country"],
queryFn: async () => {
await getKYCStatus("basic", true);
return queryClient.getQueryData<string>(["user", "country"]) ?? "";
},
staleTime: (query) => (query.state.data ? Infinity : 0),
retry: false,
});
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

The query pattern relies on side-effect data population.

The queryFn calls getKYCStatus which sets ["user", "country"] in the query client as a side effect, then reads it back. This works but is indirect. Consider returning the country code directly from the API or using select to extract it from the response.

Additionally, if getKYCStatus throws before setting the query data, the fallback empty string "" will be returned, which is truthy and may enable the providers query unexpectedly.

♻️ Potential simplification
   const { data: countryCode } = useQuery({
     queryKey: ["user", "country"],
     queryFn: async () => {
       await getKYCStatus("basic", true);
-      return queryClient.getQueryData<string>(["user", "country"]) ?? "";
+      return queryClient.getQueryData<string>(["user", "country"]);
     },
     staleTime: (query) => (query.state.data ? Infinity : 0),
     retry: false,
   });

This returns undefined instead of "" on failure, making the providers query stay disabled.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data: countryCode } = useQuery({
queryKey: ["user", "country"],
queryFn: async () => {
await getKYCStatus("basic", true);
return queryClient.getQueryData<string>(["user", "country"]) ?? "";
},
staleTime: (query) => (query.state.data ? Infinity : 0),
retry: false,
});
const { data: countryCode } = useQuery({
queryKey: ["user", "country"],
queryFn: async () => {
await getKYCStatus("basic", true);
return queryClient.getQueryData<string>(["user", "country"]);
},
staleTime: (query) => (query.state.data ? Infinity : 0),
retry: false,
});
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFunds.tsx` around lines 38 - 46, The current
useQuery implementation relies on getKYCStatus as a side-effect to populate
queryClient and then reads back via queryClient.getQueryData, which is indirect
and returns "" on failure (truthy) so downstream providers query can enable
incorrectly; change the query to return the country code directly (have queryFn
call an API that returns country or call getKYCStatus and return its country
value) or use the useQuery `select` option to extract the country from the
getKYCStatus response instead of reading queryClient; also ensure the queryFn
returns undefined (not empty string) when the country is absent or getKYCStatus
throws so the providers query remains disabled.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The countryCode is returned by the server in an HTTP header, not in the response body. The current pattern where getKYCStatus sets the cache as a side effect is a pragmatic solution to this server-side constraint.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, thank you for the clarification! That makes sense—if the country code is provided via an HTTP header rather than in the response body, the side-effect pattern where getKYCStatus caches it and the query reads it back is indeed a pragmatic solution to work with that server-side constraint. I appreciate the context.

Comment on lines 23 to 78
export default function Kyc() {
const { t } = useTranslation();
const router = useRouter();
const { currency } = useLocalSearchParams<{ currency: string }>();
const countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? "";
const validCurrency = isValidCurrency(currency);

const { mutateAsync: handleContinue, isPending } = useMutation({
mutationKey: ["kyc", "complete", "manteca"],
mutationFn: async () => {
const result = await startMantecaKYC();
if (result.status === "cancel") return;
if (result.status === "error") {
router.replace({
pathname: "/add-funds/status",
params: { status: "error", currency },
});
return;
}
await completeOnboarding();
},
});

if (!validCurrency) return <Redirect href="/add-funds" />;

async function completeOnboarding() {
try {
await startRampOnboarding({ provider: "manteca" });

await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] });

const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});
const newStatus = providers.providers.manteca.status;
if (newStatus === "ACTIVE") {
router.replace({ pathname: "/add-funds/ramp", params: { currency } });
} else if (newStatus === "ONBOARDING") {
router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } });
} else {
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
} catch (error) {
reportError(error);
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
}
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Move Kyc screen logic out of src/components.

This component drives routing, mutations, and cache reads; per project structure rules it should be a screen/container, leaving this file as a presentational UI component.
As per coding guidelines: "src/components/**/*.{ts,tsx}: Dumb components (UI) should receive data and callbacks via props with no knowledge of the application's state."

🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 23 - 71, The Kyc component
currently contains routing, mutations and cache reads (useMutation handling via
handleContinue, completeOnboarding, startMantecaKYC, startRampOnboarding,
queryClient and router usage) and must be converted to a dumb presentational
component; extract all side-effect and stateful logic into a new
screen/container (e.g., KycContainer) which owns the mutation (mutationKey
["kyc","complete","manteca"]), completeOnboarding flow, queryClient interactions
(invalidateQueries/fetchQuery), router.replace calls and error reporting
(reportError), and pass only props into the Kyc UI component (e.g., isPending,
onContinue callback, validCurrency, currency, countryCode) so
src/components/Kyc.tsx becomes purely presentational and receives callbacks/data
via props.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI is not reusable in other contexts - it's specifically designed for this journey. Splitting would add prop drilling without benefit.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, understood! I've already noted this pattern from our earlier discussion on this PR — these journey-specific components in src/components/add-funds/ intentionally co-locate their routing and state management with the UI since they're not designed for reuse elsewhere. Thank you for the clarification.


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Comment on lines 30 to 43
const { mutateAsync: handleContinue, isPending } = useMutation({
mutationKey: ["kyc", "complete", "manteca"],
mutationFn: async () => {
const result = await startMantecaKYC();
if (result.status === "cancel") return;
if (result.status === "error") {
router.replace({
pathname: "/add-funds/status",
params: { status: "error", currency },
});
return;
}
await completeOnboarding();
},
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Extract multi-line inline callbacks into named functions.

The mutationFn and Pressable handler are multi-line; move them to named functions for clarity and to keep handlers stable.
As per coding guidelines: "Prefer function declarations for all multi-line functions; use function expressions or arrow functions only for single-line implementations."

♻️ Proposed refactor
-  const { mutateAsync: handleContinue, isPending } = useMutation({
-    mutationKey: ["kyc", "complete", "manteca"],
-    mutationFn: async () => {
-      const result = await startMantecaKYC();
-      if (result.status === "cancel") return;
-      if (result.status === "error") {
-        router.replace({
-          pathname: "/add-funds/status",
-          params: { status: "error", currency },
-        });
-        return;
-      }
-      await completeOnboarding();
-    },
-  });
+  async function runKycFlow() {
+    const result = await startMantecaKYC();
+    if (result.status === "cancel") return;
+    if (result.status === "error") {
+      router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
+      return;
+    }
+    await completeOnboarding();
+  }
+
+  const { mutateAsync: handleContinue, isPending } = useMutation({
+    mutationKey: ["kyc", "complete", "manteca"],
+    mutationFn: runKycFlow,
+  });
+
+  function handleBackPress() {
+    if (router.canGoBack()) {
+      router.back();
+    } else {
+      router.replace("/(main)/(home)");
+    }
+  }
@@
-            <Pressable
-              onPress={() => {
-                if (router.canGoBack()) {
-                  router.back();
-                } else {
-                  router.replace("/(main)/(home)");
-                }
-              }}
-            >
+            <Pressable onPress={handleBackPress}>

Also applies to: 82-89

🤖 Prompt for AI Agents
In `@src/components/add-funds/Kyc.tsx` around lines 30 - 43, The inline multi-line
mutationFn passed to useMutation and the multi-line Pressable onPress handler
should be moved into named function declarations for stability and readability:
extract the async mutation body into a function like function handleMantecaKyc()
that calls startMantecaKYC(), checks result.status ("cancel" / "error") and
calls router.replace or completeOnboarding() as currently implemented, then pass
that function as mutationFn to useMutation; likewise extract the Pressable
handler into a named function (e.g., function onPressContinue()) and reference
it in the Pressable. Ensure you keep existing symbols (startMantecaKYC,
completeOnboarding, router.replace, mutateAsync/handleContinue) and behavior
unchanged while replacing inline arrow bodies with these function declarations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessary

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, understood! I'll skip this suggestion.


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

import Text from "../shared/Text";
import View from "../shared/View";

const currencyImages: Record<string, React.FC<{ height: string; width: string }>> = { ARS, BRL, USD };
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

head -150 src/components/add-funds/Onboard.tsx | tail -130

Repository: exactly/exa

Length of output: 4870


🏁 Script executed:

sed -n '1,30p' src/components/add-funds/Onboard.tsx

Repository: exactly/exa

Length of output: 1272


🏁 Script executed:

sed -n '115,125p' src/components/add-funds/Onboard.tsx

Repository: exactly/exa

Length of output: 665


🏁 Script executed:

rg -n "width.*height" src/components/add-funds/Onboard.tsx

Repository: exactly/exa

Length of output: 121


🏁 Script executed:

fd -e svg src/assets/images | grep -E "(ars|brl|usd)" | head -5

Repository: exactly/exa

Length of output: 428


🏁 Script executed:

rg "CurrencyImage|ARS|BRL|USD" src/components --type tsx -A 2 | head -40

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

fd . src/assets/images | head -10

Repository: exactly/exa

Length of output: 407


🏁 Script executed:

rg "width=" src/components -A 2 | grep -E "(CurrencyImage|SVG|svg)" -A 2 -B 2 | head -30

Repository: exactly/exa

Length of output: 819


🏁 Script executed:

cat package.json | grep -E "(svg|react-native|expo)"

Repository: exactly/exa

Length of output: 3534


🏁 Script executed:

cat src/components/shared/Blocky.tsx | head -50

Repository: exactly/exa

Length of output: 1383


🏁 Script executed:

rg "React.FC.*height.*width" src/components -A 2 -B 2

Repository: exactly/exa

Length of output: 415


🏁 Script executed:

rg "SVG.*width|SVG.*height" src/components -B 2 -A 2 | head -40

Repository: exactly/exa

Length of output: 413


🏁 Script executed:

rg "currencyImages|ARS|BRL|USD" src/ -B 3 -A 3 --type ts --type tsx

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

cat src/components/shared/Blocky.tsx | sed -n '1,10p'

Repository: exactly/exa

Length of output: 240


🏁 Script executed:

rg "currencyImages|ARS|BRL|USD" src -B 3 -A 3

Repository: exactly/exa

Length of output: 50367


🏁 Script executed:

cat src/components/shared/View.tsx | head -40

Repository: exactly/exa

Length of output: 516


Use numeric values for SVG component dimensions.

The currency image components (ARS, BRL, USD) are SVG-based and expect numeric dimensions, not string percentages. Update the prop type from string to number and pass computed numeric values instead of "100%".

🛠️ Example fix
-const currencyImages: Record<string, React.FC<{ height: string; width: string }>> = { ARS, BRL, USD };
+const currencyImages: Record<string, React.FC<{ height: number; width: number }>> = { ARS, BRL, USD };
@@
-                  <CurrencyImage width="100%" height="100%" />
+                  <CurrencyImage width={200} height={200} />

Also applies to: 120-121

🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` at line 24, The currencyImages map
currently types SVG components as React.FC<{ height: string; width: string }>
and the ARS, BRL, USD components are being passed string percentages like
"100%"; change the prop type to React.FC<{ height: number; width: number }>,
update any usages of currencyImages/ARS/BRL/USD to compute and pass numeric
pixel values (e.g., containerHeight or parsed values) instead of "100%", and
adjust places referenced around the same usage (lines ~120-121) to pass numbers
so the SVG components receive numeric dimensions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use this pattern is others svgs

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, you're absolutely right! String percentage dimensions like width="100%" and height="100%" are fully supported by react-native-svg, and I can see this is already the established pattern in your codebase (as documented from your earlier comment on Kyc.tsx).

The review suggestion to use numeric types was incorrect. Your current implementation is valid and follows the standard practice where these SVG components fill their parent container which controls the actual sizing through aspectRatio or other layout constraints.


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 614
File: src/components/benefits/BenefitSheet.tsx:79-84
Timestamp: 2026-01-05T21:35:46.251Z
Learning: In React Native, SVG components (like those used for logos) expect numeric values for width and height props (e.g., width={32} height={32}), not design token strings. This is a valid exception to the design token rule.

Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Kyc.tsx:99-101
Timestamp: 2026-02-04T17:53:04.727Z
Learning: In the exactly/exa React Native codebase, using `width="100%"` and `height="100%"` as string props on SVG components is the standard pattern. These SVGs are typically placed inside parent View components with `aspectRatio` set, which controls the actual sizing while the SVG fills the available space with percentage dimensions.

Learnt from: franm91
Repo: exactly/exa PR: 614
File: src/components/benefits/BenefitsSection.tsx:101-122
Timestamp: 2026-01-05T22:05:08.040Z
Learning: The Carousel component from react-native-reanimated-carousel requires numeric values for width and height props (e.g., width={300} height={160}), not design token strings. This is a valid exception to the design token rule.

Learnt from: dieguezguille
Repo: exactly/exa PR: 635
File: src/components/shared/PaymentScheduleSheet.tsx:73-77
Timestamp: 2026-01-15T16:28:54.965Z
Learning: In the exactly/exa repository, when displaying token amounts that require decimal precision (e.g., converting from base units using market.decimals), the pattern `Number(bigintAmount) / 10 ** decimals` should be used instead of `Number(bigintAmount / BigInt(10 ** decimals))`, because BigInt division truncates to integer and loses all fractional parts needed for proper display.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

<SafeView fullScreen>
<View gap={20} fullScreen padded>
<View gap={20}>
<View flexDirection="row" gap={10} justifyContent="space-between" alignItems="center">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use design tokens for gap values.

gap={10} should use a design token (e.g., gap="$s3").

As per coding guidelines: "All styling must use predefined design tokens."

♻️ Suggested fix
-          <View flexDirection="row" gap={10} justifyContent="space-between" alignItems="center">
+          <View flexDirection="row" gap="$s3" justifyContent="space-between" alignItems="center">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<View flexDirection="row" gap={10} justifyContent="space-between" alignItems="center">
<View flexDirection="row" gap="$s3" justifyContent="space-between" alignItems="center">
🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` at line 92, Replace the hardcoded numeric
gap value on the JSX View inside the Ramp component: locate the View element
with props flexDirection="row" gap={10} justifyContent="space-between"
alignItems="center" and change gap to use the design token (for example
gap="$s3") so all styling uses predefined design tokens instead of raw numbers.

Comment on lines 1 to 12
import { picklist, safeParse } from "valibot";

export const currencyMap = {
ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" },
BRL: { name: "Brazilian Real", emoji: "🇧🇷" },
USD: { name: "US Dollars", emoji: "🇺🇸" },
} as const;

export type Currency = keyof typeof currencyMap;

export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]);

Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if valibot is available and what it exports
rg -t ts "from ['\"]valibot" src/ | head -20

Repository: exactly/exa

Length of output: 1423


🏁 Script executed:

# Check the full context of the currencies.ts file
cat -n src/utils/currencies.ts

Repository: exactly/exa

Length of output: 666


🏁 Script executed:

# Search for usages of Currency type to understand if changing its definition would impact anything
rg "Currency" src/ -t ts --context 2

Repository: exactly/exa

Length of output: 5882


🏁 Script executed:

# Check if valibot's Input type is documented in package.json or dependencies
cat package.json | grep -A 5 "valibot"

Repository: exactly/exa

Length of output: 419


🏁 Script executed:

# Check if there are other files using Input from valibot to confirm the correct export name
rg "Input|InferInput" src/ -t ts | grep "from.*valibot"

Repository: exactly/exa

Length of output: 167


🏁 Script executed:

# Verify the valibot version and API
cat package.json | grep -E '"valibot":|"@hono/valibot-validator":'

Repository: exactly/exa

Length of output: 81


🌐 Web query:

valibot Input type export TypeScript inference

💡 Result:

Use Valibot’s utility type InferInput and export it from the schema’s typeof:

import * as v from "valibot";

export const LoginSchema = v.object({
  email: v.string(),
  password: v.string(),
});

export type LoginInput = v.InferInput<typeof LoginSchema>;
export type LoginOutput = v.InferOutput<typeof LoginSchema>; // optional
  • InferInput<typeof Schema> = the type accepted as input to validation. (valibot.dev)
  • InferOutput<typeof Schema> = the type returned by parse/safeParse (can differ if you use defaults/transforms/brands, etc.). (valibot.dev)
  • If you want a clean export pattern for both, Valibot recommends suffixing with Schema/Input/Output. (valibot.dev)

Citations:


Use InferInput instead of Input to derive Currency from CurrencySchema.

The core concept is correct—derive the type from the schema to keep them in sync and avoid the circular reference. However, valibot uses InferInput, not Input. The codebase already uses this pattern in src/utils/server.ts.

♻️ Suggested fix
-import { picklist, safeParse } from "valibot";
+import { picklist, safeParse } from "valibot";
+import type { InferInput } from "valibot";
@@
-export type Currency = keyof typeof currencyMap;
-
-export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]);
+export const CurrencySchema = picklist(
+  Object.keys(currencyMap) as [keyof typeof currencyMap, ...(keyof typeof currencyMap)[]],
+);
+export type Currency = InferInput<typeof CurrencySchema>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { picklist, safeParse } from "valibot";
export const currencyMap = {
ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" },
BRL: { name: "Brazilian Real", emoji: "🇧🇷" },
USD: { name: "US Dollars", emoji: "🇺🇸" },
} as const;
export type Currency = keyof typeof currencyMap;
export const CurrencySchema = picklist(Object.keys(currencyMap) as [Currency, ...Currency[]]);
import { picklist, safeParse } from "valibot";
import type { InferInput } from "valibot";
export const currencyMap = {
ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" },
BRL: { name: "Brazilian Real", emoji: "🇧🇷" },
USD: { name: "US Dollars", emoji: "🇺🇸" },
} as const;
export const CurrencySchema = picklist(
Object.keys(currencyMap) as [keyof typeof currencyMap, ...(keyof typeof currencyMap)[]],
);
export type Currency = InferInput<typeof CurrencySchema>;
🤖 Prompt for AI Agents
In `@src/utils/currencies.ts` around lines 1 - 12, Replace the manual Currency
type and import with valibot's InferInput: import InferInput from valibot and
derive Currency from the already defined CurrencySchema (e.g., type Currency =
InferInput<typeof CurrencySchema>) instead of using keyof typeof currencyMap;
keep currencyMap and CurrencySchema (picklist) as-is so the schema is the single
source of truth and the derived Currency type stays in sync with CurrencySchema
and currencyMap.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation already has currencyMap as the single source of truth. Both Currency type and CurrencySchema are derived from it, so they're inherently in sync.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91 You're absolutely right! Your current implementation is valid and maintains consistency effectively. Since currencyMap is the source of truth and both Currency and CurrencySchema derive directly from it, they will stay in sync. Thank you for the clarification.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFunds.tsx`:
- Around line 93-95: The onPress handler currently calls router.push with a raw
string ("/add-funds/bridge"); replace this with the Expo Router typed route
constant/function (e.g., use the generated Routes enum/const for the add-funds
bridge route) so navigation is type-safe. Update the onPress in AddFunds.tsx to
call router.push with the typed route (reference the existing useRouter()
instance) and add the import for the generated route identifiers (the Routes or
route helper used across the project) so the route is not constructed as a raw
string.

In `@src/components/add-funds/Kyc.tsx`:
- Line 27: The component reads countryCode at render time which can become
stale; move the queryClient.getQueryData<string>(["user","country"]) call into
the completeOnboarding function so the mutation always reads the latest cache
value. Update the code to remove the top-level countryCode constant and instead
call queryClient.getQueryData inside completeOnboarding (referencing the same
query key), then use that local countryCode variable in the existing mutation
payload/logic.

In `@src/components/add-funds/Onboard.tsx`:
- Around line 70-76: The code assumes providers.providers.manteca always exists
and then reads its status into newStatus, which can throw if manteca is missing;
update the logic around the fetchQuery result (the providers variable returned
by queryClient.fetchQuery / getRampProviders) to null-check or use optional
chaining for providers.providers.manteca before accessing .status and provide a
safe fallback (e.g., undefined or a default status) and handle that fallback
where newStatus is used so missing manteca entries won't cause a runtime error.

In `@src/components/add-funds/Status.tsx`:
- Around line 30-34: The code uses raw path strings in Redirect and
router.replace (see Redirect and handleClose/router.replace) which breaks the
typed-route convention; replace the string routes with the app's typed route
helpers (use the generated typed routes or route constants) so Redirect gets a
typed route prop and handleClose calls router.replace with the corresponding
typed route value instead of "/add-funds" or "/(main)/(home)"; update the
imports to pull the typed routes (or route helper functions) and use those
symbols wherever validCurrency gating currently returns or navigates.

In `@src/utils/server.ts`:
- Around line 302-314: The onboardingData parameter of startRampOnboarding is
overly restrictive (typed as { provider: "manteca" }); change its type to accept
the broader RampProvider (or a union that includes "manteca" and future
providers) so the function signature becomes something like
startRampOnboarding(onboardingData: { provider: RampProvider }) and update any
callers to pass a RampProvider value; keep the body (auth(), api.ramp.$post,
error handling and return response.json()) unchanged but ensure
imports/reference to RampProvider are added where startRampOnboarding is
defined.

Comment on lines +70 to +55
const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});

const newStatus = providers.providers.manteca.status;
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add null check for manteca provider to prevent runtime error.

providers.providers.manteca is accessed without checking if the manteca key exists. If the provider response doesn't include manteca (e.g., due to country restrictions), this will throw a runtime error.

🛡️ Add defensive check
       const providers = await queryClient.fetchQuery({
         queryKey: ["ramp", "providers", countryCode],
         queryFn: () => getRampProviders(countryCode),
         staleTime: 0,
       });

-      const newStatus = providers.providers.manteca.status;
+      const mantecaProvider = providers.providers.manteca;
+      if (!mantecaProvider) {
+        router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
+        return;
+      }
+      const newStatus = mantecaProvider.status;
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` around lines 70 - 76, The code assumes
providers.providers.manteca always exists and then reads its status into
newStatus, which can throw if manteca is missing; update the logic around the
fetchQuery result (the providers variable returned by queryClient.fetchQuery /
getRampProviders) to null-check or use optional chaining for
providers.providers.manteca before accessing .status and provide a safe fallback
(e.g., undefined or a default status) and handle that fallback where newStatus
is used so missing manteca entries won't cause a runtime error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is catch and redirect to the correct screen

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, you're right—the try-catch block around completeOnboarding() will catch any runtime error if manteca is missing, and the error handling in the mutation flow will manage the redirect appropriately.

While an explicit null check would make the error path more predictable and easier to debug (you'd know immediately it's a missing provider vs. another type of error), the current approach with try-catch does provide safety. I'll defer to your judgment here since the code is protected as-is. 👍


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: mainqueg
Repo: exactly/exa PR: 680
File: server/api/ramp.ts:157-168
Timestamp: 2026-01-27T14:36:26.450Z
Learning: In server/api/ramp.ts, the error handling pattern intentionally returns 4xx responses only for explicitly handled cases (e.g., MantecaErrorCodes.NOT_SUPPORTED_CURRENCY). Other error codes from MantecaErrorCodes should be re-thrown and result in 5xx errors, not automatically mapped to 400 responses.

Learnt from: mainqueg
Repo: exactly/exa PR: 637
File: server/hooks/persona.ts:186-195
Timestamp: 2026-01-15T19:06:45.697Z
Learning: In server/hooks/persona.ts, the webhook handler has intentionally different error handling for Manteca vs Panda flows: the Manteca path (payload.template === "manteca") lets addDocument errors propagate to enable webhook retries, while the Panda path uses .catch() to swallow addDocument errors and prevent retries since the user creation is already committed.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Comment on lines +302 to +310
export async function startRampOnboarding(onboardingData: { provider: "manteca" }) {
await auth();
const response = await api.ramp.$post({
json: onboardingData,
});

if (!response.ok) {
const { code } = await response.json();
throw new APIError(response.status, code);
}

return response.json();
}
Copy link

@coderabbitai coderabbitai bot Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider making the provider parameter more flexible.

The onboardingData parameter is typed as { provider: "manteca" } which is very restrictive. If additional providers are planned, consider using the RampProvider type.

♻️ Suggested improvement
-export async function startRampOnboarding(onboardingData: { provider: "manteca" }) {
+export async function startRampOnboarding(onboardingData: { provider: RampProvider }) {
🤖 Prompt for AI Agents
In `@src/utils/server.ts` around lines 302 - 314, The onboardingData parameter of
startRampOnboarding is overly restrictive (typed as { provider: "manteca" });
change its type to accept the broader RampProvider (or a union that includes
"manteca" and future providers) so the function signature becomes something like
startRampOnboarding(onboardingData: { provider: RampProvider }) and update any
callers to pass a RampProvider value; keep the body (auth(), api.ramp.$post,
error handling and return response.json()) unchanged but ensure
imports/reference to RampProvider are added where startRampOnboarding is
defined.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if a new provider is added the correct type will be implemented

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, that sounds reasonable! The type can be updated when additional providers are introduced. 👍


🧠 Learnings used
Learnt from: mainqueg
Repo: exactly/exa PR: 680
File: server/api/ramp.ts:157-168
Timestamp: 2026-01-27T14:36:26.450Z
Learning: In server/api/ramp.ts, the error handling pattern intentionally returns 4xx responses only for explicitly handled cases (e.g., MantecaErrorCodes.NOT_SUPPORTED_CURRENCY). Other error codes from MantecaErrorCodes should be re-thrown and result in 5xx errors, not automatically mapped to 400 responses.

Learnt from: mainqueg
Repo: exactly/exa PR: 680
File: server/api/ramp.ts:186-195
Timestamp: 2026-01-27T14:44:40.043Z
Learning: In server/api/ramp.ts, the error handling pattern for Bridge operations intentionally returns 4xx responses only for explicitly handled cases (e.g., BridgeErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL). Other error codes from BridgeErrorCodes should be re-thrown and result in 5xx errors, not automatically mapped to 400 responses.

Learnt from: mainqueg
Repo: exactly/exa PR: 700
File: server/utils/ramps/manteca.ts:52-71
Timestamp: 2026-01-30T17:31:23.674Z
Learning: In server/utils/ramps/manteca.ts, the uploadIdentityFile function's withRetry implementation intentionally retries all error codes (including 4xx client errors) for simplicity. The team plans to monitor errors and potentially refine the retry logic later based on observed behavior.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/server.ts (1)

178-190: 🧹 Nitpick | 🔵 Trivial

createMantecaKYC duplicates getKYCTokens logic.

createMantecaKYC is nearly identical to calling getKYCTokens("manteca", redirectURI). Consider reusing the existing function to reduce duplication.

♻️ Suggested consolidation
 export async function createMantecaKYC(redirectURI?: string) {
-  await auth();
-  const response = await api.kyc.$post({ json: { scope: "manteca", redirectURI } });
-  if (!response.ok) {
-    const { code } = (await response.json()) as { code: string };
-    throw new APIError(response.status, code);
-  }
-  return response.json();
+  return getKYCTokens("manteca", redirectURI);
 }
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFiatButton.tsx`:
- Around line 25-46: The router.push calls in handlePress should use compact
single-line object literals; update each case (NOT_STARTED, ONBOARDING, ACTIVE)
so their router.push invocation passes a single-line object (e.g., router.push({
pathname: "/add-funds/onboard", params: { currency } })) rather than multi-line
blocks—locate handlePress and replace the multi-line objects passed to
router.push with single-line equivalents to satisfy the compact-style rule.

In `@src/components/add-funds/Onboard.tsx`:
- Line 60: CurrencyImage is being computed before the validCurrency check,
causing an undefined or invalid currency to temporarily resolve to ARS; move the
CurrencyImage resolution so it only runs after validation (i.e., compute
CurrencyImage after the validCurrency check or guard with validCurrency) by
updating the code around the CurrencyImage assignment (reference symbols:
CurrencyImage, currency, currencyImages, ARS, validCurrency) so that an
invalid/missing currency does not momentarily pick ARS before the redirect.

In `@src/components/add-funds/Ramp.tsx`:
- Line 67: The current assignment const depositInfo = data?.depositInfo[0] can
yield undefined when depositInfo is an empty array; change it to explicitly
handle empty arrays by using either data?.depositInfo?.at(0) or a length check
(e.g., data?.depositInfo?.length ? data.depositInfo[0] : undefined) so
depositInfo is deterministically undefined only when there is no element, and
update any downstream usage that assumes an element exists to handle the
explicit undefined from depositInfo in the Ramp component.
- Around line 57-63: The query currently casts currency with `currency as
Currency` even though `validCurrency` already guarantees validity; instead,
narrow the value before calling useQuery to avoid inline casts: compute a typed
variable (e.g., const typedCurrency = validCurrency ? currency as Currency :
undefined), set useQuery enabled to !!typedCurrency, and pass typedCurrency into
the queryFn for getRampQuote; reference symbols: useQuery, getRampQuote,
validCurrency, currency, and queryFn. This ensures the type is asserted once
outside the query and removes the unsafe cast inside the queryFn.

In `@src/i18n/es.json`:
- Around line 540-560: The new add-funds i18n keys were appended to the end of
es.json; move them into the existing onboarding/fiat section (or insert in
sorted order) so related strings are grouped—locate keys like "Turn {{currency}}
transfers to onchain USDC", "Transfer from accounts in your name and
automatically receive USDC in your Exa account.", "Copy and share your account
details to turn {{currency}} transfers into USDC.", "Account details",
"Beneficiary name", and "Continue verification" and insert them near the other
onboarding/fiat strings (or alphabetically among nearby keys) rather than
leaving them at the end of the file.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFunds.tsx`:
- Around line 114-121: Rename the variable providerData to a more concrete noun
(e.g., provider) in the map over Object.entries(providers.providers) so the
callback signature becomes ([providerKey, provider]) and update all references
inside that callback (currencies = provider.onramp.currencies,
status={provider.status}) to use the new name; ensure the key construction
(key={`${providerKey}-${currency}`}) and the AddFiatButton usage remain
unchanged.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View issue and 14 additional flags in Devin Review.

Open in Devin Review

Comment on lines 3 to 7
export const currencyMap = {
ARS: { name: "Argentinian Pesos", emoji: "🇦🇷" },
BRL: { name: "Brazilian Real", emoji: "🇧🇷" },
USD: { name: "US Dollars", emoji: "🇺🇸" },
} as const;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Currency validation mismatch causes redirect loop for non-ARS/BRL/USD currencies

The client-side currencyMap only defines ARS, BRL, and USD, but the server can return additional currencies (CLP, COP, PUSD, CRC, GTQ, MXN, PHP, BOB) based on the user's country.

Click to expand

Flow that triggers the bug:

  1. AddFunds.tsx fetches providers from server via getRampProviders()
  2. Server returns currencies based on user's country (e.g., CLP for Chile users via CurrenciesByExchange in server/utils/ramps/manteca.ts:570-581)
  3. AddFiatButton renders with currency="CLP" and user clicks it
  4. User navigates to /add-funds/onboard?currency=CLP
  5. Onboard.tsx calls isValidCurrency("CLP") which returns false because CLP is not in currencyMap
  6. User is immediately redirected back to /add-funds via <Redirect href="/add-funds" />

Actual vs Expected:

  • Actual: Users from Chile, Colombia, Panama, Costa Rica, Guatemala, Mexico, Philippines, and Bolivia cannot complete the onramp flow - they get stuck in a redirect loop.
  • Expected: All currencies returned by the server should be valid on the client side.

Impact:

Users from 8 countries cannot use the manteca ramp feature at all despite the server supporting their currencies.

Recommendation: Sync the client-side currencyMap with all currencies from the server's MantecaCurrency array, or fetch the valid currencies dynamically from the server.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good review but for now the client will support limited currencies

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/add-funds/Ramp.tsx`:
- Around line 124-142: The onCopy callbacks passed to DetailRow (for DetailRow
props in Ramp.tsx) call copyToClipboard with variables of type string |
undefined (beneficiaryName, depositAddress, depositAlias) using
short-circuiting, which doesn't narrow types inside the callback; update each
onCopy to perform an explicit runtime type check (e.g., if (typeof
beneficiaryName === "string") copyToClipboard(beneficiaryName)) for
beneficiaryName, depositAddress and depositAlias so only a string is passed to
copyToClipboard and TypeScript type errors are resolved.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFiatButton.tsx`:
- Line 19: The emoji lookup uses the `in` operator which can match prototype
properties; update the guard in the `emoji` assignment in AddFiatButton (the
`emoji` constant that reads from `currencyMap`) to use an own-property check
such as Object.prototype.hasOwnProperty.call(currencyMap, currency) so the
fallback "💰" is used when the map doesn't actually contain the currency key;
keep the existing type assertion (e.g., `currency as keyof typeof currencyMap`)
when accessing the map value.

const { t } = useTranslation();
const router = useRouter();

const emoji = currency in currencyMap ? currencyMap[currency as keyof typeof currencyMap].emoji : "💰";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard emoji lookup against prototype keys.

currency in currencyMap will match prototype properties (e.g., toString), skipping the fallback and yielding undefined. Use an own-property check to keep the fallback reliable.

♻️ Proposed fix
-  const emoji = currency in currencyMap ? currencyMap[currency as keyof typeof currencyMap].emoji : "💰";
+  const emoji = Object.hasOwn(currencyMap, currency) ? currencyMap[currency as keyof typeof currencyMap].emoji : "💰";
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFiatButton.tsx` at line 19, The emoji lookup uses
the `in` operator which can match prototype properties; update the guard in the
`emoji` assignment in AddFiatButton (the `emoji` constant that reads from
`currencyMap`) to use an own-property check such as
Object.prototype.hasOwnProperty.call(currencyMap, currency) so the fallback "💰"
is used when the map doesn't actually contain the currency key; keep the
existing type assertion (e.g., `currency as keyof typeof currencyMap`) when
accessing the map value.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/shared/InfoAlert.tsx (1)

44-67: 🧹 Nitpick | 🔵 Trivial

Consider moving the conditional rendering to wrap the Pressable.

When actionText is undefined, the Pressable still renders as an empty interactive element with an onPress handler. This creates an invisible tap target that may confuse users or accessibility tools.

♻️ Suggested refactor
-        <Pressable
-          onPress={() => {
-            onPress?.();
-          }}
-        >
-          {actionText && (
-            <XStack flexDirection="row" gap={2} alignItems="center">
-              <Text color="$interactiveOnBaseInformationSoft" fontSize={15} fontWeight="bold">
-                {actionText}
-              </Text>
-              {loading ? (
-                <Spinner color="$interactiveOnBaseInformationSoft" />
-              ) : (
-                <ChevronRight
-                  size={16}
-                  color="$interactiveOnBaseInformationSoft"
-                  fontWeight="bold"
-                  strokeWidth={3}
-                  transform={[{ translateY: 1.2 }]}
-                />
-              )}
-            </XStack>
-          )}
-        </Pressable>
+        {actionText && (
+          <Pressable onPress={() => { onPress?.(); }}>
+            <XStack flexDirection="row" gap={2} alignItems="center">
+              <Text color="$interactiveOnBaseInformationSoft" fontSize={15} fontWeight="bold">
+                {actionText}
+              </Text>
+              {loading ? (
+                <Spinner color="$interactiveOnBaseInformationSoft" />
+              ) : (
+                <ChevronRight
+                  size={16}
+                  color="$interactiveOnBaseInformationSoft"
+                  fontWeight="bold"
+                  strokeWidth={3}
+                  transform={[{ translateY: 1.2 }]}
+                />
+              )}
+            </XStack>
+          </Pressable>
+        )}
src/utils/server.ts (1)

180-188: 🧹 Nitpick | 🔵 Trivial

createMantecaKYC duplicates getKYCTokens logic.

This function is identical to calling getKYCTokens("manteca", redirectURI). Consider reusing the existing function:

♻️ Proposed simplification
-export async function createMantecaKYC(redirectURI?: string) {
-  await auth();
-  const response = await api.kyc.$post({ json: { scope: "manteca", redirectURI } });
-  if (!response.ok) {
-    const { code } = await response.json();
-    throw new APIError(response.status, code);
-  }
-  return response.json();
-}
+export async function createMantecaKYC(redirectURI?: string) {
+  return getKYCTokens("manteca", redirectURI);
+}
🤖 Fix all issues with AI agents
In `@src/app/`(main)/add-funds/_layout.tsx:
- Around line 14-17: The Stack.Screen entries in _layout.tsx are unsorted;
reorder the Stack.Screen components alphabetically by their name prop to improve
consistency and diff readability (change the sequence containing the
Stack.Screen elements "onboard", "kyc", "status", "ramp" to alphabetic order:
"kyc", "onboard", "ramp", "status").

In `@src/components/add-funds/AddFundsOption.tsx`:
- Around line 26-29: Replace the hardcoded hex shadowColor "#EEF1F0" in the
AddFundsOption component with the design token string "$grayscaleLight3"; locate
the shadowColor prop (in AddFundsOption.tsx where shadowColor="#EEF1F0") and set
it to " $grayscaleLight3 " so it uses the tamagui token (matching other uses
like "$uiNeutralSecondary"), keeping the rest of the shadow props (shadowOffset,
shadowOpacity, shadowRadius) unchanged.

In `@src/components/add-funds/MantecaDisclaimer.tsx`:
- Around line 16-18: Remove the redundant XStack wrapper around the Info icon in
the MantecaDisclaimer component: locate the XStack that only contains the Info
component and delete the XStack so Info is placed directly as the sibling where
the wrapper was; confirm layout and styling still apply (any props on XStack
that were intended for spacing should be moved to the parent or applied directly
to Info) and keep the Info import unchanged.
- Line 7: The file imports t directly from "i18next" which bypasses React
context; update the MantecaDisclaimer component to use the react-i18next hook
instead: remove the direct import { t } from "i18next", import useTranslation
from "react-i18next" (alongside Trans which is already imported), then call
const { t } = useTranslation() inside the MantecaDisclaimer functional component
and keep using t(...)/Trans as before so translations respond to language
changes.

In `@src/components/add-funds/Onboard.tsx`:
- Around line 169-171: In the Onboard component locate the Trans element that
uses i18nKey="I accept the <terms>Terms and Conditions</terms> ." and remove the
unintended space before the period so the key reads "I accept the <terms>Terms
and Conditions</terms>."; update the i18nKey (or corresponding translation
resource) accordingly to eliminate the extra space and ensure punctuation
renders correctly.
- Line 35: The checkbox state is initialized as checked which lets users proceed
without consent; change the useState initialization in the Onboard component
from useState(true) to useState(false) so acknowledged starts unchecked (update
references to acknowledged and setAcknowledged as needed), and remove the stray
space before the period in the i18nKey string so the localized label punctuation
is correct.

In `@src/i18n/es.json`:
- Line 564: The JSON i18n entry uses incorrect verb agreement in the source key;
update the English source key string "The fiat deposit services are provided by
<provider>Manteca</provider> (Sixalime SAS) and is subject to <terms>Terms and
Conditions</terms>." to use "are subject to" (so both verbs are plural) and
update the corresponding Spanish translation key to match the corrected source
key exactly, preserving the <provider> and <terms> placeholder tags and the
existing Spanish text.

In `@src/utils/currencies.ts`:
- Around line 3-7: Update the currencyMap constant so the ARS entry uses the
standard demonym: change the name value in currencyMap.AR S (the ARS object)
from "Argentinian Pesos" to "Argentine Pesos" while leaving the emoji and other
entries unchanged.

Comment on lines 26 to 29
shadowColor="#EEF1F0"
shadowOffset={{ width: 0, height: 6 }}
shadowOpacity={0.6}
shadowRadius={28}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for shadowColor usage in the codebase
echo "=== shadowColor usage ==="
rg "shadowColor" --type=ts --type=tsx -C2 | head -50

echo -e "\n=== Design token definitions (shadow-related) ==="
rg "shadow|Shadow" --type=ts --type=tsx -B2 -A2 | grep -E "^\s*\$|export.*shadow|const.*shadow" | head -30

echo -e "\n=== Checking the specific file ==="
rg "\$[a-zA-Z]+" src/components/add-funds/AddFundsOption.tsx

echo -e "\n=== Looking for token definitions file ==="
fd -type f -name "*token*" -o -name "*theme*" -o -name "*colors*" | grep -E "\.(ts|tsx|js|json)$" | head -20

Repository: exactly/exa

Length of output: 455


🏁 Script executed:

#!/bin/bash
# Search for shadowColor in tsx/ts files
echo "=== shadowColor usage ==="
find . -name "*.tsx" -o -name "*.ts" | xargs grep -l "shadowColor" 2>/dev/null | head -10

echo -e "\n=== Examining AddFundsOption.tsx ==="
cat src/components/add-funds/AddFundsOption.tsx | head -50

echo -e "\n=== Looking for design token/theme files ==="
find . -name "*token*" -o -name "*theme*" -o -name "*colors*" | grep -E "\.(ts|tsx|js|json)$" | head -20

echo -e "\n=== Searching for design token definitions ==="
find . -name "*.ts" -o -name "*.tsx" | xargs grep -l "\\$background\|\\$interactive" 2>/dev/null | head -5

Repository: exactly/exa

Length of output: 1612


🏁 Script executed:

#!/bin/bash
# Find tamagui configuration or token definitions
echo "=== Searching for tamagui config or theme setup ==="
find . -name "tamagui.config.*" -o -name "tamagui.json" | head -10

echo -e "\n=== Looking at the other files using shadowColor ==="
echo "--- Card.tsx shadowColor context ---"
grep -n "shadowColor" ./src/components/card/Card.tsx -B2 -A2

echo -e "\n--- PaymentsActions.tsx shadowColor context ---"
grep -n "shadowColor" ./src/components/pay-mode/PaymentsActions.tsx -B2 -A2

echo -e "\n=== Searching for token definitions (broader search) ==="
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) | xargs grep -l "\\$backgroundSoft\|token\|shadow" 2>/dev/null | grep -E "config|theme|token" | head -10

echo -e "\n=== Checking package.json for theme/design tokens ==="
grep -i "tamagui\|design\|token\|theme" package.json 2>/dev/null | head -10

Repository: exactly/exa

Length of output: 1217


🏁 Script executed:

#!/bin/bash
echo "=== Reading tamagui.config.ts ==="
cat tamagui.config.ts | head -150

echo -e "\n=== Searching for color definitions matching `#EEF1F0` ==="
grep -rn "EEF1F0\|eef1f0" . --include="*.ts" --include="*.tsx" --include="*.js" --include="*.json" 2>/dev/null

Repository: exactly/exa

Length of output: 5648


Replace the hardcoded shadowColor with the design token $grayscaleLight3.

The hex value "#EEF1F0" is already defined as grayscaleLight3 in tamagui.config.ts. Other files in the codebase use design tokens for shadow colors (e.g., $uiNeutralSecondary, $uiNeutralPrimary), so using shadowColor="$grayscaleLight3" here maintains consistency and improves maintainability.

🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFundsOption.tsx` around lines 26 - 29, Replace
the hardcoded hex shadowColor "#EEF1F0" in the AddFundsOption component with the
design token string "$grayscaleLight3"; locate the shadowColor prop (in
AddFundsOption.tsx where shadowColor="#EEF1F0") and set it to " $grayscaleLight3
" so it uses the tamagui token (matching other uses like "$uiNeutralSecondary"),
keeping the rest of the shadow props (shadowOffset, shadowOpacity, shadowRadius)
unchanged.

import { Info } from "@tamagui/lucide-icons";
import { XStack, YStack } from "tamagui";

import { t } from "i18next";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check usage patterns of t import vs useTranslation hook
echo "=== Direct t import from i18next ==="
rg -n "import.*\{ t \}.*from ['\"]i18next['\"]" src/components/ | head -20

echo -e "\n=== useTranslation hook usage ==="
rg -n "useTranslation" src/components/ | head -20

echo -e "\n=== Checking the specific file ==="
cat -n src/components/add-funds/MantecaDisclaimer.tsx

Repository: exactly/exa

Length of output: 4101


Use useTranslation hook instead of direct t import to match codebase patterns.

All other components in this codebase import from react-i18next and use the useTranslation hook. The direct import from i18next bypasses React context and won't respond to language changes. Since the component already imports Trans from react-i18next, import and call useTranslation here as well.

🤖 Prompt for AI Agents
In `@src/components/add-funds/MantecaDisclaimer.tsx` at line 7, The file imports t
directly from "i18next" which bypasses React context; update the
MantecaDisclaimer component to use the react-i18next hook instead: remove the
direct import { t } from "i18next", import useTranslation from "react-i18next"
(alongside Trans which is already imported), then call const { t } =
useTranslation() inside the MantecaDisclaimer functional component and keep
using t(...)/Trans as before so translations respond to language changes.

<XStack>
<Info size="$iconSize.md" strokeWidth="$iconStroke.md" color="$uiInfoSecondary" />
</XStack>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Redundant XStack wrapper around the icon.

The XStack on line 16 wrapping only the Info icon adds no layout benefit. The icon can be placed directly as a sibling.

♻️ Suggested simplification
     <XStack alignItems="center" gap="$s3">
-      <XStack>
-        <Info size="$iconSize.md" strokeWidth="$iconStroke.md" color="$uiInfoSecondary" />
-      </XStack>
+      <Info size="$iconSize.md" strokeWidth="$iconStroke.md" color="$uiInfoSecondary" />
       <YStack flex={1} flexShrink={1}>
🤖 Prompt for AI Agents
In `@src/components/add-funds/MantecaDisclaimer.tsx` around lines 16 - 18, Remove
the redundant XStack wrapper around the Info icon in the MantecaDisclaimer
component: locate the XStack that only contains the Info component and delete
the XStack so Info is placed directly as the sibling where the wrapper was;
confirm layout and styling still apply (any props on XStack that were intended
for spacing should be moved to the parent or applied directly to Info) and keep
the Info import unchanged.

const { currency } = useLocalSearchParams<{ currency: string }>();
const validCurrency = isValidCurrency(currency);

const [acknowledged, setAcknowledged] = useState(true);
Copy link

@coderabbitai coderabbitai bot Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/add-funds/Onboard.tsx | head -50

Repository: exactly/exa

Length of output: 2442


🏁 Script executed:

cat -n src/components/add-funds/Onboard.tsx | tail -n +50 | head -100

Repository: exactly/exa

Length of output: 4176


🏁 Script executed:

cat -n src/components/add-funds/Onboard.tsx | tail -n +145 | head -80

Repository: exactly/exa

Length of output: 2387


🏁 Script executed:

git log --oneline -20 -- src/components/add-funds/Onboard.tsx

Repository: exactly/exa

Length of output: 94


🏁 Script executed:

rg "useState\(true\)" --type tsx --type ts -B2 -A2 | head -50

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

rg "useState\(true\)" -t ts -B2 -A2 | head -50

Repository: exactly/exa

Length of output: 2894


🏁 Script executed:

rg "I accept|Terms and Conditions" -t ts -B3 -A3

Repository: exactly/exa

Length of output: 4276


🏁 Script executed:

rg "acknowledged.*useState" -t ts -B1 -A1

Repository: exactly/exa

Length of output: 1306


🏁 Script executed:

rg "disabled.*acknowledged" -t ts -B2 -A2

Repository: exactly/exa

Length of output: 2961


Confirm terms checkbox should start unchecked and fix text spacing

The checkbox is pre‑checked via useState(true), allowing users to proceed without explicit consent. Since the button text reads "Accept and continue", initialize to false to require affirmative action. Additionally, the i18nKey has an extra space before the period.

✅ Suggested fixes
-  const [acknowledged, setAcknowledged] = useState(true);
+  const [acknowledged, setAcknowledged] = useState(false);
-                  i18nKey="I accept the <terms>Terms and Conditions</terms> ."
+                  i18nKey="I accept the <terms>Terms and Conditions</terms>."
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` at line 35, The checkbox state is
initialized as checked which lets users proceed without consent; change the
useState initialization in the Onboard component from useState(true) to
useState(false) so acknowledged starts unchecked (update references to
acknowledged and setAcknowledged as needed), and remove the stray space before
the period in the i18nKey string so the localized label punctuation is correct.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good review, for this case is correct

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, thank you for the clarification! I understand that the pre-checked checkbox is intentional for this specific onboarding context. I've noted this design decision and will keep it in mind for future reviews.


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:35-35
Timestamp: 2026-02-05T21:54:03.288Z
Learning: In `src/components/add-funds/Onboard.tsx`, the `acknowledged` state is intentionally initialized to `true` (pre-checked). This is an accepted design decision for this specific onboarding context.

Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/shared/InfoAlert.tsx (1)

44-67: ⚠️ Potential issue | 🟡 Minor

Conditionally render Pressable to avoid an empty touch target.

Right now the Pressable renders even when actionText is undefined, leaving an invisible/empty press area. Wrap the Pressable itself in the conditional so the action row is fully omitted when there’s no action text.

💡 Suggested change
-        <Pressable
-          onPress={() => {
-            onPress?.();
-          }}
-        >
-          {actionText && (
-            <XStack flexDirection="row" gap={2} alignItems="center">
-              <Text color="$interactiveOnBaseInformationSoft" fontSize={15} fontWeight="bold">
-                {actionText}
-              </Text>
-              {loading ? (
-                <Spinner color="$interactiveOnBaseInformationSoft" />
-              ) : (
-                <ChevronRight
-                  size={16}
-                  color="$interactiveOnBaseInformationSoft"
-                  fontWeight="bold"
-                  strokeWidth={3}
-                  transform={[{ translateY: 1.2 }]}
-                />
-              )}
-            </XStack>
-          )}
-        </Pressable>
+        {actionText ? (
+          <Pressable
+            onPress={() => {
+              onPress?.();
+            }}
+          >
+            <XStack flexDirection="row" gap={2} alignItems="center">
+              <Text color="$interactiveOnBaseInformationSoft" fontSize={15} fontWeight="bold">
+                {actionText}
+              </Text>
+              {loading ? (
+                <Spinner color="$interactiveOnBaseInformationSoft" />
+              ) : (
+                <ChevronRight
+                  size={16}
+                  color="$interactiveOnBaseInformationSoft"
+                  fontWeight="bold"
+                  strokeWidth={3}
+                  transform={[{ translateY: 1.2 }]}
+                />
+              )}
+            </XStack>
+          </Pressable>
+        ) : null}
🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFundsOption.tsx`:
- Around line 26-29: The shadowColor prop in the AddFundsOption component is
hardcoded to "#EEF1F0"; replace it with the appropriate Tamagui design token
(e.g., $shadowColor or the project-specific token) so the component respects
theme changes and dark mode. Locate the shadowColor usage in AddFundsOption
(shadowColor="#EEF1F0") and swap the literal string for the token constant or
variable used across the app (ensure you import or reference the token name
consistent with other components). If no token exists, create a design token
(e.g., shadowColor) in the theme/tokens and use that token in the shadowColor
prop to keep consistency.

In `@src/components/add-funds/MantecaDisclaimer.tsx`:
- Around line 40-42: In MantecaDisclaimer update the openBrowser call to keep
its single string argument on one line: locate the openBrowser invocation inside
the component and inline the URL argument (the call currently spanning multiple
lines) so the call is compact
(openBrowser("https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions").catch(reportError));
preserve the .catch(reportError) chain and no other behavior changes.
- Line 22: Update the i18n key string in the MantecaDisclaimer component to
correct the grammar: change the sentence in the i18nKey used in
src/components/add-funds/MantecaDisclaimer.tsx from "The fiat deposit services
are provided by <provider>Manteca</provider> (Sixalime SAS) and is subject to
<terms>Terms and Conditions</terms>." to use "and are subject" instead of "and
is subject"; also update any corresponding translation entries that use this
exact key so the translated text matches the corrected plural verb form.

In `@src/components/add-funds/Onboard.tsx`:
- Around line 169-171: The i18nKey for the Trans component in Onboard.tsx
currently contains an extra space before the period ("I accept the <terms>Terms
and Conditions</terms> ."); update that string in the Trans call to remove the
space so it reads "I accept the <terms>Terms and Conditions</terms>." ensuring
the displayed text becomes "Terms and Conditions." (Locate the Trans usage in
the Onboard component and edit the i18nKey value accordingly.)
- Line 35: The component currently initializes acknowledged to true allowing
users to bypass consent; change the useState call in Onboard.tsx (the const
[acknowledged, setAcknowledged] = useState(...) line) to initialize acknowledged
to false, update any related logic that enables progression (e.g.,
submit/continue button handlers or isDisabled checks) to require acknowledged
=== true before proceeding, and ensure setAcknowledged is invoked by the terms
checkbox/input so consent must be explicitly given.

In `@src/i18n/es.json`:
- Line 566: Remove the stray space before the period in the Spanish translation
key "I accept the <terms>Terms and Conditions</terms> ." in src/i18n/es.json so
it becomes "I accept the <terms>Terms and Conditions</terms>.", and then update
any source references that use this i18n key (search for the exact key string or
its usages in components/templates) to match the corrected key so lookups
continue to work.

<YStack flex={1} flexShrink={1}>
<Text color="$uiNeutralPlaceholder" caption2>
<Trans
i18nKey="The fiat deposit services are provided by <provider>Manteca</provider> (Sixalime SAS) and is subject to <terms>Terms and Conditions</terms>."
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix grammar in the disclaimer sentence.

“are provided … and is subject” should be “are provided … and are subject.” Update the i18n entries accordingly since the key is the sentence.

✏️ Proposed fix
-            i18nKey="The fiat deposit services are provided by <provider>Manteca</provider> (Sixalime SAS) and is subject to <terms>Terms and Conditions</terms>."
+            i18nKey="The fiat deposit services are provided by <provider>Manteca</provider> (Sixalime SAS) and are subject to <terms>Terms and Conditions</terms>."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
i18nKey="The fiat deposit services are provided by <provider>Manteca</provider> (Sixalime SAS) and is subject to <terms>Terms and Conditions</terms>."
i18nKey="The fiat deposit services are provided by <provider>Manteca</provider> (Sixalime SAS) and are subject to <terms>Terms and Conditions</terms>."
🤖 Prompt for AI Agents
In `@src/components/add-funds/MantecaDisclaimer.tsx` at line 22, Update the i18n
key string in the MantecaDisclaimer component to correct the grammar: change the
sentence in the i18nKey used in src/components/add-funds/MantecaDisclaimer.tsx
from "The fiat deposit services are provided by <provider>Manteca</provider>
(Sixalime SAS) and is subject to <terms>Terms and Conditions</terms>." to use
"and are subject" instead of "and is subject"; also update any corresponding
translation entries that use this exact key so the translated text matches the
corrected plural verb form.

Comment on lines +40 to +38
openBrowser(
"https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions",
).catch(reportError);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Keep function arguments compact.

Please inline the single string argument; it fits and keeps the call compact.

♻️ Proposed fix
-                    openBrowser(
-                      "https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions",
-                    ).catch(reportError);
+                    openBrowser("https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions").catch(reportError);
As per coding guidelines “enforce maximally compact code style. do not introduce line breaks inside objects, arrays, or function arguments voluntarily. let prettier break lines automatically only when a line exceeds printWidth.”
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
openBrowser(
"https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions",
).catch(reportError);
openBrowser("https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions").catch(reportError);
🤖 Prompt for AI Agents
In `@src/components/add-funds/MantecaDisclaimer.tsx` around lines 40 - 42, In
MantecaDisclaimer update the openBrowser call to keep its single string argument
on one line: locate the openBrowser invocation inside the component and inline
the URL argument (the call currently spanning multiple lines) so the call is
compact
(openBrowser("https://help.exactly.app/en/articles/13616694-fiat-on-ramp-terms-and-conditions").catch(reportError));
preserve the .catch(reportError) chain and no other behavior changes.

const { currency } = useLocalSearchParams<{ currency: string }>();
const validCurrency = isValidCurrency(currency);

const [acknowledged, setAcknowledged] = useState(true);
Copy link

@coderabbitai coderabbitai bot Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t pre-check Terms acceptance.

acknowledged starts as true, so users can proceed without explicit consent; this is a compliance risk. Initialize it to false so consent is explicit.

🛠️ Suggested fix
-  const [acknowledged, setAcknowledged] = useState(true);
+  const [acknowledged, setAcknowledged] = useState(false);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [acknowledged, setAcknowledged] = useState(true);
const [acknowledged, setAcknowledged] = useState(false);
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` at line 35, The component currently
initializes acknowledged to true allowing users to bypass consent; change the
useState call in Onboard.tsx (the const [acknowledged, setAcknowledged] =
useState(...) line) to initialize acknowledged to false, update any related
logic that enables progression (e.g., submit/continue button handlers or
isDisabled checks) to require acknowledged === true before proceeding, and
ensure setAcknowledged is invoked by the terms checkbox/input so consent must be
explicitly given.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good review, this case is ok to start in true

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@franm91, understood! I'll note that the pre-checked acknowledgment state is intentional for this onboarding flow.


✏️ Learnings added
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:35-35
Timestamp: 2026-02-05T21:54:03.288Z
Learning: In `src/components/add-funds/Onboard.tsx`, the `acknowledged` state is intentionally initialized to `true` (pre-checked). This is an accepted design decision for this specific onboarding context.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/add-funds/KYC.tsx`:
- Line 88: The View using a hardcoded gap value (View with props
flexDirection="row" gap={10} justifyContent="space-between" alignItems="center")
should use the project's spacing token system instead of a raw number; replace
gap={10} with the appropriate spacing token (e.g., gap={$s2} or the token that
maps to 10px in your design system) so it aligns with surrounding `$s*` tokens
and maintains consistent spacing across components.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/add-funds/Status.tsx`:
- Around line 49-54: Replace the hardcoded onboarding translation string in the
isOnboarding branch with the existing i18n key that accepts a currency
parameter: use the t function for the onboarding message and pass the
already-validated currency variable (available in the component scope) so the
translation uses {{currency}}; update the JSX in the isOnboarding branch (where
isOnboarding, t, and currency are referenced) to call t with the currency param
instead of the non-existent literal key.

Comment on lines +49 to +54
{isOnboarding ? t("Almost there!") : t("Verification failed")}
</Text>
<Text color="$uiNeutralPlaceholder" footnote textAlign="center">
{isOnboarding
? t("We're verifying your information. You'll be able to add funds soon.")
: t("There was an error verifying your information.")}
Copy link

@coderabbitai coderabbitai bot Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.tsx" -o -name "*.ts" | grep -i status | head -20

Repository: exactly/exa

Length of output: 211


🏁 Script executed:

find . -type f \( -name "*.json" -o -name "*.ts" -o -name "*.js" \) | grep -i "locale\|i18n\|translation" | head -20

Repository: exactly/exa

Length of output: 92


🏁 Script executed:

fd "Status" -e tsx -e ts

Repository: exactly/exa

Length of output: 167


🏁 Script executed:

cat -n src/components/add-funds/Status.tsx | head -80

Repository: exactly/exa

Length of output: 3072


🏁 Script executed:

cat src/i18n/en.json

Repository: exactly/exa

Length of output: 1070


🏁 Script executed:

grep -n "We're verifying" src/i18n/en.json

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

grep -n "add funds" src/i18n/en.json

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

wc -l src/i18n/en.json

Repository: exactly/exa

Length of output: 74


🏁 Script executed:

cat src/i18n/en.json

Repository: exactly/exa

Length of output: 1070


🏁 Script executed:

cat src/i18n/es.json

Repository: exactly/exa

Length of output: 45654


Use the existing i18n key with currency parameter.

The current key "We're verifying your information. You'll be able to add funds soon." doesn't exist in the locale files. Only the version with {{currency}} is available. The currency variable is already validated in scope at line 28, so pass it to the translation:

Fix
                    {isOnboarding
-                      ? t("We're verifying your information. You'll be able to add funds soon.")
+                      ? t("We're verifying your information. You'll be able to add funds in {{currency}} soon.", { currency })
                       : t("There was an error verifying your information.")}
🤖 Prompt for AI Agents
In `@src/components/add-funds/Status.tsx` around lines 49 - 54, Replace the
hardcoded onboarding translation string in the isOnboarding branch with the
existing i18n key that accepts a currency parameter: use the t function for the
onboarding message and pass the already-validated currency variable (available
in the component scope) so the translation uses {{currency}}; update the JSX in
the isOnboarding branch (where isOnboarding, t, and currency are referenced) to
call t with the currency param instead of the non-existent literal key.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they key is correct

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for both versions of the translation key in en.json and es.json

echo "=== Checking en.json ==="
cat src/i18n/en.json | jq 'keys[] | select(test("verifying your information"))'

echo ""
echo "=== Checking es.json ==="
cat src/i18n/es.json | jq 'keys[] | select(test("verifying|verificando"))'

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Search for the exact keys being used in the code

echo "=== Searching for exact key WITHOUT currency param in es.json ==="
grep -n "We're verifying your information. You'll be able to add funds soon." src/i18n/es.json

echo ""
echo "=== Searching for exact key WITH currency param in es.json ==="
grep -n "We're verifying your information. You'll be able to add funds in {{currency}} soon." src/i18n/es.json

echo ""
echo "=== Let's also check the actual line in Status.tsx ==="
grep -n "We're verifying" src/components/add-funds/Status.tsx

Length of output: 463


@franm91, I understand. Just to clarify the situation: while English will work fine (i18next falls back to displaying the key itself), the Spanish translation file only contains the version with the {{currency}} parameter:

es.json (line 557):

"We're verifying your information. You'll be able to add funds in {{currency}} soon.": "Estamos verificando tu información. Pronto podrás agregar fondos en {{currency}}."

Status.tsx (line 53):

t("We're verifying your information. You'll be able to add funds soon.")

Since these keys don't match exactly, Spanish-speaking users will see the English text instead of the Spanish translation. If that's intentional, then no changes are needed! 👍


🧠 Learnings used
Learnt from: franm91
Repo: exactly/exa PR: 704
File: src/components/add-funds/Onboard.tsx:26-96
Timestamp: 2026-02-04T18:12:25.754Z
Learning: In `src/components/add-funds/`, journey-specific components like `Onboard.tsx`, `Kyc.tsx`, `Ramp.tsx`, and `Status.tsx` intentionally co-locate routing, mutations, and server-state orchestration with their UI. These components are not designed to be reusable in other contexts, so splitting them into smart/dumb components would add prop drilling without benefit.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/home/card-upgrade/VerifyIdentity.tsx:27-29
Timestamp: 2026-01-22T19:01:57.860Z
Learning: In `src/components/home/card-upgrade/VerifyIdentity.tsx`, the KYC mutation logic intentionally differs from the `GettingStarted.tsx` variants. The differences include calling `queryClient.setQueryData(["card-upgrade"], 1)` on success status and showing a toast via `onError` callback. These are intentional flow-specific behaviors for the card upgrade context.

Learnt from: franm91
Repo: exactly/exa PR: 653
File: src/components/getting-started/GettingStarted.tsx:132-133
Timestamp: 2026-01-22T18:44:56.353Z
Learning: In the KYC flow refactor, the `{ inquiryId, sessionToken }` response from the KYC API is a legacy format that was only returned when a `templateId` parameter was provided. This legacy format is being deprecated. The new flow in `src/components/getting-started/GettingStarted.tsx` and related files calls `getKYCStatus()` without the `templateId` parameter, so the legacy response shape will never be returned, and the `"code" in status` type guard is sufficient.
<!--

Learnt from: dieguezguille
Repo: exactly/exa PR: 635
File: src/components/home/Portfolio.tsx:116-116
Timestamp: 2026-01-15T01:18:41.751Z
Learning: In the exactly/exa repository, the team uses full sentences as i18n keys (e.g., `i18nKey="Performance is variable, not guaranteed..."`) rather than stable descriptive keys (e.g., `"portfolio.performanceDisclaimer"`). This is a deliberate architectural decision.

Learnt from: dieguezguille
Repo: exactly/exa PR: 635
File: src/i18n/en.json:1-14
Timestamp: 2026-01-15T16:25:50.233Z
Learning: In the exactly/exa repository, the i18n implementation uses a fallback strategy where English translation keys serve as the default English text. The `en.json` file contains only plural form entries that require distinct grammatical variations, while all other English strings are shown directly from the translation key itself when `t(...)` is called.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/components/add-funds/AddFundsOption.tsx`:
- Around line 26-29: The box currently only sets iOS shadow props (shadowColor,
shadowOffset, shadowOpacity, shadowRadius) in the AddFundsOption component; add
an Android elevation property to the same style/JSX element (e.g., add
elevation={6} or a value that matches the visual depth) so shadows render on
Android as well — update the style/props on the element where
shadowColor/shadowOffset/shadowOpacity/shadowRadius are defined in
AddFundsOption to include elevation.

In `@src/utils/server.ts`:
- Around line 163-173: The code in getKYCStatus currently writes the country
code to cache before verifying response.ok, which can overwrite valid cached
data on errors; move the queryClient.setQueryData call so it runs after the if
(!response.ok) check and only if response.headers.get("User-Country") is
non-null/defined; specifically, in getKYCStatus, after awaiting response and
validating response.ok, obtain const country =
response.headers.get("User-Country") and call
queryClient.setQueryData(["user","country"], country) only when country is
present, and keep the existing throw new APIError(response.status, code)
behavior unchanged.

Comment on lines 26 to 29
shadowColor="$backgroundStrong"
shadowOffset={{ width: 0, height: 6 }}
shadowOpacity={0.6}
shadowRadius={28}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

React Native shadowColor shadowOffset Android rendering elevation required

💡 Result:

On Android, React Native’s classic iOS-style shadow props don’t render the same way:

  • shadowOffset, shadowOpacity, and shadowRadius are iOS-only (they won’t affect Android rendering). [1]
  • shadowColor can work on Android only on API 28+, and for older Android versions you must rely on elevation. [1]
  • In practice, to see any shadow on Android you generally need:
    • elevation: > 0
    • a non-transparent backgroundColor
    • (and on API 28+) you may also set shadowColor to tint the elevation shadow. [2]

Example:

const card = {
  // iOS shadow
  shadowColor: '#000',
  shadowOffset: { width: 0, height: 2 },
  shadowOpacity: 0.2,
  shadowRadius: 6,

  // Android shadow
  elevation: 6,
  backgroundColor: '#fff', // important on Android
};

If you need consistent cross-platform shadows with more control, React Native now also documents boxShadow (iOS+Android) and dropShadow (Android). [1]

Sources: React Native Shadow Props docs [1]; Android behavior notes/examples from community report [2].

[1] (reactnative.dev)
[2] (stackoverflow.com)

Citations:


🏁 Script executed:

cat -n src/components/add-funds/AddFundsOption.tsx | sed -n '15,40p'

Repository: exactly/exa

Length of output: 976


Add elevation for Android shadow support.
The shadowOffset, shadowOpacity, and shadowRadius props are iOS-only and won't render shadows on Android. Since backgroundColor is already present, add elevation to ensure shadows display consistently.

Suggested fix
      shadowColor="$backgroundStrong"
      shadowOffset={{ width: 0, height: 6 }}
      shadowOpacity={0.6}
      shadowRadius={28}
+     elevation={1}
🤖 Prompt for AI Agents
In `@src/components/add-funds/AddFundsOption.tsx` around lines 26 - 29, The box
currently only sets iOS shadow props (shadowColor, shadowOffset, shadowOpacity,
shadowRadius) in the AddFundsOption component; add an Android elevation property
to the same style/JSX element (e.g., add elevation={6} or a value that matches
the visual depth) so shadows render on Android as well — update the style/props
on the element where shadowColor/shadowOffset/shadowOpacity/shadowRadius are
defined in AddFundsOption to include elevation.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 13 additional findings in Devin Review.

Open in Devin Review

const depositAlias = depositInfo?.network === "ARG_FIAT_TRANSFER" ? depositInfo.depositAlias : undefined;
const limits = providers?.providers.manteca.onramp.limits;
const limitCurrency = limits?.monthly?.symbol;
const minAmount = quote?.buyRate ? Number(quote.buyRate) : undefined;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 minAmount incorrectly uses buyRate (exchange rate) instead of minimum transfer limit

In Ramp.tsx, the minAmount variable is set to quote?.buyRate, which represents the exchange rate (e.g., how many ARS per 1 USDC), not a minimum transfer amount. This value is then displayed to users as "Min {currency} {amount}" on line 182.

Root Cause

The buyRate from the quote represents the exchange rate for converting fiat to USDC (e.g., "ARS 1000 ~ 1 USDC"). Using this as minAmount means users see the exchange rate displayed as if it were the minimum transfer limit, which is misleading and incorrect.

Actual behavior: If buyRate is 1000 (meaning 1000 ARS = 1 USDC), users see "Min ARS 1,000.00" suggesting they must transfer at least 1000 ARS.

Expected behavior: The minimum amount should come from a dedicated minimum limit field from the provider's limits, not the exchange rate.

Impact: Users receive incorrect information about minimum transfer requirements, potentially leading to failed or rejected transfers if they send amounts below the actual minimum.

Prompt for agents
The minAmount on line 92 uses quote.buyRate which is the exchange rate, not a minimum transfer limit. Review the API response structure from getRampQuote and getRampProviders to find the correct field for minimum transfer limits. The limits object at line 90 (providers?.providers.manteca.onramp.limits) likely has a minimum field that should be used instead. Update minAmount to use the correct minimum limit value from the provider's limits configuration.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good review, in this case is correct to use this value

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/components/add-funds/Ramp.tsx`:
- Around line 92-93: The current assignments for minAmount and maxAmount use
truthy checks (quote?.buyRate and limits?.monthly?.available) — replace those
with explicit null/undefined checks using != null to follow the guideline: check
quote?.buyRate != null before Number(quote.buyRate) for minAmount and
limits?.monthly?.available != null before Number(limits.monthly.available) for
maxAmount, so you still convert valid "0" strings but avoid relying on general
truthiness; update the expressions where minAmount and maxAmount are defined in
Ramp.tsx.

Comment on lines +92 to +93
const minAmount = quote?.buyRate ? Number(quote.buyRate) : undefined;
const maxAmount = limits?.monthly?.available ? Number(limits.monthly.available) : undefined;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "Ramp.tsx" -type f

Repository: exactly/exa

Length of output: 90


🏁 Script executed:

git ls-files | grep -i ramp

Repository: exactly/exa

Length of output: 258


🏁 Script executed:

find . -type f -name "*Ramp*"

Repository: exactly/exa

Length of output: 90


🏁 Script executed:

find . -type d -name "add-funds"

Repository: exactly/exa

Length of output: 108


🏁 Script executed:

git ls-files | grep src/components

Repository: exactly/exa

Length of output: 6092


🏁 Script executed:

ls -la src/

Repository: exactly/exa

Length of output: 509


🏁 Script executed:

find . -type f -name "*.tsx" | grep -i ramp

Repository: exactly/exa

Length of output: 126


🏁 Script executed:

fd -e tsx -e ts | head -30

Repository: exactly/exa

Length of output: 755


🏁 Script executed:

git ls-files | wc -l

Repository: exactly/exa

Length of output: 58


🏁 Script executed:

git ls-files | grep -E "\.(tsx?|json)$" | head -50

Repository: exactly/exa

Length of output: 1704


🏁 Script executed:

sed -n '85,110p' src/components/add-funds/Ramp.tsx

Repository: exactly/exa

Length of output: 1164


🏁 Script executed:

cat -n src/components/add-funds/Ramp.tsx | sed -n '80,130p'

Repository: exactly/exa

Length of output: 2398


🏁 Script executed:

head -150 src/components/add-funds/Ramp.tsx | tail -100

Repository: exactly/exa

Length of output: 3897


🏁 Script executed:

sed -n '75,130p' src/components/add-funds/Ramp.tsx | nl -v 75

Repository: exactly/exa

Length of output: 97


🏁 Script executed:

python3 -c "
with open('src/components/add-funds/Ramp.tsx', 'r') as f:
    lines = f.readlines()
    for i in range(85, 130):
        if i < len(lines):
            print(f'{i}: {lines[i]}', end='')
"

Repository: exactly/exa

Length of output: 2006


🏁 Script executed:

wc -l src/components/add-funds/Ramp.tsx && head -n 150 src/components/add-funds/Ramp.tsx | tail -n 60

Repository: exactly/exa

Length of output: 2524


🏁 Script executed:

grep -n "minAmount\|maxAmount" src/components/add-funds/Ramp.tsx

Repository: exactly/exa

Length of output: 517


🏁 Script executed:

rg "interface.*[Qq]uote|interface.*[Ll]imit" --type ts --type tsx -A 5 | head -40

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

fd -e ts -e tsx | xargs grep -l "getRampQuote"

Repository: exactly/exa

Length of output: 108


🏁 Script executed:

rg "getRampQuote|getRampProviders" -g "*.ts" -g "*.tsx" | head -20

Repository: exactly/exa

Length of output: 1222


🏁 Script executed:

cat server/api/ramp.ts | head -100

Repository: exactly/exa

Length of output: 3443


🏁 Script executed:

head -80 src/components/add-funds/Ramp.tsx | tail -30

Repository: exactly/exa

Length of output: 991


🏁 Script executed:

rg "export.*getRampQuote" -A 20 src/utils/server.ts

Repository: exactly/exa

Length of output: 673


🏁 Script executed:

grep -n "getRampQuote\|getRampProviders" src/utils/server.ts

Repository: exactly/exa

Length of output: 256


🏁 Script executed:

head -200 src/utils/server.ts | tail -100

Repository: exactly/exa

Length of output: 3630


🏁 Script executed:

sed -n '80,180p' server/api/ramp.ts

Repository: exactly/exa

Length of output: 3897


🏁 Script executed:

grep -n "buyRate\|interface.*Quote" server/utils/ramps/manteca.ts | head -20

Repository: exactly/exa

Length of output: 112


🏁 Script executed:

grep -r "buyRate" --include="*.ts" --include="*.tsx"

Repository: exactly/exa

Length of output: 1172


🏁 Script executed:

cat server/utils/ramps/manteca.ts | head -150

Repository: exactly/exa

Length of output: 5147


🏁 Script executed:

grep -n "LimitsResponse" server/utils/ramps/manteca.ts -A 20

Repository: exactly/exa

Length of output: 1459


🏁 Script executed:

grep -n "ProviderInfo\|Limits" server/utils/ramps/shared.ts | head -20

Repository: exactly/exa

Length of output: 95


🏁 Script executed:

grep -r "available" server/utils/ramps/ --include="*.ts" -B 3 -A 3

Repository: exactly/exa

Length of output: 4052


🏁 Script executed:

rg "LimitsResponse|limits:" server/utils/ramps/manteca.ts -A 5

Repository: exactly/exa

Length of output: 709


🏁 Script executed:

wc -l server/utils/ramps/manteca.ts

Repository: exactly/exa

Length of output: 88


🏁 Script executed:

grep -n "const LimitsResponse\|interface.*Limits\|export.*Limits" server/utils/ramps/manteca.ts

Repository: exactly/exa

Length of output: 155


🏁 Script executed:

sed -n '1,100p' server/utils/ramps/shared.ts | grep -n "ProviderInfo\|monthly\|available"

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

grep -n "export const.*Response\|export const.*Info" server/utils/ramps/shared.ts | head -20

Repository: exactly/exa

Length of output: 189


🏁 Script executed:

sed -n '106,180p' server/utils/ramps/shared.ts

Repository: exactly/exa

Length of output: 1355


🏁 Script executed:

grep -n "minAmount\|maxAmount" src/components/add-funds/Ramp.tsx -B 1 -A 1

Repository: exactly/exa

Length of output: 898


🏁 Script executed:

node << 'EOF'
const zero_string = "0";
const empty_string = "";
const null_val = null;
const undefined_val = undefined;

console.log("Testing truthy check behavior:");
console.log("\"0\" is truthy:", !!zero_string);
console.log("\"0\" ? Number(\"0\") : undefined =", zero_string ? Number(zero_string) : undefined);
console.log("");
console.log("\"\" is truthy:", !!empty_string);
console.log("\"\" ? Number(\"\") : undefined =", empty_string ? Number(empty_string) : undefined);
console.log("");
console.log("Testing != null check:");
console.log("\"0\" != null ? Number(\"0\") : undefined =", zero_string != null ? Number(zero_string) : undefined);
console.log("\"\" != null ? Number(\"\") : undefined =", empty_string != null ? Number(empty_string) : undefined);
console.log("null != null ? ... : undefined =", null != null ? Number("0") : undefined);
console.log("undefined != null ? ... : undefined =", undefined != null ? Number("0") : undefined);
EOF

Repository: exactly/exa

Length of output: 397


🏁 Script executed:

sed -n '240,280p' server/utils/ramps/manteca.ts

Repository: exactly/exa

Length of output: 1789


🏁 Script executed:

rg "buyRate|availableMonthlyLimit" server/utils/ramps/manteca.ts -B 2 -A 2 | head -30

Repository: exactly/exa

Length of output: 475


Consider using explicit null checks for clarity, but note the truthy check already handles "0" correctly.

The strings quote.buyRate and limits.monthly.available use truthy checks, which is acceptable since "0" is a truthy non-empty string in JavaScript. However, for explicit null/undefined checking per coding guidelines, use != null instead:

🛠️ Proposed fix
-  const minAmount = quote?.buyRate ? Number(quote.buyRate) : undefined;
-  const maxAmount = limits?.monthly?.available ? Number(limits.monthly.available) : undefined;
+  const minAmount = quote?.buyRate != null ? Number(quote.buyRate) : undefined;
+  const maxAmount = limits?.monthly?.available != null ? Number(limits.monthly.available) : undefined;
🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` around lines 92 - 93, The current
assignments for minAmount and maxAmount use truthy checks (quote?.buyRate and
limits?.monthly?.available) — replace those with explicit null/undefined checks
using != null to follow the guideline: check quote?.buyRate != null before
Number(quote.buyRate) for minAmount and limits?.monthly?.available != null
before Number(limits.monthly.available) for maxAmount, so you still convert
valid "0" strings but avoid relying on general truthiness; update the
expressions where minAmount and maxAmount are defined in Ramp.tsx.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/components/shared/InfoAlert.tsx`:
- Around line 8-18: The InfoAlert props currently allow actionText without
onPress or vice-versa; change the props to a discriminated union so callers must
supply both or neither (e.g., define a Props type combining title and loading
with a union of { actionText: string; onPress: () => void } | { actionText?:
never; onPress?: never }) and update the component signature from the inline
object to use that Props type (InfoAlert(props: Props) and destructure inside)
so TypeScript enforces the pairing at compile time.
- Around line 34-48: The Pressable currently still accepts taps while loading;
update the Pressable wrapping the action (the Pressable with onPress calling
onPress?.()) to set its disabled prop when the loading state is true (i.e.,
disabled={loading}) so that presses are ignored during Spinner rendering; ensure
this uses the same actionText/Spinner/ChevronRight block and does not change the
existing onPress handler other than preventing invocation when disabled.

Comment on lines 8 to 18
export default function InfoAlert({
title,
actionText,
loading,
onPress,
}: {
actionText: string;
actionText?: string;
loading?: boolean;
onPress?: () => void;
title: string;
}) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

# First, check the current state of InfoAlert.tsx
cat -n src/components/shared/InfoAlert.tsx

Repository: exactly/exa

Length of output: 2001


🏁 Script executed:

# Find all usages of InfoAlert component
rg -n --type=tsx --type=ts -C3 '<InfoAlert\b|InfoAlert\s*\{' | head -100

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

# Check if there are any mismatched actionText/onPress pairs in the codebase
rg -n 'actionText=|onPress=' src/components/shared/InfoAlert.tsx

Repository: exactly/exa

Length of output: 84


🏁 Script executed:

# Find all usages of InfoAlert component (corrected)
rg -C3 '<InfoAlert' --type ts --type tsx || rg -C3 '<InfoAlert'

Repository: exactly/exa

Length of output: 2322


🏁 Script executed:

# Also search for InfoAlert imports and usage patterns
rg 'InfoAlert' --type ts --type tsx || rg 'InfoAlert'

Repository: exactly/exa

Length of output: 776


🏁 Script executed:

# Get full context of InfoAlert usage in PluginUpgrade.tsx
rg -B5 -A10 'actionText.*Upgrade account now' src/components/shared/PluginUpgrade.tsx

Repository: exactly/exa

Length of output: 351


🏁 Script executed:

# Get full context of InfoAlert usage in Home.tsx
rg -B5 -A15 '<InfoAlert' src/components/home/Home.tsx | head -40

Repository: exactly/exa

Length of output: 1107


Enforce actionText/onPress pairing at the type level.

Right now callers can pass actionText without onPress (dead affordance) or onPress without actionText (no cue). Use a discriminated union so TypeScript rejects invalid combinations at compile time:

✅ Proposed fix
-export default function InfoAlert({
-  title,
-  actionText,
-  loading,
-  onPress,
-}: {
-  actionText?: string;
-  loading?: boolean;
-  onPress?: () => void;
-  title: string;
-}) {
+type InfoAlertProps = { title: string; loading?: boolean } & ({ actionText: string; onPress: () => void } | { actionText?: undefined; onPress?: undefined });
+
+export default function InfoAlert({ title, actionText, loading, onPress }: InfoAlertProps) {

This aligns with the coding guideline to leverage TypeScript types to catch errors at compile time.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default function InfoAlert({
title,
actionText,
loading,
onPress,
}: {
actionText: string;
actionText?: string;
loading?: boolean;
onPress?: () => void;
title: string;
}) {
type InfoAlertProps = { title: string; loading?: boolean } & ({ actionText: string; onPress: () => void } | { actionText?: undefined; onPress?: undefined });
export default function InfoAlert({ title, actionText, loading, onPress }: InfoAlertProps) {
🤖 Prompt for AI Agents
In `@src/components/shared/InfoAlert.tsx` around lines 8 - 18, The InfoAlert props
currently allow actionText without onPress or vice-versa; change the props to a
discriminated union so callers must supply both or neither (e.g., define a Props
type combining title and loading with a union of { actionText: string; onPress:
() => void } | { actionText?: never; onPress?: never }) and update the component
signature from the inline object to use that Props type (InfoAlert(props: Props)
and destructure inside) so TypeScript enforces the pairing at compile time.

Comment on lines 34 to +48
<Pressable
onPress={() => {
onPress?.();
}}
>
<XStack flexDirection="row" gap={2} alignItems="center">
<Text color="$interactiveOnBaseInformationSoft" fontSize={15} fontWeight="bold">
{actionText}
</Text>
{loading ? (
<Spinner color="$interactiveOnBaseInformationSoft" />
) : (
<ChevronRight
size={16}
color="$interactiveOnBaseInformationSoft"
fontWeight="bold"
strokeWidth={3}
transform={[{ translateY: 1.2 }]}
/>
)}
</XStack>
{actionText && (
<XStack gap="$s1" alignItems="center">
<Text emphasized subHeadline color="$interactiveOnBaseInformationSoft">
{actionText}
</Text>
{loading ? (
<Spinner color="$interactiveOnBaseInformationSoft" />
) : (
<ChevronRight size={16} color="$interactiveOnBaseInformationSoft" strokeWidth={3} />
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

React Native Pressable disabled prop behavior prevents onPress

💡 Result:

In React Native, Pressable’s disabled prop disables the entire press interaction, so onPress (and the other press callbacks) will not fire while disabled={true}. This is the intended behavior. [1]

If you need “looks disabled” styling but still want a tap to do something (e.g., show a tooltip/toast), don’t use disabled; instead, keep it enabled and gate the handler:

<Pressable
  onPress={isDisabled ? () => showWhyDisabled() : doAction}
  style={({ pressed }) => [
    styles.btn,
    isDisabled && styles.btnDisabled,
    pressed && !isDisabled && styles.btnPressed,
  ]}
/>

On React Native Web, disabled also “disables all pointer interactions,” i.e., it blocks pointer input entirely. [2]

Sources: React Native Pressable docs (disabled) [1], React Native Web Pressable docs (disabled blocks pointer interactions) [2].


🏁 Script executed:

# First, let me locate and inspect the InfoAlert.tsx file
fd -e tsx -e ts "InfoAlert" --type f

Repository: exactly/exa

Length of output: 90


🏁 Script executed:

cat -n src/components/shared/InfoAlert.tsx | head -70

Repository: exactly/exa

Length of output: 2001


Disable the action tap while loading to prevent duplicate submissions.

The Pressable currently allows presses while loading is true, which could trigger multiple submissions if the user taps repeatedly. Use the disabled prop to prevent onPress from firing during this state:

🛠️ Proposed fix
-        <Pressable
-          onPress={() => {
-            onPress?.();
-          }}
-        >
+        <Pressable disabled={loading || !onPress} onPress={onPress}>
🤖 Prompt for AI Agents
In `@src/components/shared/InfoAlert.tsx` around lines 34 - 48, The Pressable
currently still accepts taps while loading; update the Pressable wrapping the
action (the Pressable with onPress calling onPress?.()) to set its disabled prop
when the loading state is true (i.e., disabled={loading}) so that presses are
ignored during Spinner rendering; ensure this uses the same
actionText/Spinner/ChevronRight block and does not change the existing onPress
handler other than preventing invocation when disabled.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/i18n/es.json`:
- Line 564: The JSON entry with key "All deposits must be from bank accounts
under your name." in src/i18n/es.json is the last item and needs a trailing
comma per the repo style; update that line by adding a trailing comma after the
value so the entry becomes "All deposits must be from bank accounts under your
name.": "Todos los depósitos deben ser desde cuentas bancarias a tu nombre.",
ensuring valid JSON and consistency with the trailing-comma rule.

In `@src/utils/persona.ts`:
- Around line 126-169: The pagehide listener (onPageHide) is only removed on
abort causing accumulation; update the terminal handlers in startMantecaKYC and
startKYC (the onComplete, onCancel, and onError callbacks) to also remove the
listener by calling globalThis.removeEventListener("pagehide", onPageHide)
before cleanup (client.destroy(), queryClient.invalidateQueries, resolve/reject)
so the listener is cleaned on all exit paths; ensure onPageHide is in scope for
those handlers (same closure where you call globalThis.addEventListener) and
keep the existing abort removal via signal.addEventListener for parity.

In `@src/utils/server.ts`:
- Around line 298-302: Rename the parameter onboardingData in
startRampOnboarding to a tighter name like onboarding: update the function
signature export async function startRampOnboarding(onboarding: { provider:
"manteca" }) and replace all uses of onboardingData inside the function (e.g.,
the api.ramp.$post call) to use onboarding; ensure any local references and
affected callers are updated to the new identifier to keep intent clear and
follow naming guidelines.

Comment on lines +126 to +169
if (Platform.OS === "web") {
const onPageHide = () => controller.abort(new Error("page unloaded"));
globalThis.addEventListener("pagehide", onPageHide);
signal.addEventListener("abort", () => globalThis.removeEventListener("pagehide", onPageHide), { once: true });
}

if (Platform.OS === "web") {
const [{ Client }, { inquiryId, sessionToken }] = await Promise.all([
import("persona"),
getKYCTokens("manteca", await getRedirectURI()),
]);
if (signal.aborted) throw signal.reason;

return new Promise<MantecaKYCResult>((resolve, reject) => {
const onAbort = () => {
client.destroy();
reject(new Error("persona inquiry aborted", { cause: signal.reason }));
};
const client = new Client({
inquiryId,
sessionToken,
environment: environment as "production" | "sandbox",
onReady: () => client.open(),
onComplete: () => {
signal.removeEventListener("abort", onAbort);
client.destroy();
queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
resolve({ status: "complete" });
},
onCancel: () => {
signal.removeEventListener("abort", onAbort);
client.destroy();
queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
resolve({ status: "cancel" });
},
onError: (error) => {
signal.removeEventListener("abort", onAbort);
client.destroy();
reportError(error);
resolve({ status: "error" });
},
});
signal.addEventListener("abort", onAbort, { once: true });
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/utils/persona.ts | head -200

Repository: exactly/exa

Length of output: 8852


🏁 Script executed:

rg -n "pagehide|startKYC" src/utils/persona.ts -B2 -A2

Repository: exactly/exa

Length of output: 917


🏁 Script executed:

rg -n "startKYC" src/ --type ts --type tsx -l

Repository: exactly/exa

Length of output: 82


Remove the pagehide listener in terminal handlers to prevent listener accumulation.

On web, the pagehide listener is only removed when the abort signal fires. Successful completion leaves the listener attached and accumulates across repeated runs. Add cleanup in onComplete, onCancel, and onError handlers in both startKYC and startMantecaKYC for parity.

🧹 Suggested cleanup
           onComplete: () => {
             signal.removeEventListener("abort", onAbort);
             client.destroy();
+            globalThis.removeEventListener("pagehide", onPageHide);
             queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
             resolve({ status: "complete" });
           },
           onCancel: () => {
             signal.removeEventListener("abort", onAbort);
             client.destroy();
+            globalThis.removeEventListener("pagehide", onPageHide);
             queryClient.invalidateQueries({ queryKey: ["kyc", "manteca"] }).catch(reportError);
             resolve({ status: "cancel" });
           },
           onError: (error) => {
             signal.removeEventListener("abort", onAbort);
             client.destroy();
+            globalThis.removeEventListener("pagehide", onPageHide);
             reportError(error);
             resolve({ status: "error" });
           },
🤖 Prompt for AI Agents
In `@src/utils/persona.ts` around lines 126 - 169, The pagehide listener
(onPageHide) is only removed on abort causing accumulation; update the terminal
handlers in startMantecaKYC and startKYC (the onComplete, onCancel, and onError
callbacks) to also remove the listener by calling
globalThis.removeEventListener("pagehide", onPageHide) before cleanup
(client.destroy(), queryClient.invalidateQueries, resolve/reject) so the
listener is cleaned on all exit paths; ensure onPageHide is in scope for those
handlers (same closure where you call globalThis.addEventListener) and keep the
existing abort removal via signal.addEventListener for parity.

Comment on lines +298 to +302
export async function startRampOnboarding(onboardingData: { provider: "manteca" }) {
await auth();
const response = await api.ramp.$post({
json: onboardingData,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Rename onboardingData to avoid fluff naming.

data is on the avoid-list; use a tighter name like onboarding to keep intent clear.

✏️ Suggested rename
-export async function startRampOnboarding(onboardingData: { provider: "manteca" }) {
+export async function startRampOnboarding(onboarding: { provider: "manteca" }) {
   await auth();
-  const response = await api.ramp.$post({
-    json: onboardingData,
-  });
+  const response = await api.ramp.$post({
+    json: onboarding,
+  });

As per coding guidelines, avoid fluff words that carry no meaningful information in variable names. usual suspects include data, state, manager, engine, object, entity, and instance.

🤖 Prompt for AI Agents
In `@src/utils/server.ts` around lines 298 - 302, Rename the parameter
onboardingData in startRampOnboarding to a tighter name like onboarding: update
the function signature export async function startRampOnboarding(onboarding: {
provider: "manteca" }) and replace all uses of onboardingData inside the
function (e.g., the api.ramp.$post call) to use onboarding; ensure any local
references and affected callers are updated to the new identifier to keep intent
clear and follow naming guidelines.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/server.ts (1)

178-190: ⚠️ Potential issue | 🟠 Major

Remove createMantecaKYC and getMantecaKYCStatus—they are unused.

createMantecaKYC is functionally identical to getKYCTokens("manteca", redirectURI) and getMantecaKYCStatus is identical to getKYCStatus("manteca"). Both have zero usages in the codebase outside their definitions, making them dead code. Callers should invoke the parameterized originals directly.

Additionally, line 284 has a type issue: ProviderInfoSchema is imported with type, but ProviderInfo in shared.ts is a runtime valibot schema object. The import should not use the type keyword so typeof ProviderInfoSchema can extract the schema's type correctly.

🤖 Fix all issues with AI agents
In `@src/components/add-funds/KYC.tsx`:
- Around line 48-78: Duplicate async orchestration in completeOnboarding should
be extracted to a shared helper; create a new function (e.g., default export
completeOnboarding) in src/components/add-funds that accepts (router: Router,
currency?: string) and moves the existing logic that calls startRampOnboarding,
reads queryClient.getQueryData(["user","country"]), calls getKYCStatus when
missing, invalidates ["ramp","providers"], fetches getRampProviders, inspects
providers.providers.manteca.status and routes via router.replace, and on error
calls reportError and routes to error; then replace the verbatim implementations
in KYC.tsx and Onboard.tsx with an import of this helper and a single call
completeOnboarding(router, currency).

In `@src/components/add-funds/Ramp.tsx`:
- Around line 209-213: The rendering logic in Ramp.tsx currently assumes
depositInfo.estimatedProcessingTime is seconds and always runs Number(...)
causing NaN for non-numeric values; update the conditional that builds the
"Between {{min}} and {{max}} minutes" string to first check if
depositInfo.estimatedProcessingTime is a numeric value (e.g., use
Number.isFinite after coercion or a regex) and only perform
Math.round(Number(...) / 60) when numeric; if non-numeric, render
depositInfo.estimatedProcessingTime (or a safe fallback like t("Processing time
unavailable")) instead of the "Between ... minutes" template. Locate the JSX
that references depositInfo?.estimatedProcessingTime and modify the branch to
handle both numeric and textual API responses.
- Line 92: The code incorrectly treats quote.buyRate as a per-transaction
minimum and limits.monthly.available as a per-transaction maximum; update the
Ramp component so minAmount and maxAmount are not derived from quote.buyRate and
limits.monthly.available respectively. Remove or set to undefined any logic that
assigns const minAmount = quote?.buyRate... and const maxAmount =
limits?.monthly?.available..., instead either: 1) use the actual API fields for
per-transaction min/max if they exist (e.g. limits.perTransaction /
limits.daily), 2) leave minAmount/maxAmount undefined and update the UI labels
to show "Exchange rate" (quote.buyRate) and "Remaining monthly limit"
(limits.monthly.available), or 3) fetch the correct min/max from the backend;
ensure identifiers mentioned (minAmount, maxAmount, quote.buyRate,
limits.monthly.available, Ramp component) are updated consistently and any UI
text that currently says "Min" / "Max" reflects the corrected data source.

Comment on lines +48 to +78
async function completeOnboarding() {
try {
await startRampOnboarding({ provider: "manteca" });

let countryCode = queryClient.getQueryData<string>(["user", "country"]);
if (!countryCode) {
await getKYCStatus("basic", true);
countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? "";
}

await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] });

const providers = await queryClient.fetchQuery({
queryKey: ["ramp", "providers", countryCode],
queryFn: () => getRampProviders(countryCode),
staleTime: 0,
});

const newStatus = providers.providers.manteca.status;
if (newStatus === "ACTIVE") {
router.replace({ pathname: "/add-funds/ramp", params: { currency } });
} else if (newStatus === "ONBOARDING") {
router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } });
} else {
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
} catch (error) {
reportError(error);
router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

completeOnboarding is duplicated verbatim in Onboard.tsx (lines 37-71).

Both files contain ~30 identical lines of orchestration logic (startRampOnboarding → country resolution → invalidate → fetch providers → branch on manteca status → error catch). This isn't UI co-location—it's a pure async sequence that takes router and currency as context. Extracting a shared helper within the add-funds directory keeps the journey self-contained while eliminating the duplication.

♻️ Proposed extraction

Create a shared helper (e.g., src/components/add-funds/completeOnboarding.ts):

import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import { getKYCStatus, getRampProviders, startRampOnboarding } from "../../utils/server";

import type { Router } from "expo-router";

export default async function completeOnboarding(router: Router, currency: string | undefined) {
  try {
    await startRampOnboarding({ provider: "manteca" });
    let countryCode = queryClient.getQueryData<string>(["user", "country"]);
    if (!countryCode) {
      await getKYCStatus("basic", true);
      countryCode = queryClient.getQueryData<string>(["user", "country"]) ?? "";
    }
    await queryClient.invalidateQueries({ queryKey: ["ramp", "providers"] });
    const providers = await queryClient.fetchQuery({
      queryKey: ["ramp", "providers", countryCode],
      queryFn: () => getRampProviders(countryCode),
      staleTime: 0,
    });
    const newStatus = providers.providers.manteca.status;
    if (newStatus === "ACTIVE") {
      router.replace({ pathname: "/add-funds/ramp", params: { currency } });
    } else if (newStatus === "ONBOARDING") {
      router.replace({ pathname: "/add-funds/status", params: { status: newStatus, currency } });
    } else {
      router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
    }
  } catch (error) {
    reportError(error);
    router.replace({ pathname: "/add-funds/status", params: { status: "error", currency } });
  }
}

Then in both KYC.tsx and Onboard.tsx:

-  async function completeOnboarding() { ... }
+  import completeOnboarding from "./completeOnboarding";
   // call: await completeOnboarding(router, currency);
🤖 Prompt for AI Agents
In `@src/components/add-funds/KYC.tsx` around lines 48 - 78, Duplicate async
orchestration in completeOnboarding should be extracted to a shared helper;
create a new function (e.g., default export completeOnboarding) in
src/components/add-funds that accepts (router: Router, currency?: string) and
moves the existing logic that calls startRampOnboarding, reads
queryClient.getQueryData(["user","country"]), calls getKYCStatus when missing,
invalidates ["ramp","providers"], fetches getRampProviders, inspects
providers.providers.manteca.status and routes via router.replace, and on error
calls reportError and routes to error; then replace the verbatim implementations
in KYC.tsx and Onboard.tsx with an import of this helper and a single call
completeOnboarding(router, currency).

const depositAlias = depositInfo?.network === "ARG_FIAT_TRANSFER" ? depositInfo.depositAlias : undefined;
const limits = providers?.providers.manteca.onramp.limits;
const limitCurrency = limits?.monthly?.symbol;
const minAmount = quote?.buyRate ? Number(quote.buyRate) : undefined;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.ts" -o -name "*.tsx" | head -20

Repository: exactly/exa

Length of output: 617


🏁 Script executed:

# Search for ramp API types and quote shape
rg -n "quote|buyRate|minAmount" --type ts --type tsx -C 2 | head -80

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

# Check server utils for ramp API contracts
find . -path "*/server/*" -name "*ramp*" -o -name "*quote*" 2>/dev/null | head -20

Repository: exactly/exa

Length of output: 127


🏁 Script executed:

# Look for API response types or interfaces
rg -n "interface.*[Qq]uote|type.*[Qq]uote" --type ts --type tsx -C 5 | head -100

Repository: exactly/exa

Length of output: 82


🏁 Script executed:

cat -n ./server/api/ramp.ts | head -150

Repository: exactly/exa

Length of output: 6613


🏁 Script executed:

ls -la ./server/utils/ramps/

Repository: exactly/exa

Length of output: 349


🏁 Script executed:

# Search for quote type definition and limits structure
rg -n "quote|buyRate|limits" ./server/utils/ramps/ -A 3 -B 1

Repository: exactly/exa

Length of output: 3769


🏁 Script executed:

# Check the limits type in Ramp.tsx context
rg -n "limits\|quote" src/components/add-funds/Ramp.tsx -B 2 -A 2

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Check the limits response structure and fields
rg -n "LimitsResponse\|availableMonthlyLimit\|monthlyLimit" ./server/utils/ramps/ -A 5 -B 2

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Check the DepositDetails type to see all fields
rg -n "DepositDetails\|export.*object" ./server/utils/ramps/shared.ts -A 10

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Check how minAmount is used in the Ramp component
rg -n "minAmount\|maxAmount" src/components/add-funds/Ramp.tsx -B 2 -A 2

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

cat -n ./server/utils/ramps/shared.ts

Repository: exactly/exa

Length of output: 5638


🏁 Script executed:

# Look for limits in bridge.ts
cat -n ./server/utils/ramps/bridge.ts | grep -A 10 -B 2 "limits\|Limits"

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Check manteca.ts for limits details
cat -n ./server/utils/ramps/manteca.ts | grep -A 15 "LimitsResponse\|availableMonthlyLimit"

Repository: exactly/exa

Length of output: 2190


🏁 Script executed:

# Read the relevant section of Ramp.tsx directly
sed -n '60,95p' src/components/add-funds/Ramp.tsx

Repository: exactly/exa

Length of output: 1510


🏁 Script executed:

# Check how minAmount and maxAmount are displayed in the UI
sed -n '186,200p' src/components/add-funds/Ramp.tsx

Repository: exactly/exa

Length of output: 794


🏁 Script executed:

# Search for any comments or documentation about these limits in the component
grep -n "Min\|Max\|limit\|minimum\|maximum" src/components/add-funds/Ramp.tsx | head -20

Repository: exactly/exa

Length of output: 848


buyRate is an exchange rate, not a minimum deposit amount.

minAmount is derived from quote.buyRate, which is an exchange rate (line 226 displays it as "{buyRate} ~ 1 USDC"). The API provides no dedicated minimum transfer field. If displaying "Min ARS 1,200" when the buyRate is 1200, this conflates exchange rate with a deposit minimum. Similarly, maxAmount is sourced from limits.monthly.available (remaining monthly budget), not a per-transaction maximum. Both derivations are semantically incorrect for what the UI labels suggest ("Min" and "Max" transfer amounts).

🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` at line 92, The code incorrectly treats
quote.buyRate as a per-transaction minimum and limits.monthly.available as a
per-transaction maximum; update the Ramp component so minAmount and maxAmount
are not derived from quote.buyRate and limits.monthly.available respectively.
Remove or set to undefined any logic that assigns const minAmount =
quote?.buyRate... and const maxAmount = limits?.monthly?.available..., instead
either: 1) use the actual API fields for per-transaction min/max if they exist
(e.g. limits.perTransaction / limits.daily), 2) leave minAmount/maxAmount
undefined and update the UI labels to show "Exchange rate" (quote.buyRate) and
"Remaining monthly limit" (limits.monthly.available), or 3) fetch the correct
min/max from the backend; ensure identifiers mentioned (minAmount, maxAmount,
quote.buyRate, limits.monthly.available, Ramp component) are updated
consistently and any UI text that currently says "Min" / "Max" reflects the
corrected data source.

Comment on lines +209 to +213
{depositInfo?.estimatedProcessingTime ? (
t("Between {{min}} and {{max}} minutes", {
min: Math.round(Number(depositInfo.estimatedProcessingTime) / 60),
max: Math.round(Number(depositInfo.estimatedProcessingTime) / 60) + 5,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the type/docs for estimatedProcessingTime
rg -n "estimatedProcessingTime" --type ts -C 3
echo "---"
# Also check the type definitions more broadly
rg -n "estimatedProcessingTime" --type ts -A 2 -B 2

Repository: exactly/exa

Length of output: 18151


🏁 Script executed:

# Check how the value is used when it's a string like "1 - 3 business days"
rg -n "1 - 3 business days" --type ts -B 5 -A 5

Repository: exactly/exa

Length of output: 1001


🏁 Script executed:

# Check if there are any other non-numeric string values for estimatedProcessingTime
rg -n "estimatedProcessingTime:" --type ts | grep -E "\"[^0-9\"]" | head -20

Repository: exactly/exa

Length of output: 1052


🏁 Script executed:

# Check if the depositInfo is typed and if there's handling for non-numeric values
cat -n src/components/add-funds/Ramp.tsx | sed -n '200,220p'

Repository: exactly/exa

Length of output: 1173


🏁 Script executed:

# Check the type of depositInfo and estimatedProcessingTime
rg -n "depositInfo" src/components/add-funds/Ramp.tsx -B 2 -A 2 | head -30

Repository: exactly/exa

Length of output: 1453


🏁 Script executed:

# Check where data comes from and its type definition
rg -n "data\?" src/components/add-funds/Ramp.tsx -B 5 | head -40

Repository: exactly/exa

Length of output: 243


🏁 Script executed:

# Find the query hook and its return type
rg -n "useQuery" src/components/add-funds/Ramp.tsx -B 10 -A 2 | head -50

Repository: exactly/exa

Length of output: 1503


🏁 Script executed:

# Find getRampQuote function and its return type
rg -n "getRampQuote" --type ts -A 10 | head -50

Repository: exactly/exa

Length of output: 2220


🏁 Script executed:

# Find the ramp API route definition
fd -t f "ramp" server/ --type ts | grep -E "route|api" | head -10

Repository: exactly/exa

Length of output: 224


🏁 Script executed:

# Find the ramp API route definition
fd "ramp" server/ -e ts | head -20

Repository: exactly/exa

Length of output: 102


🏁 Script executed:

# Check the ramp API route definition
cat -n server/api/ramp.ts | head -100

Repository: exactly/exa

Length of output: 4143


🏁 Script executed:

# Check the quote endpoint
cat -n server/api/ramp.ts | sed -n '99,150p'

Repository: exactly/exa

Length of output: 2560


🏁 Script executed:

# Check the DepositDetails type definition in shared.ts
rg -n "type DepositDetails" server/utils/ramps/shared.ts -A 30

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

# Check the DepositDetails export in shared.ts
rg -n "DepositDetails" server/utils/ramps/shared.ts -A 5

Repository: exactly/exa

Length of output: 305


🏁 Script executed:

# Get the full DepositDetails definition
rg -n "export const DepositDetails" server/utils/ramps/shared.ts -A 80

Repository: exactly/exa

Length of output: 2625


Handle non-numeric estimatedProcessingTime values to avoid displaying "NaN minutes".

The unit assumption (seconds) is correct: numeric values like "300" and "86400" are seconds, not minutes. However, some API responses use text strings like "1 - 3 business days" (e.g., ACH transfers). When Number() converts these to NaN, the template displays "Between NaN and NaN minutes", which breaks the UI.

🤖 Prompt for AI Agents
In `@src/components/add-funds/Ramp.tsx` around lines 209 - 213, The rendering
logic in Ramp.tsx currently assumes depositInfo.estimatedProcessingTime is
seconds and always runs Number(...) causing NaN for non-numeric values; update
the conditional that builds the "Between {{min}} and {{max}} minutes" string to
first check if depositInfo.estimatedProcessingTime is a numeric value (e.g., use
Number.isFinite after coercion or a regex) and only perform
Math.round(Number(...) / 60) when numeric; if non-numeric, render
depositInfo.estimatedProcessingTime (or a safe fallback like t("Processing time
unavailable")) instead of the "Between ... minutes" template. Locate the JSX
that references depositInfo?.estimatedProcessingTime and modify the branch to
handle both numeric and textual API responses.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/server.ts (1)

182-190: ⚠️ Potential issue | 🟡 Minor

Remove createMantecaKYC — it's unused dead code.

createMantecaKYC duplicates getKYCTokens("manteca") and has no usages in the codebase. The team consistently calls getKYCTokens("manteca", redirectURI) instead (see src/utils/persona.ts).

🤖 Fix all issues with AI agents
In `@src/components/add-funds/Onboard.tsx`:
- Around line 148-166: The touch target for the checkbox is only the 16×16 View
so make the pressable area larger by increasing the hitbox on the XStack instead
of changing the visual checkbox; update the XStack wrapping the View (the
onPress that calls setAcknowledged and reads acknowledged) to include padding or
minWidth/minHeight (e.g., ensure at least 44×44) and center the inner View so
the visual checkbox (width/height 16) remains unchanged while the tappable area
is enlarged; keep the onPress and Check rendering logic as-is.
- Line 167: The XStack wrapping the "I accept the…" text should not show a
pointer unless it's interactive; either remove cursor="pointer" from XStack or
make it toggle the acceptance state—preferably wire XStack's onPress to toggle
the checkbox state (e.g., call the same handler that updates isAccepted /
setAccepted or toggleAccept()), and inside the inner terms link's onPress add
event.stopPropagation() to prevent double-toggling; update XStack, the terms
onPress, and the checkbox toggle handler accordingly.

In `@src/components/add-funds/Status.tsx`:
- Around line 34-35: The Pressable wrapper currently surrounding the action row
allows taps even when actionText is absent or when loading is true; fix this by
moving the Pressable so it is only rendered inside the actionText guard (render
Pressable only when actionText exists) and ensure its onPress is disabled while
loading (e.g., conditionally pass undefined or no-op to onPress or set disabled
prop when loading is true). Update references in the component to the Pressable,
actionText, loading, and onPress handlers so the tap target only exists when
visible and cannot be re-triggered during loading.

In `@src/components/shared/InfoAlert.tsx`:
- Around line 34-51: The Pressable currently wraps the actionText block and
remains interactive even when actionText is falsy or loading is true; move the
Pressable so it is only rendered inside the actionText conditional (so no
invisible tap target when actionText is absent) and pass a disabled prop (or
early-return from onPress) while loading is true to prevent taps firing during
loading; update the block that references Pressable, actionText, onPress,
loading and XStack accordingly so the Spinner/Chevron rendering stays the same
but the Pressable is only mounted when actionText exists and is disabled when
loading.

Comment on lines +148 to +166
<XStack
cursor="pointer"
onPress={() => {
setAcknowledged(!acknowledged);
}}
>
<View
width={16}
height={16}
backgroundColor={acknowledged ? "$backgroundBrand" : "transparent"}
borderColor="$backgroundBrand"
borderWidth={1}
borderRadius="$r2"
justifyContent="center"
alignItems="center"
>
{acknowledged && <Check size="$iconSize.xs" color="white" />}
</View>
</XStack>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Checkbox touch target is 16×16 — too small for mobile.

The pressable area is only as large as the 16×16 View it wraps. Mobile accessibility guidelines recommend a minimum of 44×44 pt (iOS) / 48×48 dp (Android). Users will struggle to tap this reliably.

Add padding or minWidth/minHeight to the pressable XStack to enlarge the hit area without changing the visual checkbox size:

🛠️ Suggested fix
             <XStack
               cursor="pointer"
+              padding="$s3"
               onPress={() => {
                 setAcknowledged(!acknowledged);
               }}
             >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<XStack
cursor="pointer"
onPress={() => {
setAcknowledged(!acknowledged);
}}
>
<View
width={16}
height={16}
backgroundColor={acknowledged ? "$backgroundBrand" : "transparent"}
borderColor="$backgroundBrand"
borderWidth={1}
borderRadius="$r2"
justifyContent="center"
alignItems="center"
>
{acknowledged && <Check size="$iconSize.xs" color="white" />}
</View>
</XStack>
<XStack
cursor="pointer"
padding="$s3"
onPress={() => {
setAcknowledged(!acknowledged);
}}
>
<View
width={16}
height={16}
backgroundColor={acknowledged ? "$backgroundBrand" : "transparent"}
borderColor="$backgroundBrand"
borderWidth={1}
borderRadius="$r2"
justifyContent="center"
alignItems="center"
>
{acknowledged && <Check size="$iconSize.xs" color="white" />}
</View>
</XStack>
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` around lines 148 - 166, The touch
target for the checkbox is only the 16×16 View so make the pressable area larger
by increasing the hitbox on the XStack instead of changing the visual checkbox;
update the XStack wrapping the View (the onPress that calls setAcknowledged and
reads acknowledged) to include padding or minWidth/minHeight (e.g., ensure at
least 44×44) and center the inner View so the visual checkbox (width/height 16)
remains unchanged while the tappable area is enlarged; keep the onPress and
Check rendering logic as-is.

{acknowledged && <Check size="$iconSize.xs" color="white" />}
</View>
</XStack>
<XStack alignItems="center" cursor="pointer">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

cursor="pointer" on text container with no onPress is misleading.

The XStack wrapping the "I accept the…" text sets cursor="pointer" but has no press handler. On web this shows a pointer cursor for text that isn't interactive (the inner <terms> link has its own handler). Either remove cursor="pointer" or wire up an onPress to toggle the checkbox — the latter is the more conventional pattern for label-as-toggle.

🛠️ Option A: remove misleading cursor
-            <XStack alignItems="center" cursor="pointer">
+            <XStack alignItems="center">
🛠️ Option B: make label toggle the checkbox (conventional)
-            <XStack alignItems="center" cursor="pointer">
+            <XStack alignItems="center" cursor="pointer" onPress={() => setAcknowledged(!acknowledged)}>

Note: Option B requires care to avoid double-toggling when the user taps the <terms> link, since the event would bubble. If choosing Option B, add event.stopPropagation() inside the terms onPress.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<XStack alignItems="center" cursor="pointer">
<XStack alignItems="center">
<Checkbox
Suggested change
<XStack alignItems="center" cursor="pointer">
<XStack alignItems="center" cursor="pointer" onPress={() => setAcknowledged(!acknowledged)}>
<Checkbox
🤖 Prompt for AI Agents
In `@src/components/add-funds/Onboard.tsx` at line 167, The XStack wrapping the "I
accept the…" text should not show a pointer unless it's interactive; either
remove cursor="pointer" from XStack or make it toggle the acceptance
state—preferably wire XStack's onPress to toggle the checkbox state (e.g., call
the same handler that updates isAccepted / setAccepted or toggleAccept()), and
inside the inner terms link's onPress add event.stopPropagation() to prevent
double-toggling; update XStack, the terms onPress, and the checkbox toggle
handler accordingly.

Comment on lines +34 to +35
router.replace("/(main)/(home)");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Pressable action area is still tappable while loading / without actionText.

The Pressable wrapping the action row (lines 34–51) fires onPress?.() regardless of whether actionText is rendered. When actionText is absent the tap target is invisible but still active, and when loading is true repeated taps can re-trigger the handler.

Move the Pressable inside the actionText guard and disable it while loading.

Proposed fix
-        <Pressable
-          onPress={() => {
-            onPress?.();
-          }}
-        >
-          {actionText && (
-            <XStack gap="$s1" alignItems="center">
-              <Text emphasized subHeadline color="$interactiveOnBaseInformationSoft">
-                {actionText}
-              </Text>
-              {loading ? (
-                <Spinner color="$interactiveOnBaseInformationSoft" />
-              ) : (
-                <ChevronRight size={16} color="$interactiveOnBaseInformationSoft" strokeWidth={3} />
-              )}
-            </XStack>
-          )}
-        </Pressable>
+        {actionText && (
+          <Pressable disabled={loading} onPress={onPress}>
+            <XStack gap="$s1" alignItems="center">
+              <Text emphasized subHeadline color="$interactiveOnBaseInformationSoft">
+                {actionText}
+              </Text>
+              {loading ? (
+                <Spinner color="$interactiveOnBaseInformationSoft" />
+              ) : (
+                <ChevronRight size={16} color="$interactiveOnBaseInformationSoft" strokeWidth={3} />
+              )}
+            </XStack>
+          </Pressable>
+        )}
🤖 Prompt for AI Agents
In `@src/components/add-funds/Status.tsx` around lines 34 - 35, The Pressable
wrapper currently surrounding the action row allows taps even when actionText is
absent or when loading is true; fix this by moving the Pressable so it is only
rendered inside the actionText guard (render Pressable only when actionText
exists) and ensure its onPress is disabled while loading (e.g., conditionally
pass undefined or no-op to onPress or set disabled prop when loading is true).
Update references in the component to the Pressable, actionText, loading, and
onPress handlers so the tap target only exists when visible and cannot be
re-triggered during loading.

Comment on lines 34 to 51
<Pressable
onPress={() => {
onPress?.();
}}
>
<XStack flexDirection="row" gap={2} alignItems="center">
<Text color="$interactiveOnBaseInformationSoft" fontSize={15} fontWeight="bold">
{actionText}
</Text>
{loading ? (
<Spinner color="$interactiveOnBaseInformationSoft" />
) : (
<ChevronRight
size={16}
color="$interactiveOnBaseInformationSoft"
fontWeight="bold"
strokeWidth={3}
transform={[{ translateY: 1.2 }]}
/>
)}
</XStack>
{actionText && (
<XStack gap="$s1" alignItems="center">
<Text emphasized subHeadline color="$interactiveOnBaseInformationSoft">
{actionText}
</Text>
{loading ? (
<Spinner color="$interactiveOnBaseInformationSoft" />
) : (
<ChevronRight size={16} color="$interactiveOnBaseInformationSoft" strokeWidth={3} />
)}
</XStack>
)}
</Pressable>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Pressable is active even when there's no visible action, and isn't disabled while loading.

Two related issues remain:

  1. The Pressable is always mounted regardless of actionText. When actionText is falsy the tap target is invisible but still responds to touches (calling onPress?.()).
  2. While loading is true, taps still fire onPress, risking duplicate submissions.

Move the Pressable inside the actionText guard and disable it during loading.

Proposed fix
-        <Pressable
-          onPress={() => {
-            onPress?.();
-          }}
-        >
-          {actionText && (
-            <XStack gap="$s1" alignItems="center">
-              <Text emphasized subHeadline color="$interactiveOnBaseInformationSoft">
-                {actionText}
-              </Text>
-              {loading ? (
-                <Spinner color="$interactiveOnBaseInformationSoft" />
-              ) : (
-                <ChevronRight size={16} color="$interactiveOnBaseInformationSoft" strokeWidth={3} />
-              )}
-            </XStack>
-          )}
-        </Pressable>
+        {actionText && (
+          <Pressable disabled={loading} onPress={onPress}>
+            <XStack gap="$s1" alignItems="center">
+              <Text emphasized subHeadline color="$interactiveOnBaseInformationSoft">
+                {actionText}
+              </Text>
+              {loading ? (
+                <Spinner color="$interactiveOnBaseInformationSoft" />
+              ) : (
+                <ChevronRight size={16} color="$interactiveOnBaseInformationSoft" strokeWidth={3} />
+              )}
+            </XStack>
+          </Pressable>
+        )}
🤖 Prompt for AI Agents
In `@src/components/shared/InfoAlert.tsx` around lines 34 - 51, The Pressable
currently wraps the actionText block and remains interactive even when
actionText is falsy or loading is true; move the Pressable so it is only
rendered inside the actionText conditional (so no invisible tap target when
actionText is absent) and pass a disabled prop (or early-return from onPress)
while loading is true to prevent taps firing during loading; update the block
that references Pressable, actionText, onPress, loading and XStack accordingly
so the Spinner/Chevron rendering stays the same but the Pressable is only
mounted when actionText exists and is disabled when loading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui: ramp manteca

2 participants