diff --git a/.changeset/clear-trains-move.md b/.changeset/clear-trains-move.md
new file mode 100644
index 000000000..87da4aaa6
--- /dev/null
+++ b/.changeset/clear-trains-move.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 adjust tamagui tokens
diff --git a/.changeset/cold-poems-end.md b/.changeset/cold-poems-end.md
new file mode 100644
index 000000000..92077bd10
--- /dev/null
+++ b/.changeset/cold-poems-end.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 update tab bar ui
diff --git a/.changeset/curly-waves-yell.md b/.changeset/curly-waves-yell.md
new file mode 100644
index 000000000..7e877eb62
--- /dev/null
+++ b/.changeset/curly-waves-yell.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 restyle card status component
diff --git a/.changeset/easy-snails-brake.md b/.changeset/easy-snails-brake.md
new file mode 100644
index 000000000..59839c8d3
--- /dev/null
+++ b/.changeset/easy-snails-brake.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 restyle action buttons
diff --git a/.changeset/green-weeks-care.md b/.changeset/green-weeks-care.md
new file mode 100644
index 000000000..3868574be
--- /dev/null
+++ b/.changeset/green-weeks-care.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+✨ implement new pay screen
diff --git a/.changeset/jolly-radios-poke.md b/.changeset/jolly-radios-poke.md
new file mode 100644
index 000000000..d9a39d1ac
--- /dev/null
+++ b/.changeset/jolly-radios-poke.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+♻️ unify card mode mutation
diff --git a/.changeset/khaki-queens-smash.md b/.changeset/khaki-queens-smash.md
new file mode 100644
index 000000000..56a9a5012
--- /dev/null
+++ b/.changeset/khaki-queens-smash.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 apply tamagui spacing tokens
diff --git a/.changeset/lazy-planes-dance.md b/.changeset/lazy-planes-dance.md
new file mode 100644
index 000000000..2df024e57
--- /dev/null
+++ b/.changeset/lazy-planes-dance.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 refine benefits carousel
diff --git a/.changeset/little-hoops-train.md b/.changeset/little-hoops-train.md
new file mode 100644
index 000000000..05bbc9f8f
--- /dev/null
+++ b/.changeset/little-hoops-train.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 replace fonts
diff --git a/.changeset/many-balloons-bake.md b/.changeset/many-balloons-bake.md
new file mode 100644
index 000000000..9d1d0a466
--- /dev/null
+++ b/.changeset/many-balloons-bake.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+🌐 adjust translations
diff --git a/.changeset/purple-lizards-run.md b/.changeset/purple-lizards-run.md
new file mode 100644
index 000000000..d48d2952b
--- /dev/null
+++ b/.changeset/purple-lizards-run.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+✨ add installments calculator
diff --git a/.changeset/quick-mangos-rescue.md b/.changeset/quick-mangos-rescue.md
new file mode 100644
index 000000000..be2ede773
--- /dev/null
+++ b/.changeset/quick-mangos-rescue.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+🐛 fix protocol asset logos
diff --git a/.changeset/salty-fans-mate.md b/.changeset/salty-fans-mate.md
new file mode 100644
index 000000000..d583340bd
--- /dev/null
+++ b/.changeset/salty-fans-mate.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 add vertical frame to styled button
diff --git a/.changeset/shiny-badgers-sleep.md b/.changeset/shiny-badgers-sleep.md
new file mode 100644
index 000000000..8cedafdcf
--- /dev/null
+++ b/.changeset/shiny-badgers-sleep.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 restyle overdue and upcoming payments
diff --git a/.changeset/soft-beans-grow.md b/.changeset/soft-beans-grow.md
new file mode 100644
index 000000000..639e6a270
--- /dev/null
+++ b/.changeset/soft-beans-grow.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💫 unify animation parameters
diff --git a/.changeset/spicy-doodles-give.md b/.changeset/spicy-doodles-give.md
new file mode 100644
index 000000000..49e038838
--- /dev/null
+++ b/.changeset/spicy-doodles-give.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 restyle portfolio summary
diff --git a/.changeset/spicy-dragons-raise.md b/.changeset/spicy-dragons-raise.md
new file mode 100644
index 000000000..892d5ce2b
--- /dev/null
+++ b/.changeset/spicy-dragons-raise.md
@@ -0,0 +1,5 @@
+---
+"@exactly/mobile": patch
+---
+
+💄 unify add funds gap value
diff --git a/.maestro/flows/local.yaml b/.maestro/flows/local.yaml
index bba3d4653..86feba1de 100644
--- a/.maestro/flows/local.yaml
+++ b/.maestro/flows/local.yaml
@@ -22,4 +22,5 @@ tags: [critical]
- runFlow: ../subflows/repay.yaml
- runFlow: ../subflows/verifyIdentity.yaml
- runFlow: ../subflows/activateCard.yaml
+- runFlow: ../subflows/readHome.yaml
- runFlow: ../subflows/storeCoverage.yaml
diff --git a/.maestro/subflows/activateCard.yaml b/.maestro/subflows/activateCard.yaml
index 16ce793ac..5747494df 100644
--- a/.maestro/subflows/activateCard.yaml
+++ b/.maestro/subflows/activateCard.yaml
@@ -42,4 +42,31 @@ appId: ${APP_ID ?? "app.exactly"}
text: \$[\s\d,.\xa0]+
above: Available balance
- tapOn: Home
-- assertVisible: SPENDING LIMIT
+- runFlow:
+ when: { visible: Tap here to change the number of installments }
+ commands:
+ - tapOn: Tap here to change the number of installments
+ - assertVisible: Set installments
+ - tapOn: "3"
+ - tapOn: Set Pay Later in 3
+ - tapOn: Later in 3
+ - assertVisible: Set installments
+ - tapOn: Installments calculator
+ - assertVisible: Enter a purchase amount
+ - assertVisible: BEST APR
+ - runFlow: { file: ../subflows/tapAria.yaml, env: { aria: Go back } }
+ - assertVisible: Exa Card pay mode
+- tapOn: Now
+- runFlow: { file: ../subflows/tapAria.yaml, env: { aria: Spending limit info } }
+- assertVisible: It's based on the USDC available in your balance.
+- tapOn: Close
+- tapOn: Later in \d+
+- runFlow: { file: ../subflows/tapAria.yaml, env: { aria: Credit limit info } }
+- assertVisible: It's based on the value of your collateral assets and updates as their value changes.
+- tapOn: Close
+- tapOn:
+ text: Learn more
+ rightOf: Exa Card pay mode
+- assertVisible: Change the pay mode before each purchase and pay how you want.
+- tapOn: Close
+
diff --git a/.maestro/subflows/copyAria.yaml b/.maestro/subflows/copyAria.yaml
new file mode 100644
index 000000000..6fabdf7ad
--- /dev/null
+++ b/.maestro/subflows/copyAria.yaml
@@ -0,0 +1,11 @@
+appId: ${APP_ID ?? "app.exactly"}
+---
+# HACK https://github.com/mobile-dev-inc/Maestro/issues/2914
+- runFlow:
+ when: { true: "${maestro.platform != 'web'}" }
+ commands:
+ - copyTextFrom: "${aria}"
+- runFlow:
+ when: { platform: web }
+ commands:
+ - copyTextFrom: { id: "${aria}" }
diff --git a/.maestro/subflows/dismissNotifications.yaml b/.maestro/subflows/dismissNotifications.yaml
index 7b18d26ba..88864e3a5 100644
--- a/.maestro/subflows/dismissNotifications.yaml
+++ b/.maestro/subflows/dismissNotifications.yaml
@@ -7,5 +7,5 @@ appId: ${APP_ID ?? "app.exactly"}
- tapOn: Not now
- pressKey: home
- launchApp: { permissions: { all: deny, camera: allow } }
- - extendedWaitUntil: { visible: Your portfolio, timeout: 180000 }
+ - extendedWaitUntil: { visible: Portfolio, timeout: 180000 }
- assertNotVisible: Stay updated
diff --git a/.maestro/subflows/readHome.yaml b/.maestro/subflows/readHome.yaml
new file mode 100644
index 000000000..5284d5e42
--- /dev/null
+++ b/.maestro/subflows/readHome.yaml
@@ -0,0 +1,67 @@
+appId: ${APP_ID ?? "app.exactly"}
+---
+- assertVisible: ${output.account.slice(0, 6)}…${output.account.slice(-4)}
+- runFlow:
+ when: { true: "${maestro.platform != 'web'}" }
+ commands: [{ assertVisible: Settings }]
+- runFlow:
+ when: { platform: web }
+ commands: [{ assertVisible: { id: Settings } }]
+- runFlow: { file: tapAria.yaml, env: { aria: Hide sensitive } }
+- assertNotVisible:
+ text: \$[\s\d,.\xa0]+
+ below: Portfolio
+- runFlow: { file: tapAria.yaml, env: { aria: Show sensitive } }
+- assertVisible:
+ text: \$[\s\d,.\xa0]+
+ below: Portfolio
+- assertVisible: Portfolio
+- assertVisible:
+ text: \$[\s\d,.\xa0]+
+ below: Portfolio
+- assertVisible: Manage portfolio
+- runFlow: readPortfolio.yaml
+- assertTrue: ${output.portfolio > 0}
+- assertVisible: Add funds
+- assertVisible: Send
+- assertVisible: Swap
+- assertVisible: Exa Card pay mode
+- assertVisible: Learn more
+- assertNotVisible: Getting Started
+- scrollUntilVisible: { element: Upcoming payments }
+- assertVisible: Benefits
+- extendedWaitUntil:
+ visible: 30 days of free travel insurance
+ timeout: 15000
+- tapOn: 30 days of free travel insurance
+- assertVisible: Copy your ID and get 30 days of travel insurance for free on Pax Assistance.
+- tapOn: COPY ID
+- runFlow: { file: tapAria.yaml, env: { aria: Close } }
+- extendedWaitUntil:
+ visible: 20% OFF on eSims
+ timeout: 15000
+- tapOn: 20% OFF on eSims
+- assertVisible: Stay connected around the world.
+- assertVisible: Terms & conditions
+- runFlow: { file: tapAria.yaml, env: { aria: Close } }
+- extendedWaitUntil:
+ visible: Visa Signature benefits
+ timeout: 15000
+- tapOn: Visa Signature benefits
+- assertVisible: Visa Signature Exa Card benefits
+- assertVisible: A world of benefits.
+- runFlow: { file: tapAria.yaml, env: { aria: Close } }
+- assertVisible: Upcoming payments
+- assertVisible:
+ text: \$[\d,]+\.\d{2}
+ below: Upcoming payments
+- scrollUntilVisible: { element: Latest activity }
+- assertVisible: Latest activity
+- assertVisible: View all
+- assertNotVisible: No activity yet
+- tapOn: View all
+- tapOn: Home
+- assertVisible: Portfolio
+- assertVisible:
+ text: \$[\s\d,.\xa0]+
+ below: Portfolio
diff --git a/.maestro/subflows/readPortfolio.yaml b/.maestro/subflows/readPortfolio.yaml
index f0ca91da0..ec4f197cf 100644
--- a/.maestro/subflows/readPortfolio.yaml
+++ b/.maestro/subflows/readPortfolio.yaml
@@ -1,6 +1,6 @@
appId: ${APP_ID ?? "app.exactly"}
---
-- copyTextFrom:
- below: Your portfolio
- text: ^(US)?\$[\s\d,.\xa0]+$
+- runFlow:
+ file: copyAria.yaml
+ env: { aria: "^(US)?\\$[\\s\\d,.\\xa0]+$" }
- evalScript: ${output.portfolio = Number(maestro.copiedText.replace(/\D/g, "")) / 100}
diff --git a/.maestro/subflows/repay.yaml b/.maestro/subflows/repay.yaml
index cde785b08..7995d7665 100644
--- a/.maestro/subflows/repay.yaml
+++ b/.maestro/subflows/repay.yaml
@@ -2,18 +2,17 @@ appId: ${APP_ID ?? "app.exactly"}
---
- scrollUntilVisible: { element: Upcoming payments }
- copyTextFrom:
- text: \d+[,.]\d{2}
+ text: \$[\d,]+\.\d{2}
below: Upcoming payments
- leftOf: Repay
-- evalScript: ${output.debt = Number(maestro.copiedText)}
+- evalScript: ${output.debt = Number(maestro.copiedText.replace(/[^\d.]/g, ''))}
- copyTextFrom:
- text: "[^%.]+"
- below: "${maestro.copiedText}"
- leftOf: Repay
+ text: "[^%.$]+"
+ below: Upcoming payments
+ leftOf: "${maestro.copiedText}"
- evalScript: ${output.maturity = maestro.copiedText}
-- tapOn: { text: Repay, rightOf: "${output.maturity}" }
+- tapOn: "${output.maturity}"
- waitForAnimationToEnd
-- tapOn: { text: Repay, leftOf: Rollover }
+- tapOn: { text: Pay, leftOf: Rollover }
- runFlow: { when: { true: "${!amount}" }, commands: [{ tapOn: Max }] }
- runFlow:
when: { true: "${amount}" }
@@ -50,9 +49,8 @@ appId: ${APP_ID ?? "app.exactly"}
commands:
- assertVisible: "${output.maturity}"
- copyTextFrom:
- text: \d+[,.]\d{2}
- above: "${output.maturity}"
+ text: \$[\d,]+\.\d{2}
below: Upcoming payments
- leftOf: Repay
- - assertTrue: ${Math.abs(output.debt - Number(maestro.copiedText) - Number(amount)) < 0.02}
+ rightOf: "${output.maturity}"
+ - assertTrue: ${Math.abs(output.debt - Number(maestro.copiedText.replace(/[^\d.]/g, '')) - Number(amount)) < 0.02}
- tapOn: Home
diff --git a/.maestro/subflows/rollover.yaml b/.maestro/subflows/rollover.yaml
index 3f8337b47..b25f4bf71 100644
--- a/.maestro/subflows/rollover.yaml
+++ b/.maestro/subflows/rollover.yaml
@@ -2,16 +2,14 @@ appId: ${APP_ID ?? "app.exactly"}
---
- scrollUntilVisible: { element: Upcoming payments }
- copyTextFrom:
- text: \d+[,.]\d{2}
+ text: "[^%.$]+"
below: Upcoming payments
- leftOf: Repay
index: 1
-- copyTextFrom:
- text: "[^%.]+"
- below: "${maestro.copiedText}"
- leftOf: Repay
- evalScript: ${output.secondMaturity = maestro.copiedText}
-- tapOn: Repay
+- copyTextFrom:
+ text: "[^%.$]+"
+ below: Upcoming payments
+- tapOn: "${maestro.copiedText}"
- waitForAnimationToEnd
- tapOn: Rollover
- runFlow:
@@ -28,12 +26,7 @@ appId: ${APP_ID ?? "app.exactly"}
env: { aria: Pending proposals, tap: Home }
- scrollUntilVisible: { element: Upcoming payments }
- copyTextFrom:
- text: \d+[,.]\d{2}
+ text: "[^%.$]+"
below: Upcoming payments
- leftOf: Repay
-- copyTextFrom:
- text: "[^%.]+"
- below: "${maestro.copiedText}"
- leftOf: Repay
- assertTrue: ${maestro.copiedText === output.secondMaturity}
- tapOn: Home
diff --git a/.maestro/subflows/verifyIdentity.yaml b/.maestro/subflows/verifyIdentity.yaml
index ba307515f..50b9b4759 100644
--- a/.maestro/subflows/verifyIdentity.yaml
+++ b/.maestro/subflows/verifyIdentity.yaml
@@ -42,7 +42,7 @@ appId: ${APP_ID ?? "app.exactly"}
- runFlow:
when: { platform: android }
commands: [{ tapOn: Confirmation Code }]
- - inputText: "12345"
+ - inputText: '12345'
- hideKeyboard
- tapOn: Continue
- assertVisible: What is your phone number?
@@ -51,14 +51,14 @@ appId: ${APP_ID ?? "app.exactly"}
- tapOn: 🇦🇷 Argentina +54
- tapOn:
containsChild: { containsChild: { id: textinput_prefix_text } } # cspell:ignore textinput
- - inputText: "1199999999"
+ - inputText: '1199999999'
- hideKeyboard
- tapOn: Continue
- assertVisible: Confirm your phone number
- runFlow: # HACK
when: { platform: android }
commands: [{ tapOn: { id: first } }]
- - inputText: "1234"
+ - inputText: '1234'
- hideKeyboard
- extendedWaitUntil: { visible: Economic activity }
- tapOn: Economic activity
@@ -81,6 +81,6 @@ appId: ${APP_ID ?? "app.exactly"}
- runScript: ../dist/getAccount.js
- runScript:
file: ../dist/approveKYC.js
- env: { credentialId: "${output.owner}" }
+ env: { credentialId: '${output.owner}' }
- tapOn: Home
- assertNotVisible: Getting Started
diff --git a/app.config.ts b/app.config.ts
index bdbe41dd7..2c7522261 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -80,9 +80,8 @@ export default {
"expo-font",
{
fonts: [
- "src/assets/fonts/BDOGrotesk-DemiBold.otf",
- "src/assets/fonts/BDOGrotesk-Regular.otf",
- "src/assets/fonts/IBMPlexMono-Medm.otf",
+ "src/assets/fonts/SplineSans-Regular.otf",
+ "src/assets/fonts/SplineSans-SemiBold.otf",
],
} satisfies FontProps,
],
diff --git a/cspell.json b/cspell.json
index c7935d432..95dfa7379 100644
--- a/cspell.json
+++ b/cspell.json
@@ -49,7 +49,6 @@
"decisioned",
"defi",
"delegatecall",
- "demi",
"deployless",
"dieguezguille",
"dismissable",
@@ -70,12 +69,10 @@
"gitmoji",
"gitmojis",
"graaljs",
- "grotesk",
"hdpi",
"hexlify",
"hideable",
"hono",
- "IBMPlexMono-Medm",
"IERC",
"indoc",
"infinitism",
@@ -146,6 +143,7 @@
"solmate",
"sourcify",
"spkg",
+ "splinesans",
"spotlightjs",
"staticcall",
"streamingfast",
diff --git a/src/app/(main)/(home)/_layout.tsx b/src/app/(main)/(home)/_layout.tsx
index a2654d31c..74b521478 100644
--- a/src/app/(main)/(home)/_layout.tsx
+++ b/src/app/(main)/(home)/_layout.tsx
@@ -2,24 +2,26 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
+import { selectionAsync } from "expo-haptics";
import Head from "expo-router/head";
import { TabList, Tabs, TabSlot, TabTrigger, useTabTrigger } from "expo-router/ui";
-import { Boxes, Coins, CreditCard, FileText, Home } from "@tamagui/lucide-icons";
+import { Boxes, CalendarCheck, CreditCard, History, Home } from "@tamagui/lucide-icons";
import { YStack } from "tamagui";
import SafeView from "../../../components/shared/SafeView";
import StatusIndicator from "../../../components/shared/StatusIndicator";
import Text from "../../../components/shared/Text";
+import reportError from "../../../utils/reportError";
import usePendingOperations from "../../../utils/usePendingOperations";
import { emitTabPress } from "../../../utils/useTabPress";
const tabs = [
{ name: "index", title: "Home", href: "/", Icon: Home },
{ name: "card", title: "Card", href: "/card", Icon: CreditCard },
- { name: "pay-mode", title: "Pay Mode", href: "/pay-mode", Icon: Coins },
+ { name: "pay", title: "Payments", href: "/pay", Icon: CalendarCheck },
{ name: "defi", title: "DeFi", href: "/defi", Icon: Boxes },
- { name: "activity", title: "Activity", href: "/activity", Icon: FileText },
+ { name: "activity", title: "Activity", href: "/activity", Icon: History },
] as const;
function TabItem({
@@ -41,7 +43,12 @@ function TabItem({
{showNotification && }
-
+
{title}
@@ -72,7 +79,16 @@ export default function HomeLayout() {
borderTopColor="$borderNeutralSoft"
>
{tabs.map(({ name, title, href, Icon }) => (
- emitTabPress(name)}>
+ {
+ selectionAsync().catch(reportError);
+ emitTabPress(name);
+ }}
+ >
0} />
))}
diff --git a/src/app/(main)/(home)/pay-mode.tsx b/src/app/(main)/(home)/pay-mode.tsx
deleted file mode 100644
index a189454b5..000000000
--- a/src/app/(main)/(home)/pay-mode.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "../../../components/pay-mode/PayMode";
diff --git a/src/app/(main)/(home)/pay.tsx b/src/app/(main)/(home)/pay.tsx
new file mode 100644
index 000000000..b18566087
--- /dev/null
+++ b/src/app/(main)/(home)/pay.tsx
@@ -0,0 +1 @@
+export { default } from "../../../components/pay/Pay";
diff --git a/src/app/(main)/calculator.tsx b/src/app/(main)/calculator.tsx
new file mode 100644
index 000000000..6475159c8
--- /dev/null
+++ b/src/app/(main)/calculator.tsx
@@ -0,0 +1 @@
+export { default } from "../../components/pay/InstallmentsCalculator";
diff --git a/src/app/(main)/pay/index.tsx b/src/app/(main)/pay/index.tsx
deleted file mode 100644
index bb2725296..000000000
--- a/src/app/(main)/pay/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "../../../components/pay-mode/Pay";
diff --git a/src/app/(main)/pay/_layout.tsx b/src/app/(main)/repay/_layout.tsx
similarity index 100%
rename from src/app/(main)/pay/_layout.tsx
rename to src/app/(main)/repay/_layout.tsx
diff --git a/src/app/(main)/repay/index.tsx b/src/app/(main)/repay/index.tsx
new file mode 100644
index 000000000..03ff57645
--- /dev/null
+++ b/src/app/(main)/repay/index.tsx
@@ -0,0 +1 @@
+export { default } from "../../../components/pay/Repay";
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 3b59d1a7a..664a39ddf 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -30,9 +30,8 @@ import { WagmiProvider } from "wagmi";
import domain from "@exactly/common/domain";
-import BDOGroteskDemiBold from "../assets/fonts/BDOGrotesk-DemiBold.otf";
-import BDOGroteskRegular from "../assets/fonts/BDOGrotesk-Regular.otf";
-import IBMPlexMonoMedium from "../assets/fonts/IBMPlexMono-Medm.otf";
+import SplineSansRegular from "../assets/fonts/SplineSans-Regular.otf";
+import SplineSansSemiBold from "../assets/fonts/SplineSans-SemiBold.otf";
import AppIcon from "../assets/icon.png";
import ThemeProvider from "../components/context/ThemeProvider";
import Error from "../components/shared/Error";
@@ -135,9 +134,8 @@ export default wrap(function RootLayout() {
const navigationContainer = useNavigationContainerRef();
useServerFonts({
- "BDOGrotesk-DemiBold": BDOGroteskDemiBold as FontSource,
- "BDOGrotesk-Regular": BDOGroteskRegular as FontSource,
- "IBMPlexMono-Medm": IBMPlexMonoMedium as FontSource,
+ "SplineSans-Regular": SplineSansRegular as FontSource,
+ "SplineSans-SemiBold": SplineSansSemiBold as FontSource,
});
useServerAssets([AppIcon]);
useEffect(() => {
diff --git a/src/assets/fonts/BDOGrotesk-DemiBold.otf b/src/assets/fonts/BDOGrotesk-DemiBold.otf
deleted file mode 100644
index d968f184f..000000000
Binary files a/src/assets/fonts/BDOGrotesk-DemiBold.otf and /dev/null differ
diff --git a/src/assets/fonts/BDOGrotesk-Regular.otf b/src/assets/fonts/BDOGrotesk-Regular.otf
deleted file mode 100644
index 9bfe3ec91..000000000
Binary files a/src/assets/fonts/BDOGrotesk-Regular.otf and /dev/null differ
diff --git a/src/assets/fonts/IBMPlexMono-Medm.otf b/src/assets/fonts/IBMPlexMono-Medm.otf
deleted file mode 100644
index f99385d31..000000000
Binary files a/src/assets/fonts/IBMPlexMono-Medm.otf and /dev/null differ
diff --git a/src/assets/fonts/SplineSans-Regular.otf b/src/assets/fonts/SplineSans-Regular.otf
new file mode 100644
index 000000000..11c39dd8d
Binary files /dev/null and b/src/assets/fonts/SplineSans-Regular.otf differ
diff --git a/src/assets/fonts/SplineSans-SemiBold.otf b/src/assets/fonts/SplineSans-SemiBold.otf
new file mode 100644
index 000000000..d81616815
Binary files /dev/null and b/src/assets/fonts/SplineSans-SemiBold.otf differ
diff --git a/src/assets/images/card-bg.svg b/src/assets/images/card-bg.svg
new file mode 100644
index 000000000..170f33f67
--- /dev/null
+++ b/src/assets/images/card-bg.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/images/exa.svg b/src/assets/images/exa.svg
new file mode 100644
index 000000000..97faef5cc
--- /dev/null
+++ b/src/assets/images/exa.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/activity/Activity.tsx b/src/components/activity/Activity.tsx
index e1e1c188e..456b32cd9 100644
--- a/src/components/activity/Activity.tsx
+++ b/src/components/activity/Activity.tsx
@@ -80,7 +80,7 @@ export default function Activity() {
ListHeaderComponent={
<>
-
+
{t("All Activity")}
diff --git a/src/components/activity/PendingProposals.tsx b/src/components/activity/PendingProposals.tsx
index 0b8298a6b..65548554f 100644
--- a/src/components/activity/PendingProposals.tsx
+++ b/src/components/activity/PendingProposals.tsx
@@ -146,7 +146,7 @@ export default function PendingProposals() {
return (
-
+
{
if (router.canGoBack()) {
diff --git a/src/components/activity/details/ActivityDetails.tsx b/src/components/activity/details/ActivityDetails.tsx
index c5296ebac..83551a219 100644
--- a/src/components/activity/details/ActivityDetails.tsx
+++ b/src/components/activity/details/ActivityDetails.tsx
@@ -27,7 +27,7 @@ export default function ActivityDetails() {
if (!item) return null;
return (
-
+
{
if (router.canGoBack()) {
diff --git a/src/components/add-funds/AddCrypto.tsx b/src/components/add-funds/AddCrypto.tsx
index 45a465522..6d431eca7 100644
--- a/src/components/add-funds/AddCrypto.tsx
+++ b/src/components/add-funds/AddCrypto.tsx
@@ -72,13 +72,19 @@ export default function AddCrypto() {
-
+
{t("Your {{chain}} address", { chain: chain.name })}
{address && (
-
+
{shortenHex(address, 10, 12)}
)}
diff --git a/src/components/add-funds/AddFunds.tsx b/src/components/add-funds/AddFunds.tsx
index 4da0f5042..5fe957b94 100644
--- a/src/components/add-funds/AddFunds.tsx
+++ b/src/components/add-funds/AddFunds.tsx
@@ -56,7 +56,7 @@ export default function AddFunds() {
-
+
{
if (router.canGoBack()) {
@@ -110,7 +110,7 @@ export default function AddFunds() {
) : (
providers && (
-
+
{Object.entries(providers).flatMap(([providerKey, provider]) => {
const currencies = provider.onramp.currencies;
return currencies.map((currency) => (
diff --git a/src/components/add-funds/AddFundsOption.tsx b/src/components/add-funds/AddFundsOption.tsx
index 5c49529ad..ac8755bdb 100644
--- a/src/components/add-funds/AddFundsOption.tsx
+++ b/src/components/add-funds/AddFundsOption.tsx
@@ -23,9 +23,9 @@ export default function AddFundsOption({
backgroundColor="$backgroundSoft"
borderRadius="$r5"
cursor="pointer"
+ onPress={onPress}
borderWidth={1}
borderColor="$borderNeutralSoft"
- onPress={onPress}
>
diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx
index fcba9e221..b40633aa5 100644
--- a/src/components/add-funds/Bridge.tsx
+++ b/src/components/add-funds/Bridge.tsx
@@ -566,7 +566,7 @@ export default function Bridge() {
-
+
{
if (router.canGoBack()) {
diff --git a/src/components/add-funds/Ramp.tsx b/src/components/add-funds/Ramp.tsx
index b143b9928..b42a3c6c2 100644
--- a/src/components/add-funds/Ramp.tsx
+++ b/src/components/add-funds/Ramp.tsx
@@ -106,9 +106,9 @@ export default function Ramp() {
return (
-
-
-
+
+
+
{
if (router.canGoBack()) {
@@ -129,7 +129,7 @@ export default function Ramp() {
-
+
diff --git a/src/components/add-funds/Status.tsx b/src/components/add-funds/Status.tsx
index 4a5eb7184..db1382693 100644
--- a/src/components/add-funds/Status.tsx
+++ b/src/components/add-funds/Status.tsx
@@ -36,9 +36,9 @@ export default function Status() {
return (
-
+
-
+
diff --git a/src/components/auth/Auth.tsx b/src/components/auth/Auth.tsx
index 8170d80da..60a995dc6 100644
--- a/src/components/auth/Auth.tsx
+++ b/src/components/auth/Auth.tsx
@@ -121,7 +121,7 @@ export default function Auth() {
height={itemWidth / aspectRatio}
autoPlay
autoPlayInterval={5000}
- scrollAnimationDuration={500}
+ withAnimation={{ type: "timing", config: { duration: 512, easing: Easing.bezier(0.7, 0, 0.3, 1) } }}
onSnapToItem={handleSnapToItem}
onScrollEnd={handleScrollEnd}
onProgressChange={handleProgressChange}
diff --git a/src/components/benefits/BenefitCard.tsx b/src/components/benefits/BenefitCard.tsx
index 196ef0855..181bf0344 100644
--- a/src/components/benefits/BenefitCard.tsx
+++ b/src/components/benefits/BenefitCard.tsx
@@ -1,7 +1,6 @@
import React, { memo, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import { scheduleOnRN } from "react-native-worklets";
import { ChevronRight } from "@tamagui/lucide-icons";
import { XStack, YStack } from "tamagui";
@@ -21,10 +20,7 @@ export default memo(function BenefitCard({ benefit, onPress }: BenefitCardProper
const tap = useMemo(
() =>
/* istanbul ignore next */
- Gesture.Tap().onEnd(() => {
- "worklet";
- scheduleOnRN(onPress);
- }),
+ Gesture.Tap().runOnJS(true).onEnd(onPress),
[onPress],
);
return (
@@ -47,7 +43,7 @@ export default memo(function BenefitCard({ benefit, onPress }: BenefitCardProper
-
+
{t(benefit.partner)}
@@ -56,7 +52,7 @@ export default memo(function BenefitCard({ benefit, onPress }: BenefitCardProper
{benefit.linkText ? t(benefit.linkText) : t("Get now")}
-
+
diff --git a/src/components/benefits/BenefitSheet.tsx b/src/components/benefits/BenefitSheet.tsx
index 3b11e659c..2aa7933f7 100644
--- a/src/components/benefits/BenefitSheet.tsx
+++ b/src/components/benefits/BenefitSheet.tsx
@@ -64,7 +64,7 @@ export default function BenefitSheet({ benefit, open, onClose }: BenefitSheetPro
backgroundColor="$backgroundSoft"
>
-
+
@@ -90,6 +90,8 @@ export default function BenefitSheet({ benefit, open, onClose }: BenefitSheetPro
{benefit.id === "pax" && (
{
if (!paxData) return;
@@ -114,7 +116,7 @@ export default function BenefitSheet({ benefit, open, onClose }: BenefitSheetPro
{isPaxLoading ? (
) : paxData ? (
-
+
{paxData.associateId}
) : (
diff --git a/src/components/benefits/BenefitsSection.tsx b/src/components/benefits/BenefitsSection.tsx
index 47d56ab59..dd1c7d2af 100644
--- a/src/components/benefits/BenefitsSection.tsx
+++ b/src/components/benefits/BenefitsSection.tsx
@@ -1,8 +1,8 @@
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Platform, StyleSheet, useWindowDimensions } from "react-native";
+import { StyleSheet, useWindowDimensions } from "react-native";
import type { SharedValue } from "react-native-reanimated";
-import { Extrapolation, interpolate, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
+import { Easing, Extrapolation, interpolate, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import Carousel from "react-native-reanimated-carousel";
import { useTheme, View, XStack } from "tamagui";
@@ -12,7 +12,6 @@ import BenefitSheet from "./BenefitSheet";
import AiraloLogo from "../../assets/images/airalo.svg";
import PaxLogo from "../../assets/images/pax.svg";
import VisaLogo from "../../assets/images/visa.svg";
-import useAspectRatio from "../../utils/useAspectRatio";
import AnimatedView from "../shared/AnimatedView";
import Text from "../shared/Text";
@@ -60,7 +59,10 @@ const BENEFITS = [
export type Benefit = (typeof BENEFITS)[number];
-const styles = StyleSheet.create({ dot: { height: 8, borderRadius: 10 } });
+const styles = StyleSheet.create({
+ dot: { height: 4, borderRadius: 9999 },
+ overflow: { overflow: "visible" },
+});
/* istanbul ignore next */
function calculateDistance(scrollOffset: number, index: number, length: number) {
@@ -89,7 +91,7 @@ function PaginationDot({
/* istanbul ignore next */
const rStyle = useAnimatedStyle(() => {
const distance = calculateDistance(scrollOffset.value, index, length);
- const width = interpolate(distance, [0, 1], [20, 8], Extrapolation.CLAMP);
+ const width = interpolate(distance, [0, 1], [24, 8], Extrapolation.CLAMP);
const opacity = interpolate(distance, [0, 1], [1, 0.4], Extrapolation.CLAMP);
return { width, opacity };
}, [scrollOffset, index, length]);
@@ -111,11 +113,8 @@ export default function BenefitsSection() {
const [sheetOpen, setSheetOpen] = useState(false);
const scrollOffset = useSharedValue(0);
- const aspectRatio = useAspectRatio();
-
- const { width, height } = useWindowDimensions();
-
- const carouselWidth = Math.max(Platform.OS === "web" ? Math.min(height * aspectRatio, 600) - 64 : width - 64, 250);
+ const { width } = useWindowDimensions();
+ const itemWidth = Math.max(width - 40, 250);
const handleProgressChange = useCallback(
(_: number, absoluteProgress: number) => {
@@ -126,30 +125,42 @@ export default function BenefitsSection() {
return (
<>
-
-
+
+
{t("Benefits")}
- {BENEFITS.map((benefit, index) => (
-
- ))}
+
+ {BENEFITS.map((benefit, index) => (
+
+ ))}
+
-
+
gesture.activeOffsetX([-10, 10]).failOffsetY([-5, 5])}
renderItem={({ item }) => (
-
+
{t("My Exa Card")}
-
+
{
@@ -383,7 +383,7 @@ export default function Card() {
void; op
{details.pan.match(/.{1,4}/g)?.join(" ") ?? ""}
@@ -134,7 +133,6 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op
{`${card.expirationMonth}/${card.expirationYear.length === 4 ? card.expirationYear.slice(-2) : card.expirationYear}`}
@@ -152,7 +150,6 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op
{details.cvc}
diff --git a/src/components/card/CardPIN.tsx b/src/components/card/CardPIN.tsx
index faf0dacb6..27d8af433 100644
--- a/src/components/card/CardPIN.tsx
+++ b/src/components/card/CardPIN.tsx
@@ -106,15 +106,13 @@ function Countdown({ pin, error, onRetry }: { error: unknown; onRetry: () => voi
{Array.from({ length: pin.length }).map((_, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key
-
+
{displayPIN ? pin[index] : "*"}
))}
) : (
-
- {t("N/A")}
-
+ {t("N/A")}
)}
-
+
{productId === PLATINUM_PRODUCT_ID ? (
-
-
-
+
+
+
-
+
@@ -46,7 +46,7 @@ function AssetRow({ asset }: { asset: AssetItem }) {
-
+
{rate === undefined ? (
asset.market ? (
<>
@@ -73,7 +73,7 @@ function AssetRow({ asset }: { asset: AssetItem }) {
>
)}
-
+
{`$${(Number(usdValue) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
@@ -94,7 +94,7 @@ function AssetRow({ asset }: { asset: AssetItem }) {
function AssetSection({ title, assets }: { assets: AssetItem[]; title: string }) {
if (assets.length === 0) return null;
return (
-
+
{title}
diff --git a/src/components/home/CardLimits.tsx b/src/components/home/CardLimits.tsx
deleted file mode 100644
index b244fe191..000000000
--- a/src/components/home/CardLimits.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-
-import { useRouter } from "expo-router";
-
-import { ChevronRight, Info } from "@tamagui/lucide-icons";
-import { XStack, YStack } from "tamagui";
-
-import { useQuery } from "@tanstack/react-query";
-import { zeroAddress } from "viem";
-
-import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain";
-import { useReadPreviewerExactly } from "@exactly/common/generated/hooks";
-import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib";
-
-import useAccount from "../../utils/useAccount";
-import AssetLogo from "../shared/AssetLogo";
-import Text from "../shared/Text";
-
-import type { CardDetails } from "../../utils/server";
-
-export default function CardLimits({ onPress }: { onPress: () => void }) {
- const {
- t,
- i18n: { language },
- } = useTranslation();
- const { address } = useAccount();
- const router = useRouter();
- const { data: card } = useQuery({ queryKey: ["card", "details"] });
- const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, args: [address ?? zeroAddress] });
- const isCredit = card ? card.mode > 0 : false;
- return (
-
-
-
- {isCredit ? null : }
-
- {`$${(markets
- ? Number(
- isCredit ? borrowLimit(markets, marketUSDCAddress) : withdrawLimit(markets, marketUSDCAddress, WAD),
- ) / 1e6
- : 0
- ).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
-
-
-
-
- {t("Spending limit")}
-
-
-
-
- {
- router.push("/pay-mode");
- }}
- >
-
- {isCredit ? t("{{count}} installments", { count: card?.mode }) : t("Pay Now")}
-
-
-
-
- );
-}
diff --git a/src/components/home/CardStatus.tsx b/src/components/home/CardStatus.tsx
index 59e67a58d..3dbce964e 100644
--- a/src/components/home/CardStatus.tsx
+++ b/src/components/home/CardStatus.tsx
@@ -1,66 +1,294 @@
-import React from "react";
-import { Platform } from "react-native";
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform, Pressable, StyleSheet, type View as RNView } from "react-native";
-import { useRouter } from "expo-router";
+import { selectionAsync } from "expo-haptics";
-import { XStack, YStack } from "tamagui";
+import { CalendarDays, ChevronRight, CreditCard, Info, Wallet, Zap } from "@tamagui/lucide-icons";
+import { View, XStack, YStack } from "tamagui";
-import { SIGNATURE_PRODUCT_ID } from "@exactly/common/panda";
+import { useQuery } from "@tanstack/react-query";
-import CardLimits from "./CardLimits";
-import SignatureCard from "../../assets/images/card-signature.svg";
-import Card from "../../assets/images/card.svg";
+import CardBg from "../../assets/images/card-bg.svg";
+import Exa from "../../assets/images/exa.svg";
+import reportError from "../../utils/reportError";
+import Amount from "../shared/Amount";
+import Text from "../shared/Text";
-export default function CardStatus({ onInfoPress, productId }: { onInfoPress: () => void; productId: string }) {
- const router = useRouter();
+export default function CardStatus({
+ collateral,
+ creditLimit,
+ spotlightRef,
+ mode,
+ onCreditLimitInfoPress,
+ onDetailsPress,
+ onInstallmentsPress,
+ onLearnMorePress,
+ onModeChange,
+ onSpendingLimitInfoPress,
+ spendingLimit,
+}: {
+ collateral: string;
+ creditLimit: string;
+ mode: number;
+ onCreditLimitInfoPress: () => void;
+ onDetailsPress: () => void;
+ onInstallmentsPress: () => void;
+ onLearnMorePress: () => void;
+ onModeChange: (mode: number) => void;
+ onSpendingLimitInfoPress: () => void;
+ spendingLimit: string;
+ spotlightRef?: React.RefObject;
+}) {
+ const { t } = useTranslation();
return (
-
-
-
+
+
+ {t("Exa Card pay mode")}
+
+
+
+ {t("Learn more")}
+
+
+
+
+
+
+
+
+
+
+ {
+ selectionAsync().catch(reportError);
+ onDetailsPress();
+ }}
+ hitSlop={8}
+ >
+ {({ pressed, hovered }) => (
+
+
+
+ {t("Details")}
+
+
+ )}
+
+
+
+
-
+ );
+}
+
+function PayModeToggle({
+ spotlightRef,
+ mode,
+ onInstallmentsPress,
+ onModeChange,
+}: {
+ mode: number;
+ onInstallmentsPress: () => void;
+ onModeChange: (mode: number) => void;
+ spotlightRef?: React.RefObject;
+}) {
+ const { t } = useTranslation();
+ const { data: lastInstallments } = useQuery({ queryKey: ["settings", "installments"] });
+ const isDebit = mode === 0;
+ const [width, setWidth] = useState(0);
+ return (
+ {
+ setWidth(event.nativeEvent.layout.width);
+ }}
+ >
+
+ {
- router.push("/card");
+ if (isDebit) return;
+ selectionAsync().catch(reportError);
+ onModeChange(0);
}}
>
- {productId === SIGNATURE_PRODUCT_ID ? (
-
- ) : (
-
- )}
-
+
+
+
+ {t("Now")}
+
+
+
+ {
+ selectionAsync().catch(reportError);
+ if (mode > 0) onInstallmentsPress();
+ else onModeChange(lastInstallments ?? 1);
+ }}
+ >
+
+
+
+ {t("Later in {{count}}", { count: mode > 0 ? mode : (lastInstallments ?? 1) })}
+
+
+
);
}
+
+function LimitPaginator({
+ collateral,
+ creditLimit,
+ mode,
+ onCreditLimitInfoPress,
+ onSpendingLimitInfoPress,
+ spendingLimit,
+}: {
+ collateral: string;
+ creditLimit: string;
+ mode: number;
+ onCreditLimitInfoPress: () => void;
+ onSpendingLimitInfoPress: () => void;
+ spendingLimit: string;
+}) {
+ const { t } = useTranslation();
+ const [width, setWidth] = useState(0);
+ return (
+ setWidth(event.nativeEvent.layout.width)}>
+ 0 ? -width : 0} animation="default" animateOnly={["transform"]}>
+
+
+
+
+ {t("Spending limit")}
+
+
+
+
+
+
+
+ $
+
+
+ {spendingLimit.replace("$", "")}
+
+
+
+
+
+
+
+
+ {t("Credit limit")}
+
+
+
+
+
+
+ {t("Collateral {{value}}", { value: collateral })}
+
+
+
+
+ $
+
+
+ {creditLimit.replace("$", "")}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ details: { position: "absolute", top: 8, right: 8 },
+ exa: { position: "absolute", top: 12, left: 16 },
+ learnMore: { flexDirection: "row", alignItems: "center", gap: 4 },
+ segment: { flex: 1, justifyContent: "center", alignItems: "center" },
+});
diff --git a/src/components/home/CreditLimitSheet.tsx b/src/components/home/CreditLimitSheet.tsx
new file mode 100644
index 000000000..56d5891b3
--- /dev/null
+++ b/src/components/home/CreditLimitSheet.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { Pressable } from "react-native";
+
+import { ExternalLink, X } from "@tamagui/lucide-icons";
+import { XStack, YStack } from "tamagui";
+
+import { presentArticle } from "../../utils/intercom";
+import reportError from "../../utils/reportError";
+import Button from "../shared/Button";
+import ModalSheet from "../shared/ModalSheet";
+import Text from "../shared/Text";
+
+export default function CreditLimitSheet({ onClose, open }: { onClose: () => void; open: boolean }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t("Credit limit")}
+
+
+
+
+
+
+
+ }}
+ />
+
+
+ {t("It's based on the value of your collateral assets and updates as their value changes.")}
+
+
+
+
+
+
+
+
+ {t("Close")}
+
+
+
+
+
+ );
+}
diff --git a/src/components/home/GettingStarted.tsx b/src/components/home/GettingStarted.tsx
index 61b6fae52..577f817d0 100644
--- a/src/components/home/GettingStarted.tsx
+++ b/src/components/home/GettingStarted.tsx
@@ -40,7 +40,7 @@ export default function GettingStarted({ isDeployed, hasKYC }: { hasKYC: boolean
borderRadius="$r3"
opacity={1}
transform={[{ translateY: 0 }]}
- animation="moderate"
+ animation="default"
animateOnly={["opacity", "transform"]}
enterStyle={{ opacity: 0, transform: [{ translateY: -20 }] }}
exitStyle={{ opacity: 0, transform: [{ translateY: -20 }] }}
@@ -50,7 +50,7 @@ export default function GettingStarted({ isDeployed, hasKYC }: { hasKYC: boolean
{t("Getting Started")}
-
+
{
diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx
index a4234ae3d..3529a0021 100644
--- a/src/components/home/Home.tsx
+++ b/src/components/home/Home.tsx
@@ -1,45 +1,54 @@
-import React, { useRef, useState } from "react";
+import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { RefreshControl } from "react-native";
+import { RefreshControl, type View as RNView } from "react-native";
import { useRouter } from "expo-router";
import { AnimatePresence, ScrollView, YStack } from "tamagui";
import { TimeToFullDisplay } from "@sentry/react-native";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { zeroAddress } from "viem";
import { useBytecode } from "wagmi";
import accountInit from "@exactly/common/accountInit";
-import { exaPluginAddress, exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain";
+import {
+ exaPluginAddress,
+ exaPreviewerAddress,
+ marketUSDCAddress,
+ previewerAddress,
+} from "@exactly/common/generated/chain";
import {
useReadExaPreviewerPendingProposals,
useReadPreviewerExactly,
useReadUpgradeableModularAccountGetInstalledPlugins,
} from "@exactly/common/generated/hooks";
import { PLATINUM_PRODUCT_ID } from "@exactly/common/panda";
-import { healthFactor, WAD } from "@exactly/lib";
+import { borrowLimit, healthFactor, WAD, withdrawLimit } from "@exactly/lib";
import CardUpgradeSheet from "./card-upgrade/CardUpgradeSheet";
import CardStatus from "./CardStatus";
+import CreditLimitSheet from "./CreditLimitSheet";
import GettingStarted from "./GettingStarted";
import HomeActions from "./HomeActions";
import HomeDisclaimer from "./HomeDisclaimer";
+import InstallmentsSheet from "./InstallmentsSheet";
+import InstallmentsSpotlight from "./InstallmentsSpotlight";
+import PayModeSheet from "./PayModeSheet";
import PortfolioSummary from "./PortfolioSummary";
-import SpendingLimitsSheet from "./SpendingLimitsSheet";
+import SpendingLimitSheet from "./SpendingLimitSheet";
import VisaSignatureBanner from "./VisaSignatureBanner";
import VisaSignatureModal from "./VisaSignatureSheet";
import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
-import { getActivity, getKYCStatus, type CardDetails } from "../../utils/server";
+import { cardModeMutationOptions, getActivity, getKYCStatus, type CardDetails } from "../../utils/server";
import useAccount from "../../utils/useAccount";
import usePortfolio from "../../utils/usePortfolio";
import useTabPress from "../../utils/useTabPress";
import BenefitsSection from "../benefits/BenefitsSection";
-import OverduePayments from "../pay-mode/OverduePayments";
-import PaymentSheet from "../pay-mode/PaymentSheet";
-import UpcomingPayments from "../pay-mode/UpcomingPayments";
+import OverduePayments from "../pay/OverduePayments";
+import PaymentSheet from "../pay/PaymentSheet";
+import UpcomingPayments from "../pay/UpcomingPayments";
import InfoAlert from "../shared/InfoAlert";
import LatestActivity from "../shared/LatestActivity";
import LiquidationAlert from "../shared/LiquidationAlert";
@@ -57,9 +66,14 @@ export default function Home() {
t,
i18n: { language },
} = useTranslation();
- const [spendingLimitsInfoSheetOpen, setSpendingLimitsInfoSheetOpen] = useState(false);
+ const [creditLimitSheetOpen, setCreditLimitSheetOpen] = useState(false);
+ const [installmentsSheetOpen, setInstallmentsSheetOpen] = useState(false);
+ const [payModeSheetOpen, setPayModeSheetOpen] = useState(false);
+ const [spendingLimitSheetOpen, setSpendingLimitSheetOpen] = useState(false);
const [visaSignatureModalOpen, setVisaSignatureModalOpen] = useState(false);
+ const spotlightRef = useRef(null);
+
const { address: account } = useAccount();
const { data: credential } = useQuery({ queryKey: ["credential"] });
const { data: bytecode, refetch: refetchBytecode } = useBytecode({
@@ -73,8 +87,9 @@ export default function Home() {
query: { enabled: !!account && !!credential },
});
const {
- portfolio: { balanceUSD, depositMarkets },
+ portfolio: { balanceUSD },
averageRate,
+ assets,
totalBalanceUSD,
} = usePortfolio(account);
@@ -111,8 +126,22 @@ export default function Home() {
KYCStatus && "code" in KYCStatus && (KYCStatus.code === "ok" || KYCStatus.code === "legacy kyc"),
);
const { data: card } = useQuery({ queryKey: ["card", "details"], enabled: !!account && !!bytecode });
+ const { data: spotlightShown } = useQuery({ queryKey: ["settings", "installments-spotlight"] });
+ const { mutateAsync: mutateMode } = useMutation(cardModeMutationOptions);
+
+ const collateralUSD = useMemo(() => {
+ if (!markets) return 0n;
+ let total = 0n;
+ for (const market of markets) {
+ if (market.floatingDepositAssets > 0n) {
+ total += (market.floatingDepositAssets * market.usdPrice) / 10n ** BigInt(market.decimals);
+ }
+ }
+ return total;
+ }, [markets]);
const scrollRef = useRef(null);
+ const scrollOffsetRef = useRef(0);
const refresh = () => {
refetchActivity().catch(reportError);
refetchBytecode().catch(reportError);
@@ -137,6 +166,10 @@ export default function Home() {
backgroundColor="transparent"
contentContainerStyle={{ backgroundColor: "$backgroundMild" }}
showsVerticalScrollIndicator={false}
+ scrollEventThrottle={16}
+ onScroll={(event) => {
+ scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
+ }}
refreshControl={}
>
@@ -161,11 +194,11 @@ export default function Home() {
}}
/>
)}
-
+
@@ -175,10 +208,29 @@ export default function Home() {
{card && (
{
- setSpendingLimitsInfoSheetOpen(true);
+ collateral={`$${(Number(collateralUSD) / 1e18).toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+ creditLimit={`$${(markets ? Number(borrowLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+ spotlightRef={spotlightRef}
+ mode={card.mode}
+ onCreditLimitInfoPress={() => {
+ setCreditLimitSheetOpen(true);
+ }}
+ onDetailsPress={() => {
+ router.push("/card");
+ }}
+ onInstallmentsPress={() => {
+ setInstallmentsSheetOpen(true);
}}
- productId={card.productId}
+ onLearnMorePress={() => {
+ setPayModeSheetOpen(true);
+ }}
+ onModeChange={(mode: number) => {
+ mutateMode(mode).catch(reportError);
+ }}
+ onSpendingLimitInfoPress={() => {
+ setSpendingLimitSheetOpen(true);
+ }}
+ spendingLimit={`$${(markets ? Number(withdrawLimit(markets, marketUSDCAddress, WAD)) / 1e6 : 0).toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
/>
)}
@@ -194,7 +246,9 @@ export default function Home() {
)}
- {isKYCFetched && isKYCApproved && }
+
+ {isKYCFetched && isKYCApproved && }
+
router.setParams({ maturity: String(m) })} />
router.setParams({ maturity: String(m) })} />
@@ -209,10 +263,32 @@ export default function Home() {
queryClient.resetQueries({ queryKey: ["card-upgrade"] }).catch(reportError);
}}
/>
- {
+ setInstallmentsSheetOpen(false);
+ }}
+ onModeChange={(mode: number) => {
+ mutateMode(mode).catch(reportError);
+ }}
+ />
+ {
+ setCreditLimitSheetOpen(false);
+ }}
+ />
+ {
+ setPayModeSheetOpen(false);
+ }}
+ />
+ {
- setSpendingLimitsInfoSheetOpen(false);
+ setSpendingLimitSheetOpen(false);
}}
/>
+ {card && !spotlightShown && (
+ {
+ queryClient.setQueryData(["settings", "installments-spotlight"], true);
+ }}
+ onPress={() => {
+ setInstallmentsSheetOpen(true);
+ }}
+ />
+ )}
diff --git a/src/components/home/HomeActions.tsx b/src/components/home/HomeActions.tsx
index 59e7073c7..db413b386 100644
--- a/src/components/home/HomeActions.tsx
+++ b/src/components/home/HomeActions.tsx
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { useRouter } from "expo-router";
-import { ArrowDownToLine, ArrowUpRight } from "@tamagui/lucide-icons";
-import { XStack, YStack } from "tamagui";
+import { ArrowDownToLine, ArrowUpRight, Repeat } from "@tamagui/lucide-icons";
+import { XStack } from "tamagui";
import { useQuery } from "@tanstack/react-query";
import { zeroAddress } from "viem";
@@ -33,6 +33,7 @@ export default function HomeActions() {
() => [
{ key: "deposit", title: t("Add funds"), Icon: ArrowDownToLine },
{ key: "send", title: t("Send"), Icon: ArrowUpRight },
+ { key: "swap", title: t("Swap"), Icon: Repeat },
],
[t],
);
@@ -80,33 +81,45 @@ export default function HomeActions() {
}
};
return (
-
+
{actions.map(({ key, title, Icon }) => {
+ const disabled = key !== "deposit" && !bytecode;
+ const handlePress = disabled
+ ? undefined
+ : () => {
+ switch (key) {
+ case "deposit":
+ router.push("/add-funds");
+ break;
+ case "send":
+ handleSend().catch(reportError);
+ break;
+ case "swap":
+ router.push("/swaps");
+ break;
+ }
+ };
return (
-
-
+ {title}
+
);
})}
diff --git a/src/components/home/InstallmentsSheet.tsx b/src/components/home/InstallmentsSheet.tsx
new file mode 100644
index 000000000..e84ec85a3
--- /dev/null
+++ b/src/components/home/InstallmentsSheet.tsx
@@ -0,0 +1,164 @@
+import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Dimensions, Pressable, ScrollView, StyleSheet } from "react-native";
+
+import { selectionAsync } from "expo-haptics";
+import { useRouter } from "expo-router";
+
+import { Check, X } from "@tamagui/lucide-icons";
+import { XStack, YStack } from "tamagui";
+
+import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
+
+import reportError from "../../utils/reportError";
+import useInstallmentRates from "../../utils/useInstallmentRates";
+import Button from "../shared/Button";
+import ModalSheet from "../shared/ModalSheet";
+import SafeView from "../shared/SafeView";
+import Skeleton from "../shared/Skeleton";
+import Text from "../shared/Text";
+
+const CARD_SIZE = 104;
+const GAP = 8;
+const PADDING = 24;
+const INSTALLMENTS = Array.from({ length: MAX_INSTALLMENTS }, (_, index) => index + 1);
+
+export default function InstallmentsSheet({
+ mode,
+ onClose,
+ onModeChange,
+ open,
+}: {
+ mode: number;
+ onClose: () => void;
+ onModeChange: (mode: number) => void;
+ open: boolean;
+}) {
+ const router = useRouter();
+ const {
+ t,
+ i18n: { language },
+ } = useTranslation();
+ const [selected, setSelected] = useState(mode > 0 ? mode : 1);
+ useEffect(() => {
+ if (open) setSelected(mode > 0 ? mode : 1); // eslint-disable-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
+ }, [mode, open]);
+ const rates = useInstallmentRates();
+ const initial = Math.max(mode, 1);
+ const initialX = Math.max(
+ 0,
+ PADDING + (initial - 1) * (CARD_SIZE + GAP) + CARD_SIZE + PADDING - Dimensions.get("window").width,
+ );
+ return (
+
+
+
+
+
+
+
+ {t("Set installments")}
+
+
+
+
+
+
+ {t(
+ "Choose how many installments to use for future card purchases. You can always change this before each purchase.",
+ )}
+
+
+
+ {INSTALLMENTS.map((installment) => {
+ const isSelected = selected === installment;
+ return (
+ {
+ setSelected(installment);
+ selectionAsync().catch(reportError);
+ }}
+ >
+
+ {installment}
+
+ {rates?.[installment - 1] === undefined ? (
+
+ ) : (
+
+ {t("{{apr}} APR", {
+ apr: (Number(rates[installment - 1]) / 1e18).toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
+ })}
+
+ )}
+
+ );
+ })}
+
+
+
+ {
+ if (selected !== mode) onModeChange(selected);
+ onClose();
+ }}
+ contained
+ main
+ spaced
+ fullwidth
+ iconAfter={}
+ >
+ {t("Set Pay Later in {{count}}", { count: selected })}
+
+ {
+ onClose();
+ router.push("/calculator");
+ }}
+ >
+
+ {t("Installments calculator")}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scrollContent: { gap: GAP, paddingHorizontal: PADDING },
+});
diff --git a/src/components/home/InstallmentsSpotlight.tsx b/src/components/home/InstallmentsSpotlight.tsx
new file mode 100644
index 000000000..987a9c96f
--- /dev/null
+++ b/src/components/home/InstallmentsSpotlight.tsx
@@ -0,0 +1,137 @@
+import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Modal, Pressable, StyleSheet, useWindowDimensions, type View as RNView } from "react-native";
+import SVG, { Defs, Mask, Rect } from "react-native-svg";
+
+import { Theme, View, YStack, type ScrollView } from "tamagui";
+
+import Text from "../shared/Text";
+
+export default function InstallmentsSpotlight({
+ onDismiss,
+ onPress,
+ scrollOffset,
+ scrollRef,
+ targetRef,
+}: {
+ onDismiss: () => void;
+ onPress: () => void;
+ scrollOffset: React.RefObject;
+ scrollRef: React.RefObject;
+ targetRef: React.RefObject;
+}) {
+ const { t } = useTranslation();
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
+ const [target, setTarget] = useState<{ height: number; width: number; x: number; y: number }>();
+ useEffect(() => {
+ let remeasureTimer: ReturnType;
+ const timer = setTimeout(() => {
+ targetRef.current?.measureInWindow((x, y, width, height) => {
+ if (width > 0 && height > 0 && y >= 0 && y + height <= screenHeight) {
+ setTarget({ x, y, width, height });
+ return;
+ }
+ if (width > 0 && height > 0) {
+ const contentY = scrollOffset.current + y;
+ scrollRef.current?.scrollTo({ y: Math.max(0, contentY - screenHeight / 3), animated: true });
+ } else {
+ scrollRef.current?.scrollTo({ y: 0, animated: true });
+ }
+ remeasureTimer = setTimeout(() => {
+ targetRef.current?.measureInWindow((x2, y2, w2, h2) => {
+ if (w2 > 0 && h2 > 0) setTarget({ x: x2, y: y2, width: w2, height: h2 });
+ });
+ }, 400);
+ });
+ }, 600);
+ return () => {
+ clearTimeout(timer);
+ clearTimeout(remeasureTimer);
+ };
+ }, [screenHeight, scrollOffset, scrollRef, targetRef]);
+ if (!target) return null;
+ const cutout = {
+ x: target.x - 8,
+ y: target.y - 8,
+ width: target.width + 16,
+ height: target.height + 16,
+ };
+ const cutoutRadius = cutout.height / 2;
+ const tooltipTop = cutout.y + cutout.height + 12;
+ const tooltipLeft = Math.max(16, Math.min(cutout.x + cutout.width / 2 - 100, screenWidth - 216));
+ const arrowLeft = cutout.x + cutout.width / 2 - tooltipLeft - 6;
+ return (
+
+
+
+
+ {
+ onPress();
+ onDismiss();
+ }}
+ />
+
+
+
+
+ {t("Tap here to change the number of installments")}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({ cutoutPress: { position: "absolute" } });
diff --git a/src/components/home/PayModeSheet.tsx b/src/components/home/PayModeSheet.tsx
new file mode 100644
index 000000000..d6df27bae
--- /dev/null
+++ b/src/components/home/PayModeSheet.tsx
@@ -0,0 +1,127 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Pressable } from "react-native";
+
+import { CalendarDays, ExternalLink, X, Zap } from "@tamagui/lucide-icons";
+import { XStack, YStack } from "tamagui";
+
+import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
+
+import { presentArticle } from "../../utils/intercom";
+import reportError from "../../utils/reportError";
+import Button from "../shared/Button";
+import ModalSheet from "../shared/ModalSheet";
+import Text from "../shared/Text";
+
+export default function PayModeSheet({ onClose, open }: { onClose: () => void; open: boolean }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t("Exa Card pay mode")}
+
+
+
+
+
+
+ {t("Change the pay mode before each purchase and pay how you want.")}
+
+
+
+
+
+
+
+ {t("Now")}
+
+
+
+ {t("Pay instantly using your available USDC.")}
+
+
+
+
+
+
+ {t("Later")}
+
+
+
+ {t(
+ "Pay without selling your crypto. Use it as collateral to unlock a credit limit and split purchases into up to {{max}} installments.",
+ { max: MAX_INSTALLMENTS },
+ )}
+
+
+
+
+
+ {
+ presentArticle("9465994").catch(reportError);
+ }}
+ contained
+ main
+ spaced
+ fullwidth
+ iconAfter={}
+ >
+ {t("Learn more")}
+
+
+
+ {t("Close")}
+
+
+
+
+
+ );
+}
diff --git a/src/components/home/Portfolio.tsx b/src/components/home/Portfolio.tsx
index 9257e2991..089dfd0a3 100644
--- a/src/components/home/Portfolio.tsx
+++ b/src/components/home/Portfolio.tsx
@@ -41,7 +41,14 @@ export default function Portfolio() {
return (
-
+
{
if (router.canGoBack()) {
@@ -87,7 +94,6 @@ export default function Portfolio() {
{
- router.push("/portfolio");
- }}
- >
-
-
- {t("Your portfolio")}
+
+
+
+ {t("Portfolio")}
-
-
-
- {`$${(Number(totalBalanceUSD) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
-
- {processingBalance ? (
{
- router.push("/activity");
+ router.push("/portfolio");
}}
- gap="$s2"
- alignItems="center"
>
-
- {t("Processing balance {{amount}}", {
- amount: `$${processingBalance.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
- })}
+
+ {t("Manage portfolio")}
-
+
- ) : balanceUSD > 0n ? (
-
- ) : null}
+
+
+
+
+ {processingBalance ? (
+ {
+ router.push("/activity");
+ }}
+ gap="$s2"
+ alignItems="center"
+ >
+
+ {t("Processing balance {{amount}}", {
+ amount: `$${processingBalance.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
+ })}
+
+
+
+ ) : balanceUSD > 0n ? (
+
+ {t("{{rate}} APR", {
+ rate: (Number(averageRate) / 1e18).toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }),
+ })}
+
+ ) : null}
+
+ {visible.length > 0 && (
+
+ {visible.map((asset, index) => (
+ 0 ? -12 : 0}
+ zIndex={visible.length - index}
+ >
+
+
+ ))}
+ {extra > 0 && (
+
+
+ +{extra}
+
+
+ )}
+
+ )}
+
);
}
diff --git a/src/components/home/SpendingLimitSheet.tsx b/src/components/home/SpendingLimitSheet.tsx
new file mode 100644
index 000000000..a9fcbdef1
--- /dev/null
+++ b/src/components/home/SpendingLimitSheet.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { Pressable } from "react-native";
+
+import { ExternalLink, X } from "@tamagui/lucide-icons";
+import { XStack, YStack } from "tamagui";
+
+import { presentArticle } from "../../utils/intercom";
+import reportError from "../../utils/reportError";
+import Button from "../shared/Button";
+import ModalSheet from "../shared/ModalSheet";
+import Text from "../shared/Text";
+
+export default function SpendingLimitSheet({ onClose, open }: { onClose: () => void; open: boolean }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t("Spending limit")}
+
+
+
+
+
+
+
+ }}
+ />
+
+
+ {t("It's based on the USDC available in your balance.")}
+
+
+
+
+
+ {
+ presentArticle("9922633").catch(reportError);
+ }}
+ contained
+ main
+ spaced
+ fullwidth
+ iconAfter={}
+ >
+ {t("Learn more")}
+
+
+
+ {t("Close")}
+
+
+
+
+
+ );
+}
diff --git a/src/components/home/SpendingLimitsSheet.tsx b/src/components/home/SpendingLimitsSheet.tsx
deleted file mode 100644
index 13348b2c6..000000000
--- a/src/components/home/SpendingLimitsSheet.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { Pressable } from "react-native";
-
-import { X } from "@tamagui/lucide-icons";
-import { ScrollView, XStack, YStack } from "tamagui";
-
-import { presentArticle } from "../../utils/intercom";
-import reportError from "../../utils/reportError";
-import Button from "../shared/Button";
-import ModalSheet from "../shared/ModalSheet";
-import SafeView from "../shared/SafeView";
-import Text from "../shared/Text";
-import View from "../shared/View";
-
-export default function SpendingLimitsSheet({ open, onClose }: { onClose: () => void; open: boolean }) {
- const { t } = useTranslation();
- return (
-
-
-
-
-
-
- {t("Spending limit")}
-
-
- {t("Your spending limit is the maximum amount you can spend on your Exa Card.")}
-
-
-
-
-
-
- {t("WHEN")}
-
-
-
- {t("PAY NOW")}
-
-
-
- {t("IS ENABLED")}
-
-
- {t("Only your USDC balance counts toward your spending limit.")}
-
-
-
-
- {t("WHEN")}
-
-
-
- {t("INSTALLMENTS")}
-
-
-
- {t("IS ENABLED")}
-
-
- {t("All supported assets count toward your spending limit.")}
-
-
-
- }
- >
- {t("Close")}
-
- {
- presentArticle("9922633").catch(reportError);
- }}
- >
-
- {t("Learn more about your spending limit")}
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/loans/Amount.tsx b/src/components/loans/Amount.tsx
index 4b5c44bec..cef586c02 100644
--- a/src/components/loans/Amount.tsx
+++ b/src/components/loans/Amount.tsx
@@ -60,7 +60,14 @@ export default function Amount() {
}, []);
return (
-
+
{
queryClient.setQueryData(["loan"], (old: Loan) => ({ ...old, amount: null }));
diff --git a/src/components/loans/Asset.tsx b/src/components/loans/Asset.tsx
index 14b2741bf..e7fcf0714 100644
--- a/src/components/loans/Asset.tsx
+++ b/src/components/loans/Asset.tsx
@@ -30,7 +30,14 @@ export default function Asset() {
const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, args: [address ?? zeroAddress] });
return (
-
+
{
if (router.canGoBack()) {
@@ -89,7 +96,7 @@ export default function Asset() {
height={16}
backgroundColor={selected ? "$interactiveBaseBrandDefault" : "$uiNeutralSecondary"}
borderRadius="$r_0"
- padding={4}
+ padding="$s2"
alignItems="center"
justifyContent="center"
>
diff --git a/src/components/loans/Installments.tsx b/src/components/loans/Installments.tsx
index 73cdde9e3..43b7d5096 100644
--- a/src/components/loans/Installments.tsx
+++ b/src/components/loans/Installments.tsx
@@ -38,7 +38,14 @@ export default function Installments() {
}, []);
return (
-
+
{
if (router.canGoBack()) {
diff --git a/src/components/loans/Loans.tsx b/src/components/loans/Loans.tsx
index 7ba224d7d..13aec47a1 100644
--- a/src/components/loans/Loans.tsx
+++ b/src/components/loans/Loans.tsx
@@ -17,8 +17,8 @@ import { presentArticle } from "../../utils/intercom";
import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import useAsset from "../../utils/useAsset";
-import PaymentSheet from "../pay-mode/PaymentSheet";
-import UpcomingPayments from "../pay-mode/UpcomingPayments";
+import PaymentSheet from "../pay/PaymentSheet";
+import UpcomingPayments from "../pay/UpcomingPayments";
import SafeView from "../shared/SafeView";
import Text from "../shared/Text";
import View from "../shared/View";
diff --git a/src/components/loans/Maturity.tsx b/src/components/loans/Maturity.tsx
index 273a0a346..66429dfb3 100644
--- a/src/components/loans/Maturity.tsx
+++ b/src/components/loans/Maturity.tsx
@@ -46,7 +46,14 @@ export default function Maturity() {
}, []);
return (
-
+
{
queryClient.setQueryData(["loan"], (old: Loan) => ({ ...old, maturity: undefined }));
diff --git a/src/components/loans/Receiver.tsx b/src/components/loans/Receiver.tsx
index b5e876e38..20f66e478 100644
--- a/src/components/loans/Receiver.tsx
+++ b/src/components/loans/Receiver.tsx
@@ -70,7 +70,14 @@ export default function Receiver() {
}, []);
return (
-
+
{
queryClient.setQueryData(["loan"], (old: Loan) => ({ ...old, receiver: undefined }));
@@ -128,7 +135,7 @@ export default function Receiver() {
>
{receiverType === "internal" && }
-
+
{t("Your Exa account")}
{t("Deposit {{symbol}} into your Exa App wallet", { symbol })}
@@ -159,7 +166,7 @@ export default function Receiver() {
>
{receiverType === "external" && }
-
+
{t("External address on {{chain}}", { chain: chain.name })}
{t("Deposit {{symbol}} directly to an external wallet", { symbol })}
diff --git a/src/components/loans/Review.tsx b/src/components/loans/Review.tsx
index 76a7d2627..9c47028ab 100644
--- a/src/components/loans/Review.tsx
+++ b/src/components/loans/Review.tsx
@@ -199,7 +199,7 @@ export default function Review() {
void }) {
- const {
- t,
- i18n: { language },
- } = useTranslation();
- const { address } = useAccount();
- const { data: bytecode } = useBytecode({ address: address ?? zeroAddress, query: { enabled: !!address } });
- const { data: pendingProposals } = useReadExaPreviewerPendingProposals({
- address: exaPreviewerAddress,
- args: [address ?? zeroAddress],
- query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 },
- });
- const { data: markets } = useReadPreviewerExactly({
- address: previewerAddress,
- args: [address ?? zeroAddress],
- query: { enabled: !!address && !!bytecode, refetchInterval: 30_000 },
- });
- const overduePayments = new Map();
- if (markets) {
- for (const { fixedBorrowPositions } of markets) {
- for (const { maturity, previewValue, position } of fixedBorrowPositions) {
- if (!previewValue) continue;
- const positionAmount = position.principal + position.fee;
- if (previewValue === 0n) continue;
- if (isBefore(new Date(Number(maturity) * 1000), new Date())) {
- overduePayments.set(maturity, {
- amount: (overduePayments.get(maturity)?.amount ?? 0n) + previewValue,
- discount: Number(WAD - (previewValue * WAD) / positionAmount) / 1e18,
- });
- }
- }
- }
- }
- const payments = [...overduePayments];
- if (payments.length === 0) return null;
- return (
-
-
-
- {t("Overdue payments")}
-
-
-
- {payments.map(([maturity, { amount, discount }]) => {
- const isRepaying = pendingProposals?.some(({ proposal }) => {
- const { proposalType: type, data } = proposal;
- const isRepayProposal =
- type === (ProposalType.RepayAtMaturity as number) ||
- type === (ProposalType.CrossRepayAtMaturity as number);
- if (!isRepayProposal) return false;
- const decoded =
- type === (ProposalType.RepayAtMaturity as number)
- ? decodeRepayAtMaturity(data)
- : decodeCrossRepayAtMaturity(data);
- return decoded.maturity === maturity;
- });
- const isRollingDebt = pendingProposals?.some(({ proposal }) => {
- const { proposalType: type, data } = proposal;
- if (type !== (ProposalType.RollDebt as number)) return false;
- const decoded = decodeRollDebt(data);
- return decoded.repayMaturity === maturity;
- });
- const processing = isRepaying || isRollingDebt; //eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
- return (
- {
- if (processing) return;
- onSelect(maturity);
- }}
- >
-
-
-
-
-
- {(Number(amount) / 1e6).toLocaleString(language, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
-
- {new Date(Number(maturity) * 1000).toLocaleDateString(language, {
- year: "numeric",
- month: "short",
- day: "numeric",
- })}
-
-
- {processing ? (
-
-
- {t("Processing")}
-
-
- ) : null}
-
-
- {processing ? null : (
-
-
- {t("Penalties {{percent}}", {
- percent: Math.abs(discount).toLocaleString(language, {
- style: "percent",
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }),
- })}
-
-
- )}
-
- {t("Repay")}
-
-
-
-
- );
- })}
-
-
- );
-}
diff --git a/src/components/pay-mode/PayMode.tsx b/src/components/pay-mode/PayMode.tsx
deleted file mode 100644
index 1cc702702..000000000
--- a/src/components/pay-mode/PayMode.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import React, { useRef } from "react";
-import { Trans } from "react-i18next";
-import { RefreshControl } from "react-native";
-
-import { useRouter } from "expo-router";
-
-import { ScrollView, XStack } from "tamagui";
-
-import { zeroAddress } from "viem";
-
-import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain";
-import { useReadPreviewerExactly } from "@exactly/common/generated/hooks";
-
-import OverduePayments from "./OverduePayments";
-import PaymentSheet from "./PaymentSheet";
-import PaySelector from "./PaySelector";
-import UpcomingPayments from "./UpcomingPayments";
-import { presentCollection } from "../../utils/intercom";
-import openBrowser from "../../utils/openBrowser";
-import queryClient from "../../utils/queryClient";
-import reportError from "../../utils/reportError";
-import useAsset from "../../utils/useAsset";
-import useTabPress from "../../utils/useTabPress";
-import SafeView from "../shared/SafeView";
-import Text from "../shared/Text";
-import View from "../shared/View";
-
-export default function PayMode() {
- const { account } = useAsset(marketUSDCAddress);
- const router = useRouter();
- const { refetch, isPending } = useReadPreviewerExactly({ address: previewerAddress, args: [account ?? zeroAddress] });
-
- const scrollRef = useRef(null);
- const refresh = () => {
- refetch().catch(reportError);
- queryClient.refetchQueries({ queryKey: ["activity"] }).catch(reportError);
- };
- useTabPress("pay-mode", () => {
- scrollRef.current?.scrollTo({ y: 0, animated: true });
- refresh();
- });
-
- return (
-
-
-
- }
- >
- <>
-
-
- router.setParams({ maturity: String(m) })} />
- router.setParams({ maturity: String(m) })} />
-
-
- {
- openBrowser("https://exact.ly/").catch(reportError);
- }}
- />
- ),
- terms: (
- {
- presentCollection("10544608").catch(reportError);
- }}
- />
- ),
- }}
- />
-
-
-
-
- >
-
-
-
- );
-}
diff --git a/src/components/pay-mode/PaySelector.tsx b/src/components/pay-mode/PaySelector.tsx
deleted file mode 100644
index a5ab43a40..000000000
--- a/src/components/pay-mode/PaySelector.tsx
+++ /dev/null
@@ -1,400 +0,0 @@
-import React, { useMemo, useState } from "react";
-import { Trans, useTranslation } from "react-i18next";
-import { Pressable, StyleSheet } from "react-native";
-
-import { Check, CircleHelp } from "@tamagui/lucide-icons";
-import { useToastController } from "@tamagui/toast";
-import { XStack, YStack } from "tamagui";
-
-import { useMutation, useQuery } from "@tanstack/react-query";
-import { formatUnits, parseUnits, zeroAddress } from "viem";
-
-import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain";
-import { useReadPreviewerExactly, useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks";
-import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
-import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib";
-
-import ManualRepaymentSheet from "./ManualRepaymentSheet";
-import { presentArticle } from "../../utils/intercom";
-import queryClient from "../../utils/queryClient";
-import reportError from "../../utils/reportError";
-import { setCardMode, type CardDetails } from "../../utils/server";
-import useAccount from "../../utils/useAccount";
-import useAsset from "../../utils/useAsset";
-import useInstallments from "../../utils/useInstallments";
-import AssetLogo from "../shared/AssetLogo";
-import Input from "../shared/Input";
-import Skeleton from "../shared/Skeleton";
-import Text from "../shared/Text";
-import View from "../shared/View";
-
-export default function PaySelector() {
- const toast = useToastController();
- const {
- t,
- i18n: { language },
- } = useTranslation();
- const [input, setInput] = useState("100");
- const assets = useMemo(() => {
- return parseUnits(input.replaceAll(/\D/g, ".").replaceAll(/\.(?=.*\.)/g, ""), 6);
- }, [input]);
- const { address } = useAccount();
- const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, args: [address ?? zeroAddress] });
- const exaUSDC = markets?.find(({ market }) => market === marketUSDCAddress);
- const { firstMaturity } = useInstallments({
- totalAmount: assets,
- installments: 1,
- });
-
- const { data: manualRepaymentAcknowledged } = useQuery({ queryKey: ["manual-repayment-acknowledged"] });
- const [manualRepaymentSheetOpen, setManualRepaymentSheetOpen] = useState(false);
- const [pendingInstallment, setPendingInstallment] = useState(null);
-
- const { data: card } = useQuery({ queryKey: ["card", "details"] });
- const { mutateAsync: mutateMode } = useMutation({
- mutationKey: ["card", "mode"],
- mutationFn: setCardMode,
- onMutate: async (newMode) => {
- await queryClient.cancelQueries({ queryKey: ["card", "details"] });
- const previous = queryClient.getQueryData(["card", "details"]);
- queryClient.setQueryData(["card", "details"], (old: CardDetails) => ({ ...old, mode: newMode }));
- return { previous };
- },
- onError: (error, _, context) => {
- if (context?.previous) {
- queryClient.setQueryData(["card", "details"], context.previous);
- }
- reportError(error);
- },
- onSettled: async (data) => {
- await queryClient.invalidateQueries({ queryKey: ["card", "details"] });
- if (data && "mode" in data && data.mode > 0) {
- queryClient.setQueryData(["settings", "installments"], data.mode);
- }
- },
- });
-
- function setInstallments(value: number) {
- if (!card || card.mode === value) return;
- mutateMode(value).catch(reportError);
- const message = value === 0 ? t("Pay Now selected") : t("Installments selected", { count: value });
- toast.show(message, {
- native: true,
- duration: 1000,
- burntOptions: { haptic: "success" },
- });
- }
-
- function handleInstallmentSelection(value: number) {
- if (value === 0) {
- setInstallments(value);
- return;
- }
- if (!manualRepaymentAcknowledged) {
- setPendingInstallment(value);
- setManualRepaymentSheetOpen(true);
- return;
- }
- setInstallments(value);
- }
-
- function handleConfirm() {
- queryClient.setQueryData(["manual-repayment-acknowledged"], true);
- if (pendingInstallment !== null) {
- setInstallments(pendingInstallment);
- }
- setPendingInstallment(null);
- setManualRepaymentSheetOpen(false);
- }
-
- return (
- <>
-
-
-
-
- {t("Pay Mode")}
-
- {
- presentArticle("9465994").catch(reportError);
- }}
- >
-
-
-
-
- }}
- />
-
-
-
- {t("Simulate a purchase of")}
-
-
-
- USDC
-
-
-
-
-
-
-
-
-
- {t("INSTANT PAY ({{asset}})", { asset: "USDC" })}
-
-
-
- {t("Available limit: {{asset}}", { asset: "USDC" })}
-
-
- {(markets ? Number(withdrawLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
-
-
-
-
-
-
- {t("INSTALLMENT PLANS")}
-
-
-
- {t("Credit limit: {{asset}}", { asset: "USDC" })}
-
-
- {(markets ? Number(borrowLimit(markets, marketUSDCAddress)) / 1e6 : 0).toLocaleString(language, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
-
-
- {Array.from({ length: MAX_INSTALLMENTS }, (_, index) => index + 1).map((installment) => (
-
- ))}
-
-
- {t("First due date: {{date}} - then every 28 days.", {
- date: new Date(firstMaturity * 1000).toLocaleDateString(language, {
- year: "numeric",
- month: "short",
- day: "numeric",
- }),
- })}
-
-
-
- {
- setManualRepaymentSheetOpen(false);
- }}
- onActionPress={handleConfirm}
- penaltyRate={exaUSDC?.penaltyRate}
- />
-
- >
- );
-}
-
-function InstallmentButton({
- installment,
- cardDetails,
- onSelect,
- assets,
-}: {
- assets: bigint;
- cardDetails?: null | { mode: number };
- installment: number;
- onSelect: (installment: number) => void;
-}) {
- const {
- t,
- i18n: { language },
- } = useTranslation();
- const { market, account } = useAsset(marketUSDCAddress);
- const calculationAssets = assets === 0n ? 100_000_000n : assets;
- const {
- data: installments,
- firstMaturity,
- timestamp,
- isFetching: isInstallmentsFetching,
- } = useInstallments({
- totalAmount: calculationAssets,
- installments: installment,
- });
- const { data: borrowPreview, isLoading: isBorrowPreviewLoading } = useReadPreviewerPreviewBorrowAtMaturity({
- address: previewerAddress,
- args: [market?.market ?? zeroAddress, BigInt(firstMaturity), calculationAssets],
- query: { enabled: !!market && !!account && !!firstMaturity && calculationAssets > 0n },
- });
-
- const selected = cardDetails?.mode === installment;
-
- const apr =
- installment > 0
- ? installment > 1 && installments
- ? Number(installments.effectiveRate) / 1e18
- : borrowPreview
- ? Number(
- ((borrowPreview.assets - calculationAssets) * WAD * 31_536_000n) /
- (calculationAssets * (borrowPreview.maturity - BigInt(timestamp))),
- ) / 1e18
- : 0
- : 0;
-
- return (
- {
- onSelect(installment);
- }}
- >
-
-
- {selected && }
-
-
-
- 0 ? "$uiNeutralSecondary" : "$uiNeutralPrimary"}>
- {installment > 0 ? `${installment}x` : t("Pay Now")}
-
- {installment > 0 && }
-
- {installment > 0 &&
- (isInstallmentsFetching || (installment === 1 && isBorrowPreviewLoading) ? (
-
- ) : (
-
- {Number(
- formatUnits(
- assets
- ? installments && installment > 1
- ? (installments.installments[0] ?? 0n)
- : (borrowPreview?.assets ?? 0n)
- : 0n,
- 6,
- ),
- ).toLocaleString(language, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
-
- ))}
-
- {installment > 0 &&
- (isInstallmentsFetching || isBorrowPreviewLoading ? (
-
- ) : (
-
- {t("{{apr}} APR", {
- apr: apr.toLocaleString(language, {
- style: "percent",
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }),
- })}
-
- ))}
-
-
-
- USDC
-
-
- {isInstallmentsFetching || (installment === 1 && isBorrowPreviewLoading) ? (
-
- ) : (
- (assets === 0n
- ? Number(assets)
- : Number(
- formatUnits(
- installment === 0
- ? assets
- : installments && installment > 1
- ? installments.installments.reduce((accumulator, current) => accumulator + current, 0n)
- : installment === 1 && borrowPreview
- ? borrowPreview.assets
- : 0n,
- 6,
- ),
- )
- ).toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
- )}
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({ button: { flexGrow: 1 } });
diff --git a/src/components/pay-mode/PaymentsActions.tsx b/src/components/pay-mode/PaymentsActions.tsx
deleted file mode 100644
index 03d5b01eb..000000000
--- a/src/components/pay-mode/PaymentsActions.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { Pressable } from "react-native";
-
-import { CircleDollarSign, Coins } from "@tamagui/lucide-icons";
-import { styled, Switch } from "tamagui";
-
-import Text from "../shared/Text";
-import View from "../shared/View";
-
-const StyledAction = styled(View, {
- flex: 1,
- minHeight: 140,
- borderWidth: 1,
- padding: 16,
- borderRadius: 10,
- backgroundColor: "$backgroundSoft",
- borderColor: "$borderNeutralSoft",
- justifyContent: "space-between",
- flexBasis: "50%",
-});
-
-export default function PaymentsActions() {
- const { t } = useTranslation();
- return (
-
-
-
-
-
- {t("Auto-pay")}
-
-
-
-
-
-
-
-
-
-
-
- {t("Collateral")}
-
-
- {t("Manage")}
-
-
-
-
-
- );
-}
diff --git a/src/components/pay-mode/AssetSelectionSheet.tsx b/src/components/pay/AssetSelectionSheet.tsx
similarity index 100%
rename from src/components/pay-mode/AssetSelectionSheet.tsx
rename to src/components/pay/AssetSelectionSheet.tsx
diff --git a/src/components/pay-mode/Empty.tsx b/src/components/pay/Empty.tsx
similarity index 78%
rename from src/components/pay-mode/Empty.tsx
rename to src/components/pay/Empty.tsx
index 8029f9c58..4668c592b 100644
--- a/src/components/pay-mode/Empty.tsx
+++ b/src/components/pay/Empty.tsx
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native";
import { YStack } from "tamagui";
-import Blob from "../../assets/images/exa-card-blob.svg";
+import ExaCardBlob from "../../assets/images/exa-card-blob.svg";
import ExaCard from "../../assets/images/exa-card.svg";
import Text from "../shared/Text";
import View from "../shared/View";
@@ -15,8 +15,8 @@ export default function Empty() {
-
-
+
+
@@ -27,7 +27,7 @@ export default function Empty() {
{t("No payments pending")}
- {t("You're all caught up! Start using your card to see payments listed here.")}
+ {t("You're all caught up! Start using your card in Pay Later mode to see payments listed here.")}
diff --git a/src/components/pay/InstallmentsCalculator.tsx b/src/components/pay/InstallmentsCalculator.tsx
new file mode 100644
index 000000000..4063109ab
--- /dev/null
+++ b/src/components/pay/InstallmentsCalculator.tsx
@@ -0,0 +1,288 @@
+import React, { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Pressable } from "react-native";
+
+import { useRouter } from "expo-router";
+
+import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons";
+import { ScrollView, XStack, YStack } from "tamagui";
+
+import { formatUnits, parseUnits } from "viem";
+
+import { marketUSDCAddress } from "@exactly/common/generated/chain";
+import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
+import MIN_BORROW_INTERVAL from "@exactly/common/MIN_BORROW_INTERVAL";
+import {
+ fixedRate,
+ fixedUtilization,
+ globalUtilization,
+ MATURITY_INTERVAL,
+ splitInstallments,
+ WAD,
+} from "@exactly/lib";
+
+import { presentArticle } from "../../utils/intercom";
+import reportError from "../../utils/reportError";
+import useAsset from "../../utils/useAsset";
+import useInstallmentRates from "../../utils/useInstallmentRates";
+import Input from "../shared/Input";
+import SafeView from "../shared/SafeView";
+import Skeleton from "../shared/Skeleton";
+import Text from "../shared/Text";
+import View from "../shared/View";
+
+const INSTALLMENTS = Array.from({ length: MAX_INSTALLMENTS }, (_, index) => index + 1);
+
+export default function InstallmentsCalculator() {
+ const router = useRouter();
+ const {
+ t,
+ i18n: { language },
+ } = useTranslation();
+ const [input, setInput] = useState("100");
+ const assets = useMemo(() => parseUnits(input.replaceAll(/\D/g, ".").replaceAll(/\.(?=.*\.)/g, ""), 6), [input]);
+ const { market } = useAsset(marketUSDCAddress);
+ const rates = useInstallmentRates();
+ const installmentData = useMemo(() => {
+ if (!market) return;
+ const calculationAssets = assets > 0n ? assets : 100_000_000n;
+ const timestamp = Math.floor(Date.now() / 1000);
+ const nextMaturity = timestamp - (timestamp % MATURITY_INTERVAL) + MATURITY_INTERVAL;
+ const firstMaturity =
+ nextMaturity - timestamp < MIN_BORROW_INTERVAL ? nextMaturity + MATURITY_INTERVAL : nextMaturity;
+ const {
+ fixedPools,
+ floatingBackupBorrowed,
+ floatingUtilization,
+ interestRateModel: { parameters },
+ totalFloatingBorrowAssets,
+ totalFloatingDepositAssets,
+ } = market;
+ const uGlobal = globalUtilization(totalFloatingDepositAssets, totalFloatingBorrowAssets, floatingBackupBorrowed);
+ const borrowImpact =
+ totalFloatingDepositAssets > 0n ? (calculationAssets * WAD - 1n) / totalFloatingDepositAssets + 1n : 0n;
+ try {
+ const result = INSTALLMENTS.map((count) => {
+ const uFixed = fixedPools
+ .filter(({ maturity }) => maturity >= firstMaturity && maturity < firstMaturity + count * MATURITY_INTERVAL)
+ .map(({ supplied, borrowed }) => fixedUtilization(supplied, borrowed, totalFloatingDepositAssets));
+ if (uFixed.length === 0) return { count, installments: undefined, totalAmount: 0n };
+ if (count === 1) {
+ const rate = fixedRate(
+ firstMaturity,
+ fixedPools.length,
+ (uFixed[0] ?? 0n) + borrowImpact,
+ floatingUtilization,
+ uGlobal + borrowImpact,
+ parameters,
+ timestamp,
+ );
+ const time = BigInt(firstMaturity - timestamp);
+ const fee = (calculationAssets * rate * time) / (WAD * 31_536_000n);
+ const total = calculationAssets + fee;
+ return { count, installments: [total], totalAmount: total };
+ }
+ const { installments } = splitInstallments(
+ calculationAssets,
+ totalFloatingDepositAssets,
+ firstMaturity,
+ fixedPools.length,
+ uFixed,
+ floatingUtilization,
+ uGlobal,
+ parameters,
+ timestamp,
+ );
+ return { count, installments, totalAmount: installments.reduce((a, b) => a + b, 0n) };
+ });
+ return { result, firstMaturity };
+ } catch (error) {
+ reportError(error);
+ }
+ }, [market, assets]);
+
+ const bestAprIndex = useMemo(() => {
+ if (!rates) return;
+ let minIndex = 0;
+ for (let index = 1; index < rates.length; index++) {
+ if ((rates[index] ?? 0n) < (rates[minIndex] ?? 0n)) minIndex = index;
+ }
+ return minIndex;
+ }, [rates]);
+
+ return (
+
+
+
+
+
+ {
+ if (router.canGoBack()) router.back();
+ }}
+ >
+
+
+
+ {t("Installments calculator")}
+
+ {
+ presentArticle("11541409").catch(reportError);
+ }}
+ >
+
+
+
+
+
+
+
+ {t("Enter a purchase amount")}
+
+
+ {t("to estimate installments cost")}
+
+
+
+
+ $
+
+
+
+
+
+
+
+ {INSTALLMENTS.map((count) => {
+ const data = installmentData?.result[count - 1];
+ const isBestAPR = bestAprIndex === count - 1;
+ return (
+
+
+ {data?.installments ? (
+
+
+ {count}x{" "}
+
+ $
+ {Number(formatUnits(data.installments[0] ?? 0n, 6)).toLocaleString(language, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+ ) : (
+
+ )}
+
+ {rates?.[count - 1] === undefined ? (
+
+ ) : (
+ <>
+
+ {t("{{apr}} APR", {
+ apr: (Number(rates[count - 1]) / 1e18).toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
+ })}
+
+ {isBestAPR && (
+
+
+ {t("BEST APR")}
+
+
+ )}
+ >
+ )}
+
+
+ {data?.installments ? (
+
+ $
+ {Number(formatUnits(data.totalAmount, 6)).toLocaleString(language, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ {installmentData && (
+
+ {t("First due date: {{date}} - then every 28 days.", {
+ date: new Date(installmentData.firstMaturity * 1000).toLocaleDateString(language, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }),
+ })}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/pay-mode/ManualRepaymentSheet.tsx b/src/components/pay/ManualRepaymentSheet.tsx
similarity index 100%
rename from src/components/pay-mode/ManualRepaymentSheet.tsx
rename to src/components/pay/ManualRepaymentSheet.tsx
diff --git a/src/components/pay/OverduePayments.tsx b/src/components/pay/OverduePayments.tsx
new file mode 100644
index 000000000..c69cbd46c
--- /dev/null
+++ b/src/components/pay/OverduePayments.tsx
@@ -0,0 +1,156 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+import { selectionAsync } from "expo-haptics";
+
+import { ChevronRight } from "@tamagui/lucide-icons";
+import { Separator, XStack, YStack } from "tamagui";
+
+import { isBefore } from "date-fns";
+import { zeroAddress } from "viem";
+import { useBytecode } from "wagmi";
+
+import { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain";
+import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly } from "@exactly/common/generated/hooks";
+import ProposalType, {
+ decodeCrossRepayAtMaturity,
+ decodeRepayAtMaturity,
+ decodeRollDebt,
+} from "@exactly/common/ProposalType";
+import { WAD } from "@exactly/lib";
+
+import reportError from "../../utils/reportError";
+import useAccount from "../../utils/useAccount";
+import Text from "../shared/Text";
+import View from "../shared/View";
+
+export default function OverduePayments({
+ excludeMaturity,
+ onSelect,
+}: {
+ excludeMaturity?: bigint;
+ onSelect: (maturity: bigint) => void;
+}) {
+ const {
+ t,
+ i18n: { language },
+ } = useTranslation();
+ const { address } = useAccount();
+ const { data: bytecode } = useBytecode({ address: address ?? zeroAddress, query: { enabled: !!address } });
+ const { data: pendingProposals } = useReadExaPreviewerPendingProposals({
+ address: exaPreviewerAddress,
+ args: [address ?? zeroAddress],
+ query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 },
+ });
+ const { data: markets } = useReadPreviewerExactly({
+ address: previewerAddress,
+ args: [address ?? zeroAddress],
+ query: { enabled: !!address && !!bytecode, refetchInterval: 30_000 },
+ });
+ const exaUSDC = markets?.find(({ market }) => market === marketUSDCAddress);
+ const overduePayments = new Map();
+ if (markets) {
+ for (const { fixedBorrowPositions } of markets) {
+ for (const { maturity, previewValue, position } of fixedBorrowPositions) {
+ if (!previewValue) continue;
+ const positionAmount = position.principal + position.fee;
+ if (previewValue === 0n) continue;
+ if (maturity === excludeMaturity) continue;
+ if (isBefore(new Date(Number(maturity) * 1000), new Date())) {
+ overduePayments.set(maturity, {
+ amount: (overduePayments.get(maturity)?.amount ?? 0n) + previewValue,
+ discount: Number(WAD - (previewValue * WAD) / positionAmount) / 1e18,
+ });
+ }
+ }
+ }
+ }
+ const payments = [...overduePayments];
+ if (payments.length === 0) return null;
+ return (
+
+
+
+ {t("Overdue payments")}
+
+
+
+ {payments.map(([maturity, { amount, discount }], index) => {
+ const isRepaying = pendingProposals?.some(({ proposal }) => {
+ const { proposalType: type, data } = proposal;
+ const isRepayProposal =
+ type === (ProposalType.RepayAtMaturity as number) ||
+ type === (ProposalType.CrossRepayAtMaturity as number);
+ if (!isRepayProposal) return false;
+ const decoded =
+ type === (ProposalType.RepayAtMaturity as number)
+ ? decodeRepayAtMaturity(data)
+ : decodeCrossRepayAtMaturity(data);
+ return decoded.maturity === maturity;
+ });
+ const isRollingDebt = pendingProposals?.some(({ proposal }) => {
+ const { proposalType: type, data } = proposal;
+ if (type !== (ProposalType.RollDebt as number)) return false;
+ const decoded = decodeRollDebt(data);
+ return decoded.repayMaturity === maturity;
+ });
+ const processing = isRepaying || isRollingDebt; //eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
+ const formattedDate = new Date(Number(maturity) * 1000).toLocaleDateString(language, {
+ year: "2-digit",
+ month: "short",
+ day: "numeric",
+ });
+ const formattedAmount = (Number(amount) / 10 ** (exaUSDC?.decimals ?? 6)).toLocaleString(language, {
+ style: "currency",
+ currency: "USD",
+ });
+ return (
+
+ {index > 0 && }
+ {
+ if (processing) return;
+ selectionAsync().catch(reportError);
+ onSelect(maturity);
+ }}
+ >
+
+
+ {formattedDate}
+
+
+ {processing
+ ? t("Processing")
+ : `+${Math.abs(discount).toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}`}
+
+
+
+ {formattedAmount}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/pay/Pay.tsx b/src/components/pay/Pay.tsx
new file mode 100644
index 000000000..727fb02e7
--- /dev/null
+++ b/src/components/pay/Pay.tsx
@@ -0,0 +1,414 @@
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Pressable, RefreshControl } from "react-native";
+
+import { useRouter } from "expo-router";
+
+import { Check, Coins, ExternalLink, Eye, EyeOff, FileText, Info, RefreshCw } from "@tamagui/lucide-icons";
+import { ScrollView, XStack, YStack } from "tamagui";
+
+import { useQuery } from "@tanstack/react-query";
+import { formatDistance, isBefore } from "date-fns";
+import { enUS, es } from "date-fns/locale";
+import { zeroAddress } from "viem";
+import { optimismSepolia } from "viem/chains";
+
+import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain";
+import { useReadPreviewerExactly } from "@exactly/common/generated/hooks";
+import { WAD } from "@exactly/lib";
+
+import Empty from "./Empty";
+import OverduePayments from "./OverduePayments";
+import PaymentSheet from "./PaymentSheet";
+import UpcomingPayments from "./UpcomingPayments";
+import { presentArticle } from "../../utils/intercom";
+import openBrowser from "../../utils/openBrowser";
+import queryClient from "../../utils/queryClient";
+import reportError from "../../utils/reportError";
+import useAsset from "../../utils/useAsset";
+import useTabPress from "../../utils/useTabPress";
+import Amount from "../shared/Amount";
+import InfoSheet from "../shared/InfoSheet";
+import SafeView from "../shared/SafeView";
+import Button from "../shared/StyledButton";
+import Text from "../shared/Text";
+import View from "../shared/View";
+
+export default function Pay() {
+ const {
+ t,
+ i18n: { language },
+ } = useTranslation();
+ const router = useRouter();
+ const { account, market: exaUSDC } = useAsset(marketUSDCAddress);
+ const {
+ data: markets,
+ refetch,
+ isPending,
+ } = useReadPreviewerExactly({
+ address: previewerAddress,
+ args: [account ?? zeroAddress],
+ query: { refetchInterval: 30_000 },
+ });
+
+ const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] });
+ const [infoType, setInfoType] = useState<"discount" | "fees" | "total" | null>(null);
+ const scrollRef = useRef(null);
+ const refresh = useCallback(() => {
+ refetch().catch(reportError);
+ queryClient.refetchQueries({ queryKey: ["activity"] }).catch(reportError);
+ }, [refetch]);
+ useTabPress("pay", () => {
+ scrollRef.current?.scrollTo({ y: 0, animated: true });
+ refresh();
+ });
+
+ const allMaturities = useMemo(() => {
+ if (!markets) return [];
+ const map = new Map();
+ for (const { fixedBorrowPositions } of markets) {
+ for (const { maturity, previewValue, position } of fixedBorrowPositions) {
+ if (!previewValue || previewValue === 0n) continue;
+ const positionAmount = position.principal + position.fee;
+ const existing = map.get(maturity);
+ map.set(maturity, {
+ previewValue: (existing?.previewValue ?? 0n) + previewValue,
+ positionAmount: (existing?.positionAmount ?? 0n) + positionAmount,
+ isOverdue: isBefore(new Date(Number(maturity) * 1000), new Date()),
+ });
+ }
+ }
+ return [...map].sort(([a], [b]) => Number(a - b));
+ }, [markets]);
+
+ const hasPayments = allMaturities.length > 0;
+ const firstMaturity = allMaturities[0];
+
+ const totalOutstandingUSD = useMemo(() => {
+ if (!exaUSDC) return 0;
+ const total = allMaturities.reduce((sum, [, { previewValue }]) => sum + previewValue, 0n);
+ return Number(total) / 10 ** exaUSDC.decimals;
+ }, [allMaturities, exaUSDC]);
+
+ const viewStatement = useCallback(() => {
+ openBrowser(
+ `https://${{ [optimismSepolia.id]: "testnet" }[chain.id] ?? "app"}.exact.ly/dashboard?account=${account}&tab=b`,
+ ).catch(reportError);
+ }, [account]);
+
+ const onSelect = useCallback(
+ (maturity: bigint) => {
+ router.setParams({ maturity: String(maturity) });
+ },
+ [router],
+ );
+
+ return (
+
+
+
+ }
+ >
+ {hasPayments ? (
+ <>
+
+
+ {t("Payments")}
+
+ {
+ queryClient.setQueryData(["settings", "sensitive"], !hidden);
+ }}
+ hitSlop={15}
+ >
+ {hidden ? : }
+
+
+ setInfoType("total")}
+ onStatementsPress={viewStatement}
+ />
+
+ {firstMaturity && exaUSDC && (
+ setInfoType(firstMaturity[1].isOverdue ? "fees" : "discount")}
+ onPay={() => onSelect(firstMaturity[0])}
+ onRollover={() => {
+ router.setParams({ maturity: String(firstMaturity[0]) });
+ }}
+ />
+ )}
+
+
+
+
+ setInfoType(null)} title={t("Total outstanding")}>
+
+ {t("This total includes all your purchases, loans, interest, and any applicable late fees.")}
+
+ setInfoType(null)}>
+ {t("Got it!")}
+
+
+
+
+
+ setInfoType(null)}
+ title={t("Early repayment discount")}
+ >
+
+ {t(
+ "You can repay early and save on interest. The final amount updates automatically before you confirm.",
+ )}
+
+ {
+ presentArticle("10245778").catch(reportError);
+ }}
+ >
+ {t("Learn more")}
+
+
+
+
+ setInfoType(null)}
+ >
+ {t("Close")}
+
+
+ setInfoType(null)} title={t("Late payment fees")}>
+
+ {t(
+ "Late fees are charged daily after the due date. The rate applies to your full balance (principal + interest) and keeps adding up until you pay.",
+ )}
+
+
+ {t("Example: On a $100 balance, a 0.45% daily fee adds $0.45 per day.")}
+
+ {
+ presentArticle("10245778").catch(reportError);
+ }}
+ >
+ {t("Learn more")}
+
+
+
+
+ setInfoType(null)}
+ >
+ {t("Close")}
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function TotalOutstandingCard({
+ amount,
+ count,
+ onInfoPress,
+ onStatementsPress,
+ t,
+}: {
+ amount: number;
+ count: number;
+ onInfoPress: () => void;
+ onStatementsPress: () => void;
+ t: (key: string, options?: Record) => string;
+}) {
+ return (
+
+
+
+
+ {t("Total outstanding")}
+
+
+
+
+
+
+
+ {t("Statements")}
+
+
+
+
+
+
+ {count > 0 && (
+
+ {t("in {{count}} payments", { count })}
+
+ )}
+
+
+ );
+}
+
+function FirstMaturityCard({
+ data,
+ decimals: assetDecimals,
+ language,
+ maturity,
+ onInfoPress,
+ onPay,
+ onRollover,
+ t,
+}: {
+ data: { isOverdue: boolean; positionAmount: bigint; previewValue: bigint };
+ decimals: number;
+ language: string;
+ maturity: bigint;
+ onInfoPress: () => void;
+ onPay: () => void;
+ onRollover: () => void;
+ t: (key: string, options?: Record) => string;
+}) {
+ const { previewValue, positionAmount, isOverdue } = data;
+ const maturityDate = new Date(Number(maturity) * 1000);
+ const now = new Date();
+ const dateFnsLocale = language === "es" ? es : enUS;
+ const timeDistance = formatDistance(isOverdue ? maturityDate : now, isOverdue ? now : maturityDate, {
+ locale: dateFnsLocale,
+ });
+
+ const discount = Number(WAD - (previewValue * WAD) / positionAmount) / 1e18;
+ const penaltyPercent = isOverdue ? Math.abs(discount) : discount;
+ const originalAmount = Number(positionAmount) / 10 ** assetDecimals;
+
+ return (
+
+
+
+
+
+ {isOverdue
+ ? t("{{time}} past due", { time: timeDistance })
+ : t("Due in {{time}}", { time: timeDistance })}
+
+
+
+
+ {t("View statement")}
+
+
+
+
+
+ {maturityDate.toLocaleDateString(language, { year: "numeric", month: "short", day: "numeric" })}
+ {` - ${maturityDate.toLocaleTimeString(language, { hour: "2-digit", minute: "2-digit", timeZoneName: "short" })}`}
+
+
+
+
+
+ {isOverdue ? (
+ <>
+
+ {penaltyPercent.toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+
+ {t("Late payment fee")}
+
+ >
+ ) : discount >= 0.001 ? (
+ <>
+
+ {`$${originalAmount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+
+
+ {t("{{percent}} OFF", {
+ percent: discount.toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
+ })}
+
+ >
+ ) : null}
+
+
+
+
+
+
+
+
+ {t("Pay")}
+
+
+
+
+
+ {t("Rollover")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/pay-mode/PaymentSheet.tsx b/src/components/pay/PaymentSheet.tsx
similarity index 59%
rename from src/components/pay-mode/PaymentSheet.tsx
rename to src/components/pay/PaymentSheet.tsx
index 28eaf60df..97f4b1db2 100644
--- a/src/components/pay-mode/PaymentSheet.tsx
+++ b/src/components/pay/PaymentSheet.tsx
@@ -7,15 +7,16 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import {
ArrowRight,
Calendar,
- ChevronRight,
CirclePercent,
Coins,
+ ExternalLink,
+ FileText,
Info,
RefreshCw,
Siren,
} from "@tamagui/lucide-icons";
import { useToastController } from "@tamagui/toast";
-import { Separator, XStack, YStack } from "tamagui";
+import { Separator, XStack, YStack, type YStackProps } from "tamagui";
import { useQuery } from "@tanstack/react-query";
import { formatDistance, isAfter } from "date-fns";
@@ -35,25 +36,28 @@ import queryClient from "../../utils/queryClient";
import reportError from "../../utils/reportError";
import useAccount from "../../utils/useAccount";
import useAsset from "../../utils/useAsset";
+import Amount from "../shared/Amount";
+import InfoSheet from "../shared/InfoSheet";
import ModalSheet from "../shared/ModalSheet";
-import SafeView from "../shared/SafeView";
import Button from "../shared/StyledButton";
import Text from "../shared/Text";
import View from "../shared/View";
import type { Credential } from "@exactly/common/validation";
-function Frame({ children }: { children: React.ReactNode }) {
+function Frame({ children, ...properties }: YStackProps & { children: React.ReactNode }) {
return (
-
{children}
-
+
);
}
@@ -61,14 +65,21 @@ function NotAvailableView({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return (
-
+
{t("This payment is no longer available")}
{t("Close")}
-
+
);
}
@@ -77,14 +88,14 @@ function RolloverIntroView({ isLatestPlugin, onContinue }: { isLatestPlugin: boo
const { t } = useTranslation();
const toast = useToastController();
return (
-
+
-
+
@@ -99,19 +110,19 @@ function RolloverIntroView({ isLatestPlugin, onContinue }: { isLatestPlugin: boo
-
+
{t("Avoid penalties by extending your deadline")}
-
+
{t("Refinance at a better rate")}
-
+
{t("Get more time to repay")}
@@ -137,121 +148,117 @@ function RolloverIntroView({ isLatestPlugin, onContinue }: { isLatestPlugin: boo
-
+
);
}
function DetailsView({
borrow,
- hidden,
language,
+ onInfoPress,
onRepayPress,
onRolloverPress,
onViewStatement,
}: {
borrow: {
discount: number;
- discountLabel: string;
dueDate: Date;
dueStatus: string;
isUpcoming: boolean;
positionValue: bigint;
previewValue: bigint;
};
- hidden: boolean;
language: string;
+ onInfoPress: () => void;
onRepayPress: () => void;
onRolloverPress: () => void;
onViewStatement: () => void;
}) {
const { t } = useTranslation();
- const { previewValue, positionValue, discount, dueDate, isUpcoming, dueStatus, discountLabel } = borrow;
+ const { previewValue, positionValue, discount, dueDate, isUpcoming, dueStatus } = borrow;
+
+ const penaltyPercent = Math.abs(discount);
+ const originalAmount = Number(positionValue) / 1e18;
+
return (
-
-
-
-
- {dueStatus}
-
- {" - "}
- {dueDate.toLocaleDateString(language, { year: "numeric", month: "short", day: "numeric" })}
-
-
- {
- presentArticle("10245778").catch(reportError);
- }}
- hitSlop={15}
- >
-
-
-
-
-
- {`$${(Number(previewValue) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
-
- {discount >= 0 && (
-
- {`$${(Number(positionValue) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+
+
+
+
+
+ {dueStatus}
- )}
- {!hidden && (
- = 0 ? "$interactiveBaseSuccessSoftDefault" : "$interactiveBaseErrorSoftDefault"
- }
- color={discount >= 0 ? "$uiSuccessSecondary" : "$uiErrorSecondary"}
- >
- {discountLabel}
-
- )}
-
-
-
-
- {t("Repay")}
-
-
-
-
-
- {t("Rollover")}
-
-
-
-
-
-
-
- {t("View Statement")}
-
-
-
-
-
+
+
+ {t("View statement")}
+
+
+
+
+ {dueDate.toLocaleDateString(language, { year: "numeric", month: "short", day: "numeric" })}
+ {` - ${dueDate.toLocaleTimeString(language, { hour: "2-digit", minute: "2-digit", timeZoneName: "short" })}`}
+
+
+
+
+
+ {isUpcoming ? (
+ discount >= 0.001 ? (
+ <>
+
+ {`$${originalAmount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
+
+
+ {t("{{percent}} OFF", {
+ percent: discount.toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
+ })}
+
+ >
+ ) : null
+ ) : (
+ <>
+
+ {penaltyPercent.toLocaleString(language, {
+ style: "percent",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+
+ {t("Late payment fee")}
+
+ >
+ )}
-
-
+
+
+
+
+
+
+
+ {t("Pay")}
+
+
+
+
+
+ {t("Rollover")}
+
+
+
+
+
+
+
);
}
@@ -262,12 +269,12 @@ export default function PaymentSheet() {
const { maturity } = parameters;
const { address } = useAccount();
const { market: USDCMarket } = useAsset(marketUSDCAddress);
+ const [infoOpen, setInfoOpen] = useState(false);
const [rolloverIntroOpen, setRolloverIntroOpen] = useState(false);
const [open, setOpen] = useState(() => !!maturity);
const [displayMaturity, setDisplayMaturity] = useState(maturity);
const toast = useToastController();
const { data: credential } = useQuery({ queryKey: ["credential"] });
- const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] });
const { data: rolloverIntroShown } = useQuery({ queryKey: ["settings", "rollover-intro-shown"] });
const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({
address,
@@ -293,7 +300,6 @@ export default function PaymentSheet() {
| undefined
| {
discount: number;
- discountLabel: string;
dueDate: Date;
dueStatus: string;
isUpcoming: boolean;
@@ -319,16 +325,7 @@ export default function PaymentSheet() {
const dueStatus = isUpcoming
? t("Due in {{time}}", { time: timeDistance })
: t("{{time}} past due", { time: timeDistance });
- const discountPercentDisplay = (discount >= 0 ? discount : discount * -1).toLocaleString(language, {
- style: "percent",
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- });
- const discountLabel =
- discount >= 0
- ? t("PAY NOW AND SAVE {{percent}}", { percent: discountPercentDisplay })
- : t("DAILY PENALTIES {{percent}}", { percent: discountPercentDisplay });
- return { discount, discountLabel, dueDate, dueStatus, isUpcoming, positionValue, previewValue };
+ return { discount, dueDate, dueStatus, isUpcoming, positionValue, previewValue };
}, [displayMaturity, USDCMarket, dateFnsLocale, t, language]);
const close = useCallback(() => {
@@ -349,7 +346,7 @@ export default function PaymentSheet() {
const navigateToRepay = useCallback(() => {
close();
- router.navigate({ pathname: "/pay", params: { maturity: displayMaturity } });
+ router.navigate({ pathname: "/repay", params: { maturity: displayMaturity } });
}, [close, router, displayMaturity]);
const navigateToRollover = useCallback(() => {
@@ -371,14 +368,12 @@ export default function PaymentSheet() {
const renderContent = () => {
if (!displayMaturity || !USDCMarket || !borrow) return ;
- if (rolloverIntroOpen) {
- return ;
- }
+ if (rolloverIntroOpen) return ;
return (
setInfoOpen(true)}
onRepayPress={navigateToRepay}
onRolloverPress={navigateToRollover}
onViewStatement={viewStatement}
@@ -387,8 +382,57 @@ export default function PaymentSheet() {
};
return (
-
- {renderContent()}
-
+ <>
+
+ {renderContent()}
+
+ {borrow && (
+ setInfoOpen(false)}
+ title={borrow.isUpcoming ? t("Early repayment discount") : t("Late payment fees")}
+ >
+ {borrow.isUpcoming ? (
+
+ {t(
+ "You can repay early and save on interest. The final amount updates automatically before you confirm.",
+ )}
+
+ ) : (
+ <>
+
+ {t(
+ "Late fees are charged daily after the due date. The rate applies to your full balance (principal + interest) and keeps adding up until you pay.",
+ )}
+
+
+ {t("Example: On a $100 balance, a 0.45% daily fee adds $0.45 per day.")}
+
+ >
+ )}
+ {
+ presentArticle("10245778").catch(reportError);
+ }}
+ >
+ {t("Learn more")}
+
+
+
+
+ setInfoOpen(false)}
+ >
+ {t("Close")}
+
+
+ )}
+ >
);
}
diff --git a/src/components/pay-mode/Pay.tsx b/src/components/pay/Repay.tsx
similarity index 99%
rename from src/components/pay-mode/Pay.tsx
rename to src/components/pay/Repay.tsx
index 914195851..b519ef95c 100644
--- a/src/components/pay-mode/Pay.tsx
+++ b/src/components/pay/Repay.tsx
@@ -58,7 +58,7 @@ import Pending from "../shared/Pending";
import Skeleton from "../shared/Skeleton";
import Success from "../shared/Success";
-export default function Pay() {
+export default function Repay() {
const insets = useSafeAreaInsets();
const {
t,
@@ -531,7 +531,7 @@ export default function Pay() {
return (
-
+
{
@@ -592,7 +592,7 @@ export default function Pay() {
{t("Subtotal")}
-
+
{isRouteFetching ? (
@@ -669,7 +669,7 @@ export default function Pay() {
{t("You will pay")}
-
+
{isRouteFetching ? (
diff --git a/src/components/pay-mode/RepayAmountSelector.tsx b/src/components/pay/RepayAmountSelector.tsx
similarity index 100%
rename from src/components/pay-mode/RepayAmountSelector.tsx
rename to src/components/pay/RepayAmountSelector.tsx
diff --git a/src/components/pay-mode/UpcomingPayments.tsx b/src/components/pay/UpcomingPayments.tsx
similarity index 55%
rename from src/components/pay-mode/UpcomingPayments.tsx
rename to src/components/pay/UpcomingPayments.tsx
index 75e2de252..60a49c15b 100644
--- a/src/components/pay-mode/UpcomingPayments.tsx
+++ b/src/components/pay/UpcomingPayments.tsx
@@ -1,10 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
+import { selectionAsync } from "expo-haptics";
+
import { ChevronRight } from "@tamagui/lucide-icons";
-import { XStack, YStack } from "tamagui";
+import { Separator, XStack, YStack } from "tamagui";
-import { isBefore } from "date-fns";
+import { isBefore, isToday, isTomorrow } from "date-fns";
import { zeroAddress } from "viem";
import { useBytecode } from "wagmi";
@@ -17,12 +19,18 @@ import ProposalType, {
} from "@exactly/common/ProposalType";
import { WAD } from "@exactly/lib";
+import reportError from "../../utils/reportError";
import useAccount from "../../utils/useAccount";
-import AssetLogo from "../shared/AssetLogo";
import Text from "../shared/Text";
import View from "../shared/View";
-export default function UpcomingPayments({ onSelect }: { onSelect: (maturity: bigint) => void }) {
+export default function UpcomingPayments({
+ excludeMaturity,
+ onSelect,
+}: {
+ excludeMaturity?: bigint;
+ onSelect: (maturity: bigint) => void;
+}) {
const {
t,
i18n: { language },
@@ -47,6 +55,7 @@ export default function UpcomingPayments({ onSelect }: { onSelect: (maturity: bi
if (!previewValue) continue;
if (isBefore(new Date(Number(maturity) * 1000), new Date())) continue;
+ if (maturity === excludeMaturity) continue;
duePayments.set(maturity, {
positionAmount: position.principal + position.fee,
amount: (duePayments.get(maturity)?.amount ?? 0n) + previewValue,
@@ -57,15 +66,23 @@ export default function UpcomingPayments({ onSelect }: { onSelect: (maturity: bi
}
const payments = [...duePayments];
return (
-
-
-
+
+
+
{t("Upcoming payments")}
-
+
{payments.length > 0 ? (
- payments.map(([maturity, { amount, discount }]) => {
+ payments.map(([maturity, { amount, discount }], index) => {
const isRepaying = pendingProposals?.some(({ proposal }) => {
const { proposalType: type, data } = proposal;
const isRepayProposal =
@@ -85,106 +102,63 @@ export default function UpcomingPayments({ onSelect }: { onSelect: (maturity: bi
return decoded.repayMaturity === maturity;
});
const processing = isRepaying || isRollingDebt; //eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
+ const maturityDate = new Date(Number(maturity) * 1000);
+ const formattedDate = isToday(maturityDate)
+ ? t("Due today")
+ : isTomorrow(maturityDate)
+ ? t("Due tomorrow")
+ : maturityDate.toLocaleDateString(language, { year: "2-digit", month: "short", day: "numeric" });
+ const formattedAmount = (Number(amount) / 10 ** (exaUSDC?.decimals ?? 6)).toLocaleString(language, {
+ style: "currency",
+ currency: "USD",
+ });
return (
- {
- if (processing) return;
- onSelect(maturity);
- }}
- >
-
-
-
-
- = 0
- ? "$interactiveBaseSuccessDefault"
- : "$uiNeutralPrimary"
- }
- >
- {(Number(amount) / 10 ** (exaUSDC?.decimals ?? 6)).toLocaleString(language, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
-
- {new Date(Number(maturity) * 1000).toLocaleDateString(language, {
- year: "numeric",
- month: "short",
- day: "numeric",
- })}
+
+ {index > 0 && }
+ {
+ if (processing) return;
+ selectionAsync().catch(reportError);
+ onSelect(maturity);
+ }}
+ >
+
+
+ {formattedDate}
-
- {processing ? (
-
-
+ {processing ? (
+
{t("Processing")}
-
- ) : null}
-
-
- {processing || discount < 0.001 ? null : (
-
-
- {t("{{discount}} off", {
- discount: discount.toLocaleString(language, {
+ ) : discount >= 0.001 ? (
+
+ {t("{{percent}} OFF", {
+ percent: discount.toLocaleString(language, {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})}
-
- )}
+ ) : null}
+
- {t("Repay")}
+ {formattedAmount}
-
+
-
+
);
})
) : (
diff --git a/src/components/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx
index be2701013..2e614eee7 100644
--- a/src/components/roll-debt/RollDebt.tsx
+++ b/src/components/roll-debt/RollDebt.tsx
@@ -70,7 +70,7 @@ export default function Pay() {
return (
-
+
-
-
+
+
{
@@ -310,7 +310,7 @@ export default function Amount() {
{t("To:")}
-
+
{shortenHex(receiver)}
diff --git a/src/components/send-funds/Asset.tsx b/src/components/send-funds/Asset.tsx
index 4cb12577a..94a9864cc 100644
--- a/src/components/send-funds/Asset.tsx
+++ b/src/components/send-funds/Asset.tsx
@@ -39,8 +39,8 @@ export default function AssetSelection() {
return (
-
-
+
+
{
@@ -75,7 +75,7 @@ export default function AssetSelection() {
{t("To:")}
-
+
{shortenHex(receiver)}
diff --git a/src/components/send-funds/Contact.tsx b/src/components/send-funds/Contact.tsx
index d0a6f6afe..39d4f5b42 100644
--- a/src/components/send-funds/Contact.tsx
+++ b/src/components/send-funds/Contact.tsx
@@ -48,7 +48,7 @@ export default function Contact({
) : null}
-
+
{shortenHex(address)}
diff --git a/src/components/send-funds/Receiver.tsx b/src/components/send-funds/Receiver.tsx
index ea1861138..76e0f6a69 100644
--- a/src/components/send-funds/Receiver.tsx
+++ b/src/components/send-funds/Receiver.tsx
@@ -54,8 +54,8 @@ export default function ReceiverSelection() {
return (
-
-
+
+
-
+
{shortenHex(receiver ?? "", 3, 5)}
{receiver && isFirstSend && (
diff --git a/src/components/shared/Amount.tsx b/src/components/shared/Amount.tsx
new file mode 100644
index 000000000..ab279bf90
--- /dev/null
+++ b/src/components/shared/Amount.tsx
@@ -0,0 +1,78 @@
+import React, { type ComponentPropsWithoutRef } from "react";
+import { useTranslation } from "react-i18next";
+
+import { XStack } from "tamagui";
+
+import { useQuery } from "@tanstack/react-query";
+
+import Text from "./Text";
+
+export default function Amount({
+ amount,
+ children,
+ label,
+ status = "neutral",
+ ...properties
+}: ComponentPropsWithoutRef & {
+ amount?: number;
+ label?: string;
+ status?: "danger" | "neutral" | "success";
+}) {
+ const {
+ i18n: { language },
+ } = useTranslation();
+ const { data: hidden } = useQuery({ queryKey: ["settings", "sensitive"] });
+ const formatted =
+ amount === undefined
+ ? undefined
+ : amount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ const whole = formatted?.slice(0, -3).replace(/^$/, "0");
+ const decimal = formatted?.slice(-3);
+ const displayLabel = hidden ? undefined : (label ?? (formatted ? `$${formatted}` : undefined));
+ const { color, wholeColor } = palette[status];
+
+ return (
+
+ {whole ? (
+ <>
+
+ $
+
+
+ {whole}
+
+ {decimal ? (
+
+ {decimal}
+
+ ) : undefined}
+ >
+ ) : (
+ children
+ )}
+
+ );
+}
+
+const palette = {
+ neutral: { color: "$uiNeutralSecondary", wholeColor: undefined },
+ danger: { color: "$uiErrorTertiary", wholeColor: "$uiErrorSecondary" },
+ success: { color: "$uiSuccessTertiary", wholeColor: "$uiSuccessSecondary" },
+};
diff --git a/src/components/shared/AssetSelector.tsx b/src/components/shared/AssetSelector.tsx
index 6e41bd05f..e6ddcd8fe 100644
--- a/src/components/shared/AssetSelector.tsx
+++ b/src/components/shared/AssetSelector.tsx
@@ -75,8 +75,7 @@ export default function AssetSelector({
),
});
- const symbol =
- asset.type === "external" ? asset.symbol : asset.symbol.slice(3) === "WETH" ? "ETH" : asset.symbol.slice(3);
+ const symbol = asset.symbol;
const name =
asset.type === "external" ? asset.name : asset.assetName === "Wrapped Ether" ? "Ether" : asset.assetName;
const isSelected = selectedMarket === (asset.type === "external" ? asset.address : asset.market);
@@ -101,7 +100,7 @@ export default function AssetSelector({
paddingHorizontal="$s4"
borderRadius="$r3"
>
-
+
diff --git a/src/components/shared/CopyAddressSheet.tsx b/src/components/shared/CopyAddressSheet.tsx
index 75bbb1f7f..0ac124f65 100644
--- a/src/components/shared/CopyAddressSheet.tsx
+++ b/src/components/shared/CopyAddressSheet.tsx
@@ -43,7 +43,7 @@ export default function CopyAddressSheet({ open, onClose }: { onClose: () => voi
{t("Double-check your address before sending funds to avoid losing them.")}
-
+
{address}
void;
+ open: boolean;
+ title: string;
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/components/shared/InstallmentSelector.tsx b/src/components/shared/InstallmentSelector.tsx
index 813f1430a..4abd921c6 100644
--- a/src/components/shared/InstallmentSelector.tsx
+++ b/src/components/shared/InstallmentSelector.tsx
@@ -103,7 +103,7 @@ function Installment({
backgroundColor={selected ? "$interactiveBaseBrandDefault" : "$backgroundStrong"}
width={16}
height={16}
- padding={4}
+ padding="$s2"
borderRadius="$r_0"
alignItems="center"
justifyContent="center"
diff --git a/src/components/shared/LiquidationAlert.tsx b/src/components/shared/LiquidationAlert.tsx
index 7ee56e49c..45412e127 100644
--- a/src/components/shared/LiquidationAlert.tsx
+++ b/src/components/shared/LiquidationAlert.tsx
@@ -17,11 +17,11 @@ export default function LiquidationAlert() {
backgroundColor="$interactiveBaseErrorSoftDefault"
justifyContent="space-between"
alignItems="center"
- gap={10}
+ gap="$s3_5"
flex={1}
>
-
+
{t("Some of your assets are at risk of being liquidated.")}
@@ -42,7 +42,7 @@ export default function LiquidationAlert() {
presentArticle("9975910").catch(reportError);
}}
>
-
+
{t("Learn more")}
diff --git a/src/components/shared/ModalSheet.tsx b/src/components/shared/ModalSheet.tsx
index e207017a7..80eac7825 100644
--- a/src/components/shared/ModalSheet.tsx
+++ b/src/components/shared/ModalSheet.tsx
@@ -22,7 +22,7 @@ export default function ModalSheet({
dismissOnSnapToBottom
unmountChildrenWhenHidden
forceRemoveScrollEnabled={open}
- animation="moderate"
+ animation="default"
dismissOnOverlayPress
onOpenChange={(isOpen: boolean) => {
if (!isOpen) onClose();
diff --git a/src/components/shared/ProfileHeader.tsx b/src/components/shared/ProfileHeader.tsx
index 48e47cbf9..7d090963a 100644
--- a/src/components/shared/ProfileHeader.tsx
+++ b/src/components/shared/ProfileHeader.tsx
@@ -37,7 +37,7 @@ export default function ProfileHeader() {
return (
-
+
{address && (
@@ -54,14 +54,12 @@ export default function ProfileHeader() {
}}
>
-
- {hidden ? "0x..." : shortenHex(address)}
-
+ {hidden ? "0x..." : shortenHex(address)}
)}
-
+
{hidden ? : }
diff --git a/src/components/shared/StyledButton.tsx b/src/components/shared/StyledButton.tsx
index 3cc621e1b..c84b5dd2e 100644
--- a/src/components/shared/StyledButton.tsx
+++ b/src/components/shared/StyledButton.tsx
@@ -2,7 +2,7 @@ import type React from "react";
import { use, useMemo, type ComponentPropsWithoutRef } from "react";
import type { ArrowRight } from "@tamagui/lucide-icons";
-import { createStyledContext, Spinner, styled, withStaticProperties, XStack } from "tamagui";
+import { createStyledContext, Spinner, styled, withStaticProperties, XStack, YStack } from "tamagui";
import Text from "./Text";
@@ -150,4 +150,59 @@ const ButtonIcon = (properties: { children: React.ReactElement;
};
-export default withStaticProperties(ButtonFrame, { Props: ButtonContext.Provider, Text: ButtonText, Icon: ButtonIcon });
+const ButtonColumnFrame = styled(YStack, {
+ name: "ButtonColumn",
+ context: ButtonContext,
+ alignItems: "center",
+ gap: "$s3_5",
+ cursor: "pointer",
+ variants: {
+ primary: { true: {} },
+ secondary: { true: {} },
+ danger: { true: {} },
+ dangerSecondary: { true: {} },
+ outlined: { true: {} },
+ transparent: { true: {} },
+ loading: { true: {} },
+ disabled: { true: { cursor: "not-allowed" } },
+ } as const,
+});
+
+const ButtonColumn = ({
+ primary,
+ secondary,
+ danger,
+ dangerSecondary,
+ outlined,
+ transparent,
+ loading,
+ disabled,
+ ...properties
+}: ComponentPropsWithoutRef) => {
+ const context = { primary, secondary, danger, dangerSecondary, outlined, transparent, loading, disabled };
+ return (
+
+
+
+ );
+};
+
+const ButtonLabel = (properties: ComponentPropsWithoutRef) => {
+ const { disabled } = use(ButtonContext.context);
+ return (
+
+ );
+};
+
+export default withStaticProperties(ButtonFrame, {
+ Props: ButtonContext.Provider,
+ Text: ButtonText,
+ Icon: ButtonIcon,
+ Column: ButtonColumn,
+ Label: ButtonLabel,
+});
diff --git a/src/components/shared/Text.tsx b/src/components/shared/Text.tsx
index a39180e60..1e7719f9e 100644
--- a/src/components/shared/Text.tsx
+++ b/src/components/shared/Text.tsx
@@ -10,16 +10,17 @@ const StyledText = styled(TamaguiText, {
emphasized: { true: { fontWeight: "bold" } },
primary: { true: { color: "$uiNeutralPrimary" } },
secondary: { true: { color: "$uiNeutralSecondary" } },
- title: { true: { fontSize: 28, letterSpacing: -0.2 } },
- title2: { true: { fontSize: 22, letterSpacing: -0.2 } },
- title3: { true: { fontSize: 20, letterSpacing: -0.2 } },
- headline: { true: { fontSize: 17, letterSpacing: -0.1 } },
- body: { true: { fontSize: 17, letterSpacing: -0.1 } },
- callout: { true: { fontSize: 16, letterSpacing: -0.2 } },
- subHeadline: { true: { fontSize: 15, letterSpacing: 0 } },
- footnote: { true: { fontSize: 13, letterSpacing: 0 } },
- caption: { true: { fontSize: 12, letterSpacing: 0 } },
- caption2: { true: { fontSize: 11, letterSpacing: 0 } },
+ largeTitle: { true: { fontSize: 36, lineHeight: 47, letterSpacing: -0.072 } },
+ title: { true: { fontSize: 30, lineHeight: 39, letterSpacing: -0.06 } },
+ title2: { true: { fontSize: 23, lineHeight: 30, letterSpacing: -0.046 } },
+ title3: { true: { fontSize: 21, lineHeight: 27, letterSpacing: -0.042 } },
+ headline: { true: { fontSize: 18, lineHeight: 23, letterSpacing: -0.036 } },
+ body: { true: { fontSize: 18, lineHeight: 23, letterSpacing: -0.036 } },
+ callout: { true: { fontSize: 17, lineHeight: 22, letterSpacing: -0.034 } },
+ subHeadline: { true: { fontSize: 16, lineHeight: 21, letterSpacing: -0.032 } },
+ footnote: { true: { fontSize: 14, lineHeight: 18, letterSpacing: -0.028 } },
+ caption: { true: { fontSize: 13, lineHeight: 17, letterSpacing: -0.026 } },
+ caption2: { true: { fontSize: 12, lineHeight: 16, letterSpacing: -0.024 } },
brand: { true: { color: "$interactiveBaseBrandDefault" } },
centered: { true: { textAlign: "center" } },
pill: { true: { fontWeight: "bold", paddingHorizontal: 4, paddingVertical: 2, borderRadius: "$r2" } },
diff --git a/src/components/shared/TransactionDetails.tsx b/src/components/shared/TransactionDetails.tsx
index a053700b8..00ca07b9e 100644
--- a/src/components/shared/TransactionDetails.tsx
+++ b/src/components/shared/TransactionDetails.tsx
@@ -76,7 +76,7 @@ export default function TransactionDetails({ hash }: { hash?: string }) {
openBrowser(`${explorerUrl}/tx/${hash}`).catch(reportError);
}}
>
-
+
{shortenHex(hash)}
diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx
index d963bf8f0..ecb468b3f 100644
--- a/src/components/swaps/Swaps.tsx
+++ b/src/components/swaps/Swaps.tsx
@@ -374,7 +374,7 @@ export default function Swaps() {
-
+
{(caution || danger) && showWarning && (
diff --git a/src/components/swaps/TokenInput.tsx b/src/components/swaps/TokenInput.tsx
index 04492ed4d..9f900a464 100644
--- a/src/components/swaps/TokenInput.tsx
+++ b/src/components/swaps/TokenInput.tsx
@@ -171,7 +171,6 @@ export default function TokenInput({
color={
isDanger ? "$uiErrorSecondary" : isActive ? "$uiNeutralPrimary" : "$uiNeutralPlaceholder"
}
- fontFamily="BDOGrotesk-Regular"
fontSize={28}
fontWeight="bold"
letterSpacing={-0.2}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f2fad4dc0..6a93568a2 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -12,5 +12,7 @@
"{{count}} step remaining_one": "{{count}} step remaining",
"{{count}} step remaining_other": "{{count}} steps remaining",
"Pending requests → {{count}}_one": "Pending request → {{count}}",
- "Pending requests → {{count}}_other": "Pending requests → {{count}}"
+ "Pending requests → {{count}}_other": "Pending requests → {{count}}",
+ "in {{count}} payments_one": "in {{count}} payment",
+ "in {{count}} payments_other": "in {{count}} payments"
}
diff --git a/src/i18n/es.json b/src/i18n/es.json
index f6da10b82..a2bb360b5 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -2,6 +2,8 @@
"Exa Account": "Cuenta Exa",
"Send": "Enviar",
"Swap": "Intercambiar",
+ "Portfolio": "Cartera",
+ "Manage portfolio": "Administrar cartera",
"Settings": "Configuración",
"Support": "Soporte",
"Logout": "Cerrar sesión",
@@ -72,6 +74,32 @@
"Select token to pay": "Seleccionar token a pagar",
"Select token to receive": "Seleccionar token a recibir",
"Spending limit": "Límite de gasto",
+ "Spending limit info": "Información del límite de gasto",
+ "Credit limit": "Límite de crédito",
+ "Credit limit info": "Información del límite de crédito",
+ "Collateral {{value}}": "Colateral {{value}}",
+ "Exa Card pay mode": "Exa Card modo de pago",
+ "Now": "Ahora",
+ "Later in {{count}}_one": "{{count}} cuota",
+ "Later in {{count}}_other": "{{count}} cuotas",
+ "Set installments": "Configurar cuotas",
+ "Choose how many installments to use for future card purchases. You can always change this before each purchase.": "Elige cuántas cuotas usar para futuras compras con tarjeta. Siempre puedes cambiar esto antes de cada compra.",
+ "Set Pay Later in {{count}}_one": "Pagar en {{count}} cuota",
+ "Set Pay Later in {{count}}_other": "Pagar en {{count}} cuotas",
+ "Installments calculator": "Calculadora de cuotas",
+ "Enter a purchase amount": "Ingresa un monto de compra",
+ "to estimate installment options": "para estimar las opciones de cuotas",
+ "BEST APR": "MEJOR TNA",
+ "The maximum amount you can spend using Pay Now.": "El monto máximo que puedes gastar usando Pagar ahora.",
+ "It's based on the USDC available in your balance.": "Se basa en el USDC disponible en tu balance.",
+ "The maximum amount you can spend using Pay Later.": "El monto máximo que puedes gastar usando Pagar después.",
+ "It's based on the value of your collateral assets and updates as their value changes.": "Se basa en el valor de tus activos en garantía y se actualiza a medida que su valor cambia.",
+ "Change the pay mode before each purchase and pay how you want.": "Cambia el modo de pago antes de cada compra y paga como quieras.",
+ "Later": "Después",
+ "Pay": "Pagar",
+ "Pay instantly using your available USDC.": "Paga instantáneamente usando tu USDC disponible.",
+ "Pay without selling your crypto. Use it as collateral to unlock a credit limit and split purchases into up to 9 installments.": "Paga sin vender tu cripto. Úsalo como garantía para desbloquear un límite de crédito y dividir compras en hasta 9 cuotas.",
+ "Tap here to change the number of installments": "Toca aquí para cambiar la cantidad de cuotas",
"Pay Now": "Pagar ahora",
"{{count}} installments of_one": "{{count}} cuota de",
"{{count}} installments of_other": "{{count}} cuotas de",
@@ -79,6 +107,20 @@
"{{count}} installments_other": "{{count}} cuotas",
"Available for {{count}} installments or less_one": "Disponible para {{count}} cuota",
"Available for {{count}} installments or less_other": "Disponible para {{count}} cuotas o menos",
+ "Total outstanding": "Total pendiente",
+ "This total includes all your purchases, loans, interest, and any applicable late fees.": "Este total incluye todas tus compras, préstamos, intereses y cualquier cargo por pago tardío.",
+ "Got it!": "¡Entendido!",
+ "Early repayment discount": "Descuento por pago anticipado",
+ "You can repay early and save on interest. The final amount updates automatically before you confirm.": "Puedes pagar antes y ahorrar en intereses. El monto final se actualiza automáticamente antes de confirmar.",
+ "Late payment fees": "Intereses por pago tardío",
+ "Late fees are charged daily after the due date. The rate applies to your full balance (principal + interest) and keeps adding up until you pay.": "Los intereses por pago tardío se cobran diariamente después de la fecha de vencimiento. La tasa se aplica a tu saldo total (capital + intereses) y se sigue acumulando hasta que pagues.",
+ "Example: On a $100 balance, a 0.45% daily fee adds $0.45 per day.": "Ejemplo: con un saldo de $100, un interés diario del 0,45% suma $0,45 por día.",
+ "Statements": "Estados de cuenta",
+ "in {{count}} payments": "en {{count}} pagos",
+ "in {{count}} payments_one": "en {{count}} pago",
+ "in {{count}} payments_other": "en {{count}} pagos",
+ "View statement": "Ver estado de cuenta",
+ "{{percent}} OFF": "{{percent}} OFF",
"Overdue payments": "Pagos vencidos",
"Processing": "Procesando",
"Penalties {{percent}}": "Intereses {{percent}}",
@@ -86,8 +128,10 @@
"Repay amount": "Monto de pago",
"Repay amount slider": "Control deslizante de monto de pago",
"Set maximum repay amount": "Establecer monto máximo de pago",
+ "Due today": "Vence hoy",
+ "Due tomorrow": "Vence mañana",
"Upcoming payments": "Pagos próximos",
- "{{discount}} off": "{{discount}} de descuento",
+ "{{discount}} off": "{{discount}} off",
"Any funding or purchases will show up here.": "Cualquier financiación o compra aparecerá aquí.",
"You're all set!": "¡Todo listo!",
"You must repay each installment manually before its due date.": "Debes pagar cada cuota manualmente antes de su fecha de vencimiento.",
@@ -366,7 +410,7 @@
"Operation ID copied!": "¡ID de operación copiado!",
"Payment details": "Detalles del pago",
"Mode": "Modo",
- "Pay Later": "Pagar después",
+ "Pay Later": "Pagar Después",
"Fixed rate APR": "TNA fija",
"Installments": "Cuotas",
"Total": "Total",
@@ -383,7 +427,7 @@
"You repay in total": "Pagarás en total",
"{{rate}} FIXED APR": "{{rate}} TNA FIJA",
"Loan activity": "Actividad de préstamos",
- "Any purchases made with Pay Later will show up here.": "Cualquier compra realizada con Pagar después aparecerá aquí.",
+ "Any purchases made with Pay Later will show up here.": "Cualquier compra realizada con Pagar Después aparecerá aquí.",
"Select your funding installment plan": "Selecciona tu plan de cuotas de financiamiento",
"Funding failed": "El financiamiento falló",
"Funding request sent": "Solicitud de financiamiento enviada",
@@ -442,7 +486,7 @@
"Hide sensitive": "Ocultar sensibles",
"Pending proposals": "Solicitudes pendientes",
"Some of your assets are at risk of being liquidated.": "Algunos de tus activos están en riesgo de ser liquidados.",
- "Learn more": "Saber más",
+ "Learn more": "Aprende más",
"An account upgrade is required to access the latest features.": "Es necesaria una actualización de cuenta para acceder a las últimas funciones.",
"Processing balance → {{amount}}": "Saldo en procesamiento → {{amount}}",
"Pending requests → {{count}}_one": "Solicitud pendiente → {{count}}",
@@ -456,6 +500,7 @@
"Bridge": "Bridge",
"No payments pending": "Sin pagos pendientes",
"You're all caught up! Start using your card to see payments listed here.": "¡Estás al día! Empieza a usar tu tarjeta para ver los pagos aquí.",
+ "You're all caught up! Start using your card in Pay Later mode to see payments listed here.": "¡Estás al día! Empieza a usar tu tarjeta en modo Pagar Después para ver los pagos aquí.",
"Get your Visa Signature Exa Card": "Obtén tu Exa Card Visa Signature",
"Upgrade now": "Actualizar ahora",
"How passkeys work": "Cómo funcionan las llaves de acceso",
@@ -538,6 +583,7 @@
"Max": "Máx",
"Min": "Mín",
"Maximum amount selected.": "Monto máximo seleccionado.",
+ "Payments": "Pagos",
"Simulation failed": "Simulación fallida",
"Start verification": "Iniciar verificación",
"To upgrade your Exa Card, we first need to verify your identity so you can continue spending your onchain assets seamlessly.": "Para actualizar tu Exa Card, primero necesitamos verificar tu identidad para que puedas seguir gastando tus activos on-chain sin interrupciones.",
@@ -566,5 +612,6 @@
"The fiat deposit services are provided by Manteca (Sixalime SAS) and are subject to Terms and Conditions. Exa Labs SAS does not custody fiat funds.": "Los servicios de depósito fiat son proporcionados por Manteca (Sixalime SAS) y están sujetos a los Términos y Condiciones. Exa Labs SAS no custodia fondos fiat.",
"I accept the Terms and Conditions.": "Acepto los Términos y Condiciones.",
"All deposits must be from bank accounts under your name.": "Todos los depósitos deben ser desde cuentas bancarias a tu nombre.",
- "The bank account must be in your name": "La cuenta bancaria debe estar a tu nombre"
+ "The bank account must be in your name": "La cuenta bancaria debe estar a tu nombre",
+ "Late payment fee": "Interés por pago tardío"
}
diff --git a/src/utils/queryClient.ts b/src/utils/queryClient.ts
index 1c268f256..d7c72fdad 100644
--- a/src/utils/queryClient.ts
+++ b/src/utils/queryClient.ts
@@ -118,6 +118,13 @@ queryClient.setQueryDefaults(["settings", "installments"], {
gcTime: Infinity,
queryFn: () => queryClient.getQueryData(["settings", "installments"]),
});
+queryClient.setQueryDefaults(["settings", "installments-spotlight"], {
+ initialData: false,
+ retry: false,
+ staleTime: Infinity,
+ gcTime: Infinity,
+ queryFn: () => queryClient.getQueryData(["settings", "installments-spotlight"]),
+});
queryClient.setQueryDefaults(["simulate-purchase", "installments"], {
initialData: 1,
retry: false,
diff --git a/src/utils/server.ts b/src/utils/server.ts
index 483eaed70..db8a65479 100644
--- a/src/utils/server.ts
+++ b/src/utils/server.ts
@@ -14,7 +14,7 @@ import { Credential } from "@exactly/common/validation";
import { login as loginIntercom, logout as logoutIntercom } from "./intercom";
import { decrypt, decryptPIN, encryptPIN, session } from "./panda";
import queryClient, { APIError, type AuthMethod } from "./queryClient";
-import { isPasskeyExpected } from "./reportError";
+import reportError, { isPasskeyExpected } from "./reportError";
import ownerConfig from "./wagmi/owner";
import type { ExaAPI } from "@exactly/server/api"; // eslint-disable-line @nx/enforce-module-boundaries
@@ -126,12 +126,33 @@ export async function setCardStatus(status: "ACTIVE" | "DELETED" | "FROZEN") {
return response.json();
}
-export async function setCardMode(mode: number) {
+async function setCardMode(mode: number) {
await auth();
const response = await api.card.$patch({ json: { mode } });
if (!response.ok) throw new APIError(response.status, stringOrLegacy(await response.json()));
return response.json();
}
+export const cardModeMutationOptions = {
+ mutationKey: ["card", "mode"] as const,
+ mutationFn: setCardMode,
+};
+queryClient.setMutationDefaults(cardModeMutationOptions.mutationKey, {
+ ...cardModeMutationOptions,
+ onMutate: async (newMode: number) => {
+ await queryClient.cancelQueries({ queryKey: ["card", "details"] });
+ const previous = queryClient.getQueryData(["card", "details"]);
+ queryClient.setQueryData(["card", "details"], (old: CardDetails) => ({ ...old, mode: newMode }));
+ return { previous };
+ },
+ onError: (error, _, context) => {
+ if (context?.previous) queryClient.setQueryData(["card", "details"], context.previous);
+ reportError(error);
+ },
+ onSettled: async (data) => {
+ await queryClient.invalidateQueries({ queryKey: ["card", "details"] });
+ if (data && "mode" in data && data.mode > 0) queryClient.setQueryData(["settings", "installments"], data.mode);
+ },
+});
export async function setCardPIN(pin: string) {
await auth();
diff --git a/src/utils/useInstallmentRates.ts b/src/utils/useInstallmentRates.ts
new file mode 100644
index 000000000..85a44ef01
--- /dev/null
+++ b/src/utils/useInstallmentRates.ts
@@ -0,0 +1,73 @@
+import { useMemo } from "react";
+
+import { marketUSDCAddress } from "@exactly/common/generated/chain";
+import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
+import MIN_BORROW_INTERVAL from "@exactly/common/MIN_BORROW_INTERVAL";
+import {
+ fixedRate,
+ fixedUtilization,
+ globalUtilization,
+ MATURITY_INTERVAL,
+ splitInstallments,
+ WAD,
+} from "@exactly/lib";
+
+import reportError from "./reportError";
+import useAsset from "./useAsset";
+
+const AMOUNT = 100_000_000n;
+
+export default function useInstallmentRates() {
+ const { market } = useAsset(marketUSDCAddress);
+ return useMemo(() => {
+ if (!market) return;
+ try {
+ const timestamp = Math.floor(Date.now() / 1000);
+ const nextMaturity = timestamp - (timestamp % MATURITY_INTERVAL) + MATURITY_INTERVAL;
+ const firstMaturity =
+ nextMaturity - timestamp < MIN_BORROW_INTERVAL ? nextMaturity + MATURITY_INTERVAL : nextMaturity;
+ const { fixedPools, floatingUtilization, totalFloatingDepositAssets, totalFloatingBorrowAssets } = market;
+ const { floatingBackupBorrowed, interestRateModel } = market;
+ const uGlobal = globalUtilization(totalFloatingDepositAssets, totalFloatingBorrowAssets, floatingBackupBorrowed);
+ const { parameters } = interestRateModel;
+ const result: bigint[] = [];
+ const borrowImpact = totalFloatingDepositAssets > 0n ? (AMOUNT * WAD - 1n) / totalFloatingDepositAssets + 1n : 0n;
+ const uFixed1 =
+ fixedPools
+ .filter(({ maturity }) => maturity >= firstMaturity && maturity < firstMaturity + MATURITY_INTERVAL)
+ .map(({ supplied, borrowed }) => fixedUtilization(supplied, borrowed, totalFloatingDepositAssets))[0] ?? 0n;
+ result.push(
+ fixedRate(
+ firstMaturity,
+ fixedPools.length,
+ uFixed1 + borrowImpact,
+ floatingUtilization,
+ uGlobal + borrowImpact,
+ parameters,
+ timestamp,
+ ),
+ );
+ for (let count = 2; count <= MAX_INSTALLMENTS; count++) {
+ const uFixed = fixedPools
+ .filter(({ maturity }) => maturity >= firstMaturity && maturity < firstMaturity + count * MATURITY_INTERVAL)
+ .map(({ supplied, borrowed }) => fixedUtilization(supplied, borrowed, totalFloatingDepositAssets));
+ result.push(
+ splitInstallments(
+ AMOUNT,
+ totalFloatingDepositAssets,
+ firstMaturity,
+ fixedPools.length,
+ uFixed,
+ floatingUtilization,
+ uGlobal,
+ parameters,
+ timestamp,
+ ).effectiveRate,
+ );
+ }
+ return result;
+ } catch (error) {
+ reportError(error);
+ }
+ }, [market]);
+}
diff --git a/src/utils/usePortfolio.ts b/src/utils/usePortfolio.ts
index 0a1fb3e10..435631b93 100644
--- a/src/utils/usePortfolio.ts
+++ b/src/utils/usePortfolio.ts
@@ -94,6 +94,7 @@ export default function usePortfolio(account?: Hex, options?: { sortBy?: "usdcFi
.filter(({ floatingDepositAssets }) => floatingDepositAssets > 0n)
.map((market) => ({
...market,
+ symbol: market.symbol.slice(3) === "WETH" ? "ETH" : market.symbol.slice(3),
usdValue:
Number((withdrawLimit(markets, market.market) * market.usdPrice) / BigInt(10 ** market.decimals)) / 1e18,
type: "protocol" as const,
@@ -118,8 +119,8 @@ export default function usePortfolio(account?: Hex, options?: { sortBy?: "usdcFi
const combined = [...protocolAssets, ...externalAssets];
return combined.sort((a, b) => {
if (options?.sortBy === "usdcFirst") {
- const aSymbol = a.type === "protocol" ? a.symbol.slice(3) : a.symbol;
- const bSymbol = b.type === "protocol" ? b.symbol.slice(3) : b.symbol;
+ const aSymbol = a.symbol;
+ const bSymbol = b.symbol;
if (aSymbol === "USDC" && bSymbol !== "USDC") return -1;
if (bSymbol === "USDC" && aSymbol !== "USDC") return 1;
}
diff --git a/src/utils/useTabPress.ts b/src/utils/useTabPress.ts
index 803364f38..cd94b7a9f 100644
--- a/src/utils/useTabPress.ts
+++ b/src/utils/useTabPress.ts
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import reportError from "./reportError";
-type TabName = "activity" | "card" | "defi" | "index" | "pay-mode";
+type TabName = "activity" | "card" | "defi" | "index" | "pay";
const subscribers = new Map void>>();
diff --git a/tamagui.config.ts b/tamagui.config.ts
index 1b16a231f..a45cf4fd2 100644
--- a/tamagui.config.ts
+++ b/tamagui.config.ts
@@ -1,3 +1,5 @@
+import { Easing } from "react-native-reanimated";
+
import { createAnimations } from "@tamagui/animations-moti";
import { config } from "@tamagui/config/v3";
import { createFont, createTamagui, createTokens } from "tamagui";
@@ -190,9 +192,7 @@ const tokens = createTokens({
s0: 0,
true: 0,
s1: 2,
- s1_5: 3,
s2: 4,
- s2_5: 6,
s3: 8,
s3_5: 12,
s4: 16,
@@ -202,11 +202,11 @@ const tokens = createTokens({
s7: 40,
s8: 48,
s9: 64,
- s10: 104,
- s11: 120,
- s12: 144,
- s13: 160,
- s14: 184,
+ s10: 80,
+ s11: 96,
+ s12: 120,
+ s13: 144,
+ s14: 208,
},
radius: { r0: 0, true: 4, r1: 2, r2: 4, r3: 8, r4: 12, r5: 16, r6: 20, r_0: 9999 },
size: config.tokens.size,
@@ -215,36 +215,32 @@ const tokens = createTokens({
zIndex: config.tokens.zIndex,
});
+const sizes = config.fonts.body.size;
const body = createFont({
- family: "BDOGrotesk-Regular",
+ family: "SplineSans-Regular",
face: {
- 400: { normal: "BDOGrotesk-Regular" },
- 600: { normal: "BDOGrotesk-DemiBold" },
- 700: { normal: "BDOGrotesk-DemiBold" },
+ 400: { normal: "SplineSans-Regular" },
+ 600: { normal: "SplineSans-SemiBold" },
+ 700: { normal: "SplineSans-SemiBold" },
},
- size: config.fonts.body.size,
- weight: { regular: 400, semibold: 600 },
+ size: sizes,
+ lineHeight: Object.fromEntries(
+ Object.entries(sizes).map(([k, v]) => [k, Math.round(Number(v) * 1.3)]),
+ ) as typeof sizes,
+ letterSpacing: Object.fromEntries(Object.entries(sizes).map(([k, v]) => [k, Number(v) * -0.002])) as typeof sizes,
+ weight: { regular: 400, semibold: 600, bold: 700 },
});
const tamagui = createTamagui({
...config,
tokens,
- fonts: {
- body,
- heading: body,
- mono: createFont({
- family: "IBMPlexMono-Medm",
- face: { 500: { normal: "IBMPlexMono-Medm" } },
- weight: { medium: 500 },
- size: config.fonts.mono.size,
- }),
- },
+ fonts: { body, heading: body },
defaultFont: "body",
animations: createAnimations({
bouncy: { type: "spring", damping: 9, mass: 0.9, stiffness: 150 },
lazy: { type: "spring", damping: 18, stiffness: 50 },
slow: { type: "spring", damping: 15, stiffness: 40 },
- moderate: { type: "spring", damping: 15, mass: 0.2, stiffness: 100 },
+ default: { type: "timing", duration: 512, easing: Easing.bezier(0.7, 0, 0.3, 1) },
quick: { type: "spring", damping: 25, mass: 1.2, stiffness: 250 },
tooltip: { type: "spring", damping: 10, mass: 0.9, stiffness: 100 },
}),
@@ -389,9 +385,9 @@ const tamagui = createTamagui({
backgroundColor: "",
backgroundHover: "",
backgroundPress: "",
- borderColor: "",
- borderColorFocus: "",
- borderColorPress: "",
+ borderColor: "transparent",
+ borderColorFocus: "transparent",
+ borderColorPress: "transparent",
outlineColor: "",
},
dark: {
@@ -429,7 +425,7 @@ const tamagui = createTamagui({
uiWarningSecondary: tokens.color.feedbackWarningDark9,
uiWarningTertiary: tokens.color.feedbackWarningDark7,
uiInfoPrimary: tokens.color.feedbackInformationDark11,
- uiInfoSecondary: tokens.color.feedbackInformationLight9,
+ uiInfoSecondary: tokens.color.feedbackInformationDark9,
uiInfoTertiary: tokens.color.feedbackInformationDark7,
interactiveBaseBrandDefault: tokens.color.primaryDark9,
interactiveBaseBrandHover: tokens.color.primaryDark10,
@@ -438,8 +434,8 @@ const tamagui = createTamagui({
interactiveBaseBrandSoftHover: tokens.color.primaryDark4,
interactiveBaseBrandSoftPressed: tokens.color.primaryDark5,
interactiveBaseSuccessDefault: tokens.color.feedbackSuccessDark9,
- interactiveBaseSuccessHover: tokens.color.feedbackSuccessLight10,
- interactiveBaseSuccessPressed: tokens.color.feedbackSuccessLight11,
+ interactiveBaseSuccessHover: tokens.color.feedbackSuccessDark10,
+ interactiveBaseSuccessPressed: tokens.color.feedbackSuccessDark11,
interactiveBaseSuccessSoftDefault: tokens.color.feedbackSuccessDark3,
interactiveBaseSuccessSoftHover: tokens.color.feedbackSuccessDark4,
interactiveBaseSuccessSoftPressed: tokens.color.feedbackSuccessDark5,
@@ -485,7 +481,7 @@ const tamagui = createTamagui({
interactiveTextWarningHover: tokens.color.feedbackWarningDark10,
interactiveTextWarningPressed: tokens.color.feedbackWarningDark11,
interactiveTextInfoDefault: tokens.color.feedbackInformationDark9,
- interactiveTextInfoHover: tokens.color.feedbackInformationLight10,
+ interactiveTextInfoHover: tokens.color.feedbackInformationDark10,
interactiveTextInfoPressed: tokens.color.feedbackInformationDark11,
interactiveDisabled: tokens.color.grayscaleDark4,
interactiveOnDisabled: tokens.color.grayscaleDark8,
@@ -533,9 +529,9 @@ const tamagui = createTamagui({
backgroundColor: "",
backgroundHover: "",
backgroundPress: "",
- borderColor: "",
- borderColorFocus: "",
- borderColorPress: "",
+ borderColor: "transparent",
+ borderColorFocus: "transparent",
+ borderColorPress: "transparent",
outlineColor: "",
},
},
@@ -543,6 +539,6 @@ const tamagui = createTamagui({
export type Config = typeof tamagui;
declare module "tamagui" {
- interface TamaguiCustomConfig extends Config {} // eslint-disable-line @typescript-eslint/no-empty-interface
+ interface TamaguiCustomConfig extends Config {} // eslint-disable-line @typescript-eslint/no-empty-interface, @typescript-eslint/consistent-type-definitions
}
export default tamagui;