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/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/frank-schools-clap.md b/.changeset/frank-schools-clap.md new file mode 100644 index 000000000..ebe1d047d --- /dev/null +++ b/.changeset/frank-schools-clap.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 restructure sheets layout diff --git a/.changeset/giant-parrots-enter.md b/.changeset/giant-parrots-enter.md new file mode 100644 index 000000000..b4a81d2e1 --- /dev/null +++ b/.changeset/giant-parrots-enter.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 adjust home scroll view gaps 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/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/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/.maestro/flows/local.yaml b/.maestro/flows/local.yaml index 7d058821c..72f0941a3 100644 --- a/.maestro/flows/local.yaml +++ b/.maestro/flows/local.yaml @@ -24,5 +24,7 @@ tags: [critical] file: ../subflows/mint.yaml env: { asset: ETH, to: "${output.account}", amount: "0.1" } - runFlow: ../subflows/verifyIdentity.yaml +- runFlow: ../subflows/readBenefits.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..0f870de12 100644 --- a/.maestro/subflows/activateCard.yaml +++ b/.maestro/subflows/activateCard.yaml @@ -1,16 +1,13 @@ appId: ${APP_ID ?? "app.exactly"} --- - tapOn: Card -- runFlow: - when: { true: "${maestro.platform != 'web'}" } - commands: - - tapOn: \$[\s\d,.\xa0]+, AVAILABLE BALANCE -- runFlow: - when: { platform: web } - commands: [{ tapOn: Available balance }] +- tapOn: Card details +- waitForAnimationToEnd - runFlow: when: { visible: Accept and enable card } - commands: [{ tapOn: Accept and enable card }] + commands: + - runFlow: { file: scrollTo.yaml, env: { element: Accept and enable card } } + - tapOn: Accept and enable card - assertVisible: Manually add your card to Apple Pay & Google Pay to make contactless payments. - tapOn: Close - tapOn: Freeze card @@ -42,4 +39,26 @@ appId: ${APP_ID ?? "app.exactly"} text: \$[\s\d,.\xa0]+ above: Available balance - tapOn: Home -- assertVisible: SPENDING LIMIT +- runFlow: + when: { visible: Spending limit } + commands: + - tapOn: Later in 1 + - assertVisible: How installment repayment works + - tapOn: Close + - tapOn: Tap here to change the number of installments + - runFlow: { file: ../subflows/tapAria.yaml, env: { aria: Close } } +- runFlow: { file: scrollTo.yaml, env: { element: Learn more } } +- tapOn: + text: Learn more + rightOf: Exa Card pay mode +- assertVisible: Change the pay mode before each purchase and pay how you want. +- tapOn: Close +- runFlow: { file: scrollTo.yaml, env: { element: Credit limit info } } +- 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: Now +- runFlow: { file: scrollTo.yaml, env: { element: Spending limit info } } +- runFlow: { file: ../subflows/tapAria.yaml, env: { aria: Spending limit info } } +- assertVisible: It's based on the USDC available in your balance. +- tapOn: Close diff --git a/.maestro/subflows/borrow.yaml b/.maestro/subflows/borrow.yaml index 869426b66..d6433ebfd 100644 --- a/.maestro/subflows/borrow.yaml +++ b/.maestro/subflows/borrow.yaml @@ -7,7 +7,7 @@ appId: ${APP_ID ?? "app.exactly"} - tapOn: DeFi - runFlow: when: { visible: Explore decentralized services } - commands: [{ tapOn: "Explore decentralized services" }] + commands: [{ tapOn: 'Explore decentralized services' }] - tapOn: USDC funding - runFlow: when: { visible: Connect wallet to Exactly Protocol } @@ -32,7 +32,7 @@ appId: ${APP_ID ?? "app.exactly"} - runScript: ../dist/getAccount.js - runScript: file: ../dist/hookProposals.js - env: { account: "${output.account}" } + env: { account: '${output.account}' } - tapOn: Close - tapOn: Home - runFlow: diff --git a/.maestro/subflows/copyAria.yaml b/.maestro/subflows/copyAria.yaml new file mode 100644 index 000000000..8ab07a2fe --- /dev/null +++ b/.maestro/subflows/copyAria.yaml @@ -0,0 +1,12 @@ +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/readBenefits.yaml b/.maestro/subflows/readBenefits.yaml new file mode 100644 index 000000000..d683b4397 --- /dev/null +++ b/.maestro/subflows/readBenefits.yaml @@ -0,0 +1,33 @@ +appId: ${APP_ID ?? "app.exactly"} +--- +- repeat: + while: + notVisible: Copy your ID and get 30 days of travel insurance for free on Pax Assistance. + commands: + - extendedWaitUntil: + visible: 30 days of free travel insurance + timeout: 15000 + - tapOn: Get now +- assertVisible: "Copy Pax ID [a-z0-9]{10}" +- assertVisible: Get benefit +- runFlow: { file: tapAria.yaml, env: { aria: Close } } +- repeat: + while: + notVisible: Stay connected around the world. + commands: + - extendedWaitUntil: + visible: 20% OFF on eSims + timeout: 15000 + - tapOn: 20% OFF on eSims +- assertVisible: Get benefit +- runFlow: { file: tapAria.yaml, env: { aria: Close } } +- repeat: + while: + notVisible: Visa Signature Exa Card benefits + commands: + - extendedWaitUntil: + visible: Visa Signature benefits + timeout: 15000 + - tapOn: Learn more +- assertVisible: Go to Visa +- runFlow: { file: tapAria.yaml, env: { aria: Close } } diff --git a/.maestro/subflows/readHome.yaml b/.maestro/subflows/readHome.yaml new file mode 100644 index 000000000..db97fd8b2 --- /dev/null +++ b/.maestro/subflows/readHome.yaml @@ -0,0 +1,51 @@ +appId: ${APP_ID ?? "app.exactly"} +--- +- tapOn: Home +- 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 } } +- runFlow: + when: { true: "${maestro.platform != 'web'}" } + commands: + - assertNotVisible: + text: \$[\s\d,.\xa0]+ + below: Portfolio +- runFlow: + when: { platform: web } + commands: + - assertNotVisible: + id: \$[\s\d,.\xa0]+ +- runFlow: { file: tapAria.yaml, env: { aria: Show sensitive } } +- runFlow: + when: { true: "${maestro.platform != 'web'}" } + commands: + - assertVisible: + text: \$[\s\d,.\xa0]+ + below: Portfolio +- runFlow: + when: { platform: web } + commands: + - assertVisible: + id: \$[\s\d,.\xa0]+ +- runFlow: readPortfolio.yaml +- assertTrue: ${output.portfolio > 0} +- tapOn: Manage portfolio +- assertVisible: Your Portfolio +- runFlow: { file: tapAria.yaml, env: { aria: Back } } +- assertVisible: Add funds +- assertVisible: Send +- assertVisible: Swap +- assertNotVisible: Getting Started +- runFlow: { file: scrollTo.yaml, env: { element: Upcoming payments } } +- runFlow: { file: scrollTo.yaml, env: { element: Latest activity } } +- assertNotVisible: No activity yet +- tapOn: View all +- tapOn: Home +- tapOn: Home +- waitForAnimationToEnd +- assertVisible: 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 fd53a2bc6..d46403571 100644 --- a/.maestro/subflows/repay.yaml +++ b/.maestro/subflows/repay.yaml @@ -1,7 +1,7 @@ appId: ${APP_ID ?? "app.exactly"} --- - waitForAnimationToEnd -- scrollUntilVisible: { element: Upcoming payments } +- runFlow: { file: scrollTo.yaml, env: { element: Repay } } - copyTextFrom: text: \d+[,.]\d{2} below: Upcoming payments @@ -43,7 +43,7 @@ appId: ${APP_ID ?? "app.exactly"} file: ../subflows/tapWhileAria.yaml env: { aria: Pending proposals, tap: Home } - waitForAnimationToEnd -- scrollUntilVisible: { element: Upcoming payments } +- runFlow: { file: scrollTo.yaml, env: { element: Repay } } - runFlow: when: { true: "${!amount}" } commands: [{ assertNotVisible: "${output.maturity}" }] diff --git a/.maestro/subflows/rollover.yaml b/.maestro/subflows/rollover.yaml index 5ed03f68b..08e8122d7 100644 --- a/.maestro/subflows/rollover.yaml +++ b/.maestro/subflows/rollover.yaml @@ -1,7 +1,7 @@ appId: ${APP_ID ?? "app.exactly"} --- - waitForAnimationToEnd -- scrollUntilVisible: { element: Upcoming payments } +- runFlow: { file: scrollTo.yaml, env: { element: Repay, offset: 1 } } - copyTextFrom: text: \d+[,.]\d{2} below: Upcoming payments @@ -28,7 +28,7 @@ appId: ${APP_ID ?? "app.exactly"} file: ../subflows/tapWhileAria.yaml env: { aria: Pending proposals, tap: Home } - waitForAnimationToEnd -- scrollUntilVisible: { element: Upcoming payments } +- runFlow: { file: scrollTo.yaml, env: { element: Repay } } - copyTextFrom: text: \d+[,.]\d{2} below: Upcoming payments diff --git a/.maestro/subflows/scrollTo.yaml b/.maestro/subflows/scrollTo.yaml new file mode 100644 index 000000000..2eea5383b --- /dev/null +++ b/.maestro/subflows/scrollTo.yaml @@ -0,0 +1,46 @@ +appId: ${APP_ID ?? "app.exactly"} +--- +# HACK https://github.com/mobile-dev-inc/maestro/issues/1775 +# maestro web uses window.scroll() which doesn't reach inner scroll containers +- runFlow: + when: { true: "${maestro.platform != 'web'}" } + commands: + - scrollUntilVisible: { element: "${element}" } +- runFlow: + when: { platform: web } + commands: + - evalScript: ${output.scrollDeadline = Date.now() + 30000} + - evalScript: ${output.found = false} + - repeat: + while: + true: "${!output.found}" + commands: + - runFlow: + when: { visible: "${element}" } + commands: [{ evalScript: "${output.found = true}" }] + - runFlow: + when: { visible: { id: "${element}" } } + commands: [{ evalScript: "${output.found = true}" }] + - runFlow: + when: { true: "${!output.found}" } + commands: + - runFlow: + when: { true: "${Date.now() >= output.scrollDeadline}" } + commands: [{ evalScript: "${throw new Error('scroll timeout')}" }] + - swipe: + start: "50%,40%" + end: "50%,10%" + duration: 500 +- runFlow: + when: { true: "${typeof offset !== 'undefined' && Number(offset) > 0}" } + commands: + - evalScript: ${output.scrollExtra = 0} + - repeat: + while: + true: "${output.scrollExtra < Number(offset)}" + commands: + - swipe: + start: "50%,70%" + end: "50%,50%" + duration: 300 + - evalScript: ${output.scrollExtra = output.scrollExtra + 1} diff --git a/app.config.ts b/app.config.ts index bdbe41dd7..68a55d386 100644 --- a/app.config.ts +++ b/app.config.ts @@ -79,11 +79,7 @@ export default { [ "expo-font", { - fonts: [ - "src/assets/fonts/BDOGrotesk-DemiBold.otf", - "src/assets/fonts/BDOGrotesk-Regular.otf", - "src/assets/fonts/IBMPlexMono-Medm.otf", - ], + fonts: ["src/assets/fonts/SplineSans-Regular.otf", "src/assets/fonts/SplineSans-SemiBold.otf"], } satisfies FontProps, ], "expo-asset", diff --git a/cspell.json b/cspell.json index 06bdd5fc3..e533b8c96 100644 --- a/cspell.json +++ b/cspell.json @@ -50,7 +50,6 @@ "decisioned", "defi", "delegatecall", - "demi", "deployless", "dieguezguille", "dismissable", @@ -73,12 +72,10 @@ "gitmoji", "gitmojis", "graaljs", - "grotesk", "hdpi", "hexlify", "hideable", "hono", - "IBMPlexMono-Medm", "IERC", "indoc", "infinitism", @@ -151,6 +148,7 @@ "solmate", "sourcify", "spkg", + "splinesans", "spotlightjs", "staticcall", "streamingfast", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 0ceb0cc2f..13245326d 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -31,9 +31,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"; @@ -160,9 +159,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 ebf8d3c78..722e28791 100644 --- a/src/components/activity/Activity.tsx +++ b/src/components/activity/Activity.tsx @@ -77,7 +77,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..ad8a9edac 100644 --- a/src/components/add-funds/AddCrypto.tsx +++ b/src/components/add-funds/AddCrypto.tsx @@ -72,16 +72,18 @@ export default function AddCrypto() { - + {t("Your {{chain}} address", { chain: chain.name })} - {address && ( - - {shortenHex(address, 10, 12)} - - )} + {address && {shortenHex(address, 10, 12)}} - - - - + {acknowledged && } + + + {terms} + + + + ); diff --git a/src/components/defi/IntroSheet.tsx b/src/components/defi/IntroSheet.tsx index c4df59dd4..967cfb8c7 100644 --- a/src/components/defi/IntroSheet.tsx +++ b/src/components/defi/IntroSheet.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Pressable } from "react-native"; import { ArrowRight, Info, X } from "@tamagui/lucide-icons"; -import { ScrollView, XStack, YStack } from "tamagui"; +import { XStack, YStack } from "tamagui"; import Defi from "../../assets/images/defi.svg"; import { presentArticle } from "../../utils/intercom"; @@ -17,7 +17,7 @@ import View from "../shared/View"; export default function IntroSheet({ open, onClose }: { onClose: () => void; open: boolean }) { const { t } = useTranslation(); return ( - + void; ope - - - - - - - - - - {t("Welcome to DeFi")} - - - {t("Access decentralized services provided by third-party DeFi protocols.")} - - - - - - - - - - { - presentArticle("11731646").catch(reportError); - }} - /> - ), - }} - /> - - - - + + + + + + + + {t("Welcome to DeFi")} + + + {t("Access decentralized services provided by third-party DeFi protocols.")} + - - + + + + + + + + { + presentArticle("11731646").catch(reportError); + }} + /> + ), + }} + /> + + + + + ); diff --git a/src/components/getting-started/GettingStarted.tsx b/src/components/getting-started/GettingStarted.tsx index f6e2be629..e71e94890 100644 --- a/src/components/getting-started/GettingStarted.tsx +++ b/src/components/getting-started/GettingStarted.tsx @@ -44,9 +44,9 @@ export default function GettingStarted() { const { steps } = useOnboardingSteps({ hasKYC, isDeployed }); return ( - - - + + + - + + @@ -46,7 +45,7 @@ function AssetRow({ asset }: { asset: AssetItem }) { - + {rate === undefined ? ( asset.market ? ( <> @@ -73,7 +72,7 @@ function AssetRow({ asset }: { asset: AssetItem }) { )} - + {`$${(Number(usdValue) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} @@ -94,7 +93,7 @@ function AssetRow({ asset }: { asset: AssetItem }) { function AssetSection({ title, assets }: { assets: AssetItem[]; title: string }) { if (assets.length === 0) return null; return ( - + {title} diff --git a/src/components/home/CardLimits.tsx b/src/components/home/CardLimits.tsx deleted file mode 100644 index 6c78b5a75..000000000 --- a/src/components/home/CardLimits.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { useRouter } from "expo-router"; - -import { ChevronRight, Info } from "@tamagui/lucide-icons"; -import { XStack, YStack } from "tamagui"; - -import { useQuery } from "@tanstack/react-query"; - -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; -import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; -import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib"; - -import useAccount from "../../utils/useAccount"; -import AssetLogo from "../shared/AssetLogo"; -import Text from "../shared/Text"; - -import type { CardDetails } from "../../utils/server"; - -export default function CardLimits({ onPress }: { onPress: () => void }) { - const { - t, - i18n: { language }, - } = useTranslation(); - const { address } = useAccount(); - const router = useRouter(); - const { data: card } = useQuery({ queryKey: ["card", "details"] }); - const { data: markets } = useReadPreviewerExactly({ - address: previewerAddress, - args: address ? [address] : undefined, - query: { enabled: !!address }, - }); - const isCredit = card ? card.mode > 0 : false; - return ( - - - - {isCredit ? null : } - - {`$${(markets - ? Number( - isCredit ? borrowLimit(markets, marketUSDCAddress) : withdrawLimit(markets, marketUSDCAddress, WAD), - ) / 1e6 - : 0 - ).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {t("Spending limit")} - - - - - { - router.push("/pay-mode"); - }} - > - - {isCredit ? t("{{count}} installments", { count: card?.mode }) : t("Pay Now")} - - - - - ); -} diff --git a/src/components/home/CardStatus.tsx b/src/components/home/CardStatus.tsx index 59e67a58d..a0f53152e 100644 --- a/src/components/home/CardStatus.tsx +++ b/src/components/home/CardStatus.tsx @@ -1,66 +1,334 @@ -import React from "react"; -import { Platform } from "react-native"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, Pressable, StyleSheet, type View as RNView } from "react-native"; +import Animated, { + Easing, + interpolate, + interpolateColor, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; -import { useRouter } from "expo-router"; +import { selectionAsync } from "expo-haptics"; -import { XStack, YStack } from "tamagui"; +import { CalendarDays, ChevronRight, CreditCard, Info, Wallet, Zap } from "@tamagui/lucide-icons"; +import { useTheme, View, XStack, YStack } from "tamagui"; -import { SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; +import { useQuery } from "@tanstack/react-query"; -import CardLimits from "./CardLimits"; -import SignatureCard from "../../assets/images/card-signature.svg"; -import Card from "../../assets/images/card.svg"; +import CardBg from "../../assets/images/card-bg.svg"; +import Exa from "../../assets/images/exa.svg"; +import reportError from "../../utils/reportError"; +import Amount from "../shared/Amount"; +import Text from "../shared/Text"; -export default function CardStatus({ onInfoPress, productId }: { onInfoPress: () => void; productId: string }) { - const router = useRouter(); +export default function CardStatus({ + collateral, + creditLimit, + spotlightRef, + mode, + onCreditLimitInfoPress, + onDetailsPress, + onInstallmentsPress, + onLearnMorePress, + onModeChange, + onSpendingLimitInfoPress, + spendingLimit, +}: { + collateral: bigint; + creditLimit: bigint; + mode: number; + onCreditLimitInfoPress: () => void; + onDetailsPress: () => void; + onInstallmentsPress: () => void; + onLearnMorePress: () => void; + onModeChange: (mode: number) => void; + onSpendingLimitInfoPress: () => void; + spendingLimit: bigint; + spotlightRef?: React.RefObject; +}) { + const { t } = useTranslation(); return ( - - - + + + {t("Exa Card pay mode")} + + + + {t("Learn more")} + + + + + + { + selectionAsync().catch(reportError); + onDetailsPress(); + }} + > + + + + + + + + + {t("Details")} + + + + + - + + ); +} + +function PayModeToggle({ + spotlightRef, + mode, + onInstallmentsPress, + onModeChange, +}: { + mode: number; + onInstallmentsPress: () => void; + onModeChange: (mode: number) => void; + spotlightRef?: React.RefObject; +}) { + const { t } = useTranslation(); + const theme = useTheme(); + const { data: lastInstallments } = useQuery({ queryKey: ["settings", "installments"] }); + const isDebit = mode === 0; + const [width, setWidth] = useState(0); + const neutral = theme.uiNeutralSecondary.val; + const progress = useSharedValue(isDebit ? 0 : 1); + const [nowColor, setNowColor] = useState(isDebit ? theme.cardDebitText.val : neutral); + const [laterColor, setLaterColor] = useState(isDebit ? neutral : theme.cardCreditText.val); + const [borderColor, setBorderColor] = useState( + isDebit ? theme.cardDebitInteractive.val : theme.cardCreditInteractive.val, + ); + useEffect(() => { + progress.value = withTiming(isDebit ? 0 : 1, { duration: 512, easing: Easing.bezier(0.7, 0, 0.3, 1) }); + }, [isDebit, progress]); + /* istanbul ignore next */ + const pillStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.value, + [0, 1], + [theme.cardDebitInteractive.val, theme.cardCreditInteractive.val], + ), + transform: [{ translateX: interpolate(progress.value, [0, 1], [0, width / 2]) }], + })); + /* istanbul ignore next */ + useAnimatedReaction( + () => progress.value, + (value) => { + scheduleOnRN(setNowColor, interpolateColor(value, [0, 1], [theme.cardDebitText.val, neutral])); + scheduleOnRN(setLaterColor, interpolateColor(value, [0, 1], [neutral, theme.cardCreditText.val])); + scheduleOnRN( + setBorderColor, + interpolateColor(value, [0, 1], [theme.cardDebitInteractive.val, theme.cardCreditInteractive.val]), + ); + }, + [ + theme.cardDebitText.val, + theme.cardCreditText.val, + theme.cardDebitInteractive.val, + theme.cardCreditInteractive.val, + theme.cardDebitBorder.val, + theme.cardCreditBorder.val, + neutral, + ], + ); + return ( + { + setWidth(event.nativeEvent.layout.width); + }} + > + + { - router.push("/card"); + if (isDebit) return; + selectionAsync().catch(reportError); + onModeChange(0); }} > - {productId === SIGNATURE_PRODUCT_ID ? ( - - ) : ( - - )} - + + + + {t("Now")} + + + + { + selectionAsync().catch(reportError); + if (mode > 0) onInstallmentsPress(); + else onModeChange(lastInstallments ?? 1); + }} + > + + + + {t("Later in {{count}}", { count: mode > 0 ? mode : (lastInstallments ?? 1) })} + + + ); } + +function LimitPaginator({ + collateral, + creditLimit, + mode, + onCreditLimitInfoPress, + onSpendingLimitInfoPress, + spendingLimit, +}: { + collateral: bigint; + creditLimit: bigint; + mode: number; + onCreditLimitInfoPress: () => void; + onSpendingLimitInfoPress: () => void; + spendingLimit: bigint; +}) { + const { + t, + i18n: { language }, + } = useTranslation(); + const [width, setWidth] = useState(0); + return ( + setWidth(event.nativeEvent.layout.width)} + > + 0 ? -width : 0} animation="default" animateOnly={["transform"]}> + + + + + {t("Spending limit")} + + + + + + + + $ + + + {(Number(spendingLimit) / 1e6).toLocaleString(language, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + + + + + + + {t("Credit limit")} + + + + + + + {t("Collateral {{value}}", { + value: `$${(Number(collateral) / 1e18).toLocaleString(language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, + })} + + + + + $ + + + {(Number(creditLimit) / 1e6).toLocaleString(language, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + + + + ); +} + +const styles = StyleSheet.create({ + details: { position: "absolute", top: 8, right: 8 }, + exa: { position: "absolute", top: 12, left: 16 }, + learnMore: { flexDirection: "row", alignItems: "center", gap: 4 }, + pill: { position: "absolute", top: 2, bottom: 2, left: 2, right: 2, borderRadius: 9999 }, + segment: { flex: 1, justifyContent: "center", alignItems: "center" }, +}); diff --git a/src/components/home/CreditLimitSheet.tsx b/src/components/home/CreditLimitSheet.tsx new file mode 100644 index 000000000..56d5891b3 --- /dev/null +++ b/src/components/home/CreditLimitSheet.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; + +import { ExternalLink, X } from "@tamagui/lucide-icons"; +import { XStack, YStack } from "tamagui"; + +import { presentArticle } from "../../utils/intercom"; +import reportError from "../../utils/reportError"; +import Button from "../shared/Button"; +import ModalSheet from "../shared/ModalSheet"; +import Text from "../shared/Text"; + +export default function CreditLimitSheet({ onClose, open }: { onClose: () => void; open: boolean }) { + const { t } = useTranslation(); + return ( + + + + + + + {t("Credit limit")} + + + + + + + + }} + /> + + + {t("It's based on the value of your collateral assets and updates as their value changes.")} + + + + + + + + + {t("Close")} + + + + + + ); +} diff --git a/src/components/home/GettingStarted.tsx b/src/components/home/GettingStarted.tsx index 61b6fae52..577f817d0 100644 --- a/src/components/home/GettingStarted.tsx +++ b/src/components/home/GettingStarted.tsx @@ -40,7 +40,7 @@ export default function GettingStarted({ isDeployed, hasKYC }: { hasKYC: boolean borderRadius="$r3" opacity={1} transform={[{ translateY: 0 }]} - animation="moderate" + animation="default" animateOnly={["opacity", "transform"]} enterStyle={{ opacity: 0, transform: [{ translateY: -20 }] }} exitStyle={{ opacity: 0, transform: [{ translateY: -20 }] }} @@ -50,7 +50,7 @@ export default function GettingStarted({ isDeployed, hasKYC }: { hasKYC: boolean {t("Getting Started")} - + { diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 181c18031..8b59bfaca 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -1,40 +1,51 @@ -import React, { useRef, useState } from "react"; +import React, { useCallback, 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 { useFocusEffect, 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 { 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 { setCardMode } from "../../utils/server"; import useAccount from "../../utils/useAccount"; import usePortfolio from "../../utils/usePortfolio"; import useTabPress from "../../utils/useTabPress"; import BenefitsSection from "../benefits/BenefitsSection"; +import ManualRepaymentSheet from "../pay-mode/ManualRepaymentSheet"; import OverduePayments from "../pay-mode/OverduePayments"; import PaymentSheet from "../pay-mode/PaymentSheet"; import UpcomingPayments from "../pay-mode/UpcomingPayments"; @@ -57,8 +68,24 @@ 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 [manualRepaymentSheetOpen, setManualRepaymentSheetOpen] = useState(false); + const pendingModeRef = useRef(0); + + const [focused, setFocused] = useState(false); + useFocusEffect( + useCallback(() => { + setFocused(true); + return () => { + setFocused(false); + }; + }, []), + ); + const spotlightRef = useRef(null); const { address: account } = useAccount(); const { data: credential } = useQuery({ queryKey: ["credential"] }); @@ -73,8 +100,9 @@ export default function Home() { query: { enabled: !!account && !!credential }, }); const { - portfolio: { balanceUSD, depositMarkets }, + portfolio: { balanceUSD }, averageRate, + assets, totalBalanceUSD, } = usePortfolio(); @@ -111,8 +139,51 @@ 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({ + 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); + }, + }); + + const { data: manualRepaymentAcknowledged } = useQuery({ queryKey: ["manual-repayment-acknowledged"] }); + function handleModeChange(mode: number) { + if (mode === 0 || manualRepaymentAcknowledged) { + mutateMode(mode).catch(reportError); + return; + } + pendingModeRef.current = mode; + setManualRepaymentSheetOpen(true); + } + + const collateralUSD = useMemo( + () => + markets?.reduce( + (total, market) => + total + + (market.floatingDepositAssets > 0n + ? (market.floatingDepositAssets * market.usdPrice) / 10n ** BigInt(market.decimals) + : 0n), + 0n, + ) ?? 0n, + [markets], + ); const scrollRef = useRef(null); + const scrollOffsetRef = useRef(0); const refresh = () => { queryClient.invalidateQueries({ queryKey: ["activity"], exact: true }).catch(reportError); queryClient.invalidateQueries({ queryKey: ["kyc", "status"], exact: true }).catch(reportError); @@ -137,10 +208,14 @@ export default function Home() { backgroundColor="transparent" contentContainerStyle={{ backgroundColor: "$backgroundMild" }} showsVerticalScrollIndicator={false} + scrollEventThrottle={16} + onScroll={(event) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }} refreshControl={} > - + {markets && healthFactor(markets) < HEALTH_FACTOR_THRESHOLD && } {(showKYCMigration || showPluginOutdated) && ( @@ -161,40 +236,61 @@ export default function Home() { }} /> )} - + - - - {card && ( - { - setSpendingLimitsInfoSheetOpen(true); + {(card ?? (isKYCFetched && (!isKYCApproved || !bytecode))) && ( + + + {card && ( + { + setCreditLimitSheetOpen(true); + }} + onDetailsPress={() => { + router.push("/card"); + }} + onInstallmentsPress={() => { + setInstallmentsSheetOpen(true); + }} + onLearnMorePress={() => { + setPayModeSheetOpen(true); + }} + onModeChange={handleModeChange} + onSpendingLimitInfoPress={() => { + setSpendingLimitSheetOpen(true); + }} + spendingLimit={markets ? withdrawLimit(markets, marketUSDCAddress, WAD) : 0n} + /> + )} + + {card?.productId === PLATINUM_PRODUCT_ID && ( + { + setVisaSignatureModalOpen(true); }} - productId={card.productId} /> )} - - {card?.productId === PLATINUM_PRODUCT_ID && ( - { - setVisaSignatureModalOpen(true); - }} - /> - )} - - {isKYCFetched && (!isKYCApproved || !bytecode) && ( - - )} - - {isKYCFetched && isKYCApproved && } + + {isKYCFetched && (!isKYCApproved || !bytecode) && ( + + )} + + + )} + {isKYCFetched && isKYCApproved && } + router.setParams({ maturity: String(m) })} /> router.setParams({ maturity: String(m) })} /> @@ -209,10 +305,42 @@ export default function Home() { queryClient.resetQueries({ queryKey: ["card-upgrade"] }).catch(reportError); }} /> - { + setInstallmentsSheetOpen(false); + }} + onModeChange={handleModeChange} + /> + { + setCreditLimitSheetOpen(false); + }} + /> + { + setManualRepaymentSheetOpen(false); + }} + onActionPress={() => { + queryClient.setQueryData(["manual-repayment-acknowledged"], true); + mutateMode(pendingModeRef.current).catch(reportError); + setManualRepaymentSheetOpen(false); + }} + penaltyRate={markets?.find(({ market }) => market === marketUSDCAddress)?.penaltyRate} + /> + { + setPayModeSheetOpen(false); + }} + /> + { - setSpendingLimitsInfoSheetOpen(false); + setSpendingLimitSheetOpen(false); }} /> + {card && card.mode > 0 && !spotlightShown && focused && ( + { + queryClient.setQueryData(["settings", "installments-spotlight"], true); + }} + onPress={() => { + setInstallmentsSheetOpen(true); + }} + /> + )} diff --git a/src/components/home/HomeActions.tsx b/src/components/home/HomeActions.tsx index e8ceff87b..e5f8d9085 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 { useBytecode, useReadContract } from "wagmi"; @@ -32,8 +32,9 @@ export default function HomeActions() { () => [ { key: "deposit", title: t("Add funds"), Icon: ArrowDownToLine }, { key: "send", title: t("Send"), Icon: ArrowUpRight }, + ...(bytecode ? [{ key: "swap" as const, title: t("Swap"), Icon: Repeat }] : []), ], - [t], + [bytecode, t], ); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ @@ -79,33 +80,44 @@ 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..eb708ba94 --- /dev/null +++ b/src/components/home/InstallmentsSheet.tsx @@ -0,0 +1,181 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable } from "react-native"; +import Carousel, { type ICarouselInstance } from "react-native-reanimated-carousel"; + +import { selectionAsync } from "expo-haptics"; + +import { Check, X } from "@tamagui/lucide-icons"; +import { View, 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"; + +export default function InstallmentsSheet({ + mode, + onClose, + onModeChange, + open, +}: { + mode: number; + onClose: () => void; + onModeChange: (mode: number) => void; + open: boolean; +}) { + 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 carouselRef = useRef(null); + const rates = useInstallmentRates(); + const [width, setWidth] = useState(0); + const handleLayout = useCallback((event: { nativeEvent: { layout: { width: number } } }) => { + setWidth(event.nativeEvent.layout.width); + }, []); + const perPage = Math.max(1, Math.floor((width - 2 * PADDING + GAP) / (CARD_SIZE + GAP))); + const pageWidth = perPage * (CARD_SIZE + GAP); + const pages: number[][] = []; + for (let index = 0; index < INSTALLMENTS.length; index += perPage) { + pages.push(INSTALLMENTS.slice(index, index + perPage)); + } + return ( + + + + + + + + {t("Set installments")} + + + + + + + {t( + "Choose how many installments to use for future card purchases. You can always change this before each purchase.", + )} + + + + {width === 0 ? undefined : ( + ( + + {page.map((installment) => { + const isSelected = selected === installment; + return ( + { + setSelected(installment); + selectionAsync().catch(reportError); + const target = Math.floor((installment - 1) / perPage); + if (target !== carouselRef.current?.getCurrentIndex()) { + requestAnimationFrame(() => + carouselRef.current?.scrollTo({ index: target, animated: true }), + ); + } + }} + > + + {installment} + + {rates ? ( + rates.installments[installment - 1]?.payments === undefined ? ( + + {t("N/A")} + + ) : ( + + {t("{{apr}} APR", { + apr: (Number(rates.installments[installment - 1]?.rate) / 1e18).toLocaleString( + language, + { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 }, + ), + })} + + ) + ) : ( + + )} + + ); + })} + + )} + /> + )} + + + + + + + + + ); +} + +const CARD_SIZE = 104; +const GAP = 8; +const PADDING = 24; +const INSTALLMENTS = Array.from({ length: MAX_INSTALLMENTS }, (_, index) => index + 1); diff --git a/src/components/home/InstallmentsSpotlight.tsx b/src/components/home/InstallmentsSpotlight.tsx new file mode 100644 index 000000000..15236d3ae --- /dev/null +++ b/src/components/home/InstallmentsSpotlight.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Modal, + Platform, + Pressable, + StatusBar, + 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: windowHeight } = useWindowDimensions(); + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + const screenHeight = windowHeight + statusBarHeight; + const [target, setTarget] = useState<{ height: number; width: number; x: number; y: number }>(); + useEffect(() => { + let scrolled = false; + let attempts = 0; + const id = setInterval(() => { + if (++attempts > 10) { + clearInterval(id); + return; + } + targetRef.current?.measureInWindow((x, y, width, height) => { + if (width > 0 && height > 0 && y >= 0 && y + height <= screenHeight) { + clearInterval(id); + setTarget({ x, y, width, height }); + return; + } + if (!scrolled) { + scrolled = true; + 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 }); + } + } + }); + }, 500); + return () => clearInterval(id); + }, [screenHeight, scrollOffset, scrollRef, targetRef]); + if (!target) return null; + const cutout = { + x: target.x - 8, + y: target.y - 8 + statusBarHeight, + 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(); + }} + /> + + { + 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 6f44a1e50..acf3c7153 100644 --- a/src/components/home/Portfolio.tsx +++ b/src/components/home/Portfolio.tsx @@ -40,8 +40,16 @@ export default function Portfolio() { return ( - + { if (router.canGoBack()) { router.back(); @@ -86,7 +94,6 @@ export default function Portfolio() { { - router.push("/portfolio"); - }} - > - - - {t("Your portfolio")} + router.push("/portfolio")}> + + + {t("Portfolio")} - - - - {`$${(Number(totalBalanceUSD) / 1e18).toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - {country === "US" && processingBalance ? ( - { - router.push("/activity"); - }} - gap="$s2" - alignItems="center" - > - - {t("Processing balance {{amount}}", { - amount: `$${processingBalance.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, - })} + + + {t("Manage portfolio")} - + - ) : balanceUSD > 0n ? ( - - ) : null} + + + + + {country === "US" && 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 05d5f7da8..6fde66ff9 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) => ({ ...old, amount: undefined })); diff --git a/src/components/loans/Asset.tsx b/src/components/loans/Asset.tsx index ae54952ec..82cd16ef6 100644 --- a/src/components/loans/Asset.tsx +++ b/src/components/loans/Asset.tsx @@ -34,7 +34,14 @@ export default function Asset() { }); return ( - + { if (router.canGoBack()) { @@ -93,7 +100,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 b75bf6edc..65a2a6464 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/Maturity.tsx b/src/components/loans/Maturity.tsx index c7d88bcb4..c7bcc80bd 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) => ({ ...old, maturity: undefined })); diff --git a/src/components/loans/Receiver.tsx b/src/components/loans/Receiver.tsx index 9f0d12354..4b32f6084 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) => ({ ...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 8f49a47ff..40332a770 100644 --- a/src/components/loans/Review.tsx +++ b/src/components/loans/Review.tsx @@ -199,7 +199,7 @@ export default function Review() { - + { @@ -593,7 +593,7 @@ export default function Pay() { {t("Subtotal")} - + {isRouteFetching ? ( @@ -670,7 +670,7 @@ export default function Pay() { {t("You will pay")} - + {isRouteFetching ? ( diff --git a/src/components/pay-mode/PaySelector.tsx b/src/components/pay-mode/PaySelector.tsx index 1c974f61f..9f47abfd3 100644 --- a/src/components/pay-mode/PaySelector.tsx +++ b/src/components/pay-mode/PaySelector.tsx @@ -115,7 +115,7 @@ export default function PaySelector() { <> - + {t("Pay Mode")} @@ -191,7 +191,7 @@ export default function PaySelector() { - + - + {Array.from({ length: MAX_INSTALLMENTS }, (_, index) => index + 1).map((installment) => ( )} - - + +