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")} )} + + + {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, + }), + })} + + )} + + ); + })} + + + + + { + 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 }, + )} + + + + + + + + + {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.")} + + + + + + + + + {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.")} - - - - - { - 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)} + title={t("Early repayment discount")} + > + + {t( + "You can repay early and save on interest. The final amount updates automatically before you confirm.", + )} + + + 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.")} + + + 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} + + + + + + + + + + + + + ); +} 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")} - + ); } @@ -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("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")} + + + )} - - + + + + + + + + + + + ); } @@ -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 ( - + {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;