diff --git a/CLAUDE.md b/CLAUDE.md index ed8f870d1..ec7fa3d9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ This is a **Bun monorepo** using workspaces: ## Essential Commands +> Always use `bun`- never `npm`, `yarn`, or `pnpm`. Run `bun lint:fix` after any code change. + ```bash # Install all dependencies bun install @@ -70,7 +72,7 @@ Phase metadata and valid transitions are stored in PostgreSQL and seeded via `se ### Frontend Architecture - **State**: Zustand stores (`stores/`) + React Context (`contexts/`) -- **Forms**: React Hook Form with Yup validation +- **Forms**: React Hook Form with Zod validation (not Yup) - **Data Fetching**: TanStack Query - **Routing**: TanStack Router (route tree auto-generated in `routeTree.gen.ts`) - **State Machines**: XState machines in `machines/` for complex flows (KYC, ramp process) @@ -95,6 +97,8 @@ Contains cross-package utilities: **Important**: Always rebuild shared when making changes: `bun build:shared` +After ANY change to `packages/shared`, run `bun build:shared` before running frontend/api. + ## Code Style Guidelines From `.clinerules/`: @@ -110,6 +114,11 @@ From `.clinerules/`: - Extract complex conditional rendering into new components - Skip useless comments; only comment race conditions, TODOs, or genuinely confusing code +### XState v5 +- Use `setup({ ... }).createMachine(...)` API- not `createMachine` directly +- Actor refs from `useActor` / `useSelector` from `@xstate/react` +- Machine files live in `apps/frontend/src/machines/` + ### Biome Configuration - Line width: 128 - Indent: 2 spaces @@ -118,6 +127,22 @@ From `.clinerules/`: - Quote style: double - Sorted Tailwind classes enforced via `useSortedClasses` rule +## Token Exhaustiveness + +`FiatToken` currently has 6 values: `EURC`, `ARS`, `BRL`, `USD`, `MXN`, `COP`. + +Any `Record` must include ALL six. Missing entries cause TypeScript errors +when shared is rebuilt. Check: tokenAvailability, mapFiatToDestination, success page +ARRIVAL_TEXT_BY_TOKEN, sep10 tokenMapping. + +## No Over-Engineering + +- Don't add features, refactors, or "improvements" beyond what was asked +- Don't add docstrings/comments to code you didn't touch +- Don't create helpers/utilities for one-time operations +- Don't validate inputs that can't be invalid (internal calls, typed params) +- Three similar lines is better than a premature abstraction + ## Testing ### Backend Integration Tests diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 6cc387042..1feafa3b0 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -6,14 +6,10 @@ import { AlfredpayCreateCustomerResponse, AlfredpayCustomerType, AlfredpayGetKybRedirectLinkResponse, - AlfredpayGetKybStatusResponse, AlfredpayGetKycRedirectLinkRequest, AlfredpayGetKycRedirectLinkResponse, - AlfredpayGetKycStatusRequest, AlfredpayGetKycStatusResponse, AlfredpayKybStatus, - AlfredpayKycRedirectFinishedRequest, - AlfredpayKycRedirectOpenedRequest, AlfredpayKycStatus, AlfredpayStatusRequest, AlfredpayStatusResponse @@ -258,10 +254,8 @@ export class AlfredpayController { : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); const newStatus = AlfredpayController.mapKycStatus(statusResponse.status); - console.log("newStatus", newStatus); const updateData: Partial = {}; - console.log("our status", alfredPayCustomer.status); if (newStatus && newStatus !== alfredPayCustomer.status) { updateData.status = newStatus; } diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index fea9b6b44..441c7a7c4 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -8,7 +8,6 @@ import { getNetworkId, getOnChainTokenDetails, getRoute, - isEvmToken, multiplyByPowerOfTen, Networks, RampCurrency, diff --git a/apps/api/src/api/services/sep10/sep10.service.ts b/apps/api/src/api/services/sep10/sep10.service.ts index 360b9352c..9de083b1a 100644 --- a/apps/api/src/api/services/sep10/sep10.service.ts +++ b/apps/api/src/api/services/sep10/sep10.service.ts @@ -34,7 +34,9 @@ export const signSep10Challenge = async ( [FiatToken.EURC]: "EURC", [FiatToken.ARS]: "ARS", [FiatToken.BRL]: "BRL", - [FiatToken.USD]: "USDC" // USD maps to USDC for consistency, though not used for SEP10 + [FiatToken.USD]: "USDC", + [FiatToken.MXN]: "USDC", + [FiatToken.COP]: "USDC" }; const outToken = tokenMapping[fiatToken]; diff --git a/apps/frontend/App.css b/apps/frontend/App.css index 32a2e9c11..91c2d71a9 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -50,6 +50,7 @@ :root:has(:is(.modal-open, .modal:target, .modal-toggle:checked + .modal, .modal[open])) { scrollbar-gutter: unset; + overflow-y: scroll; } } @@ -123,6 +124,7 @@ .btn { height: 3rem; box-shadow: none; + cursor: pointer !important; } .btn-vortex-primary { @@ -135,7 +137,7 @@ } .btn-vortex-primary:hover { - @apply bg-blue-200 text-blue-700; + @apply bg-blue-100 text-blue-700; } .btn-vortex-primary:disabled { @@ -162,10 +164,30 @@ @apply border-blue-300; } +.btn-vortex-success { + @apply bg-green-500; + @apply text-white; + @apply rounded-[var(--radius-field)]; + @apply border; + @apply border-green-500; + @apply duration-200; + transition: scale 0.1s ease-in-out; +} + +.btn-vortex-success:active { + scale: 0.98; +} + +.btn-vortex-success:hover { + @apply bg-green-100; + @apply text-green-700; + @apply border-green-300; +} + .btn-vortex-primary-inverse { @apply bg-white; @apply text-blue-700; - @apply rounded-xl; + @apply rounded-[var(--radius-field)]; @apply border; @apply border-blue-700; @apply cursor-pointer; @@ -198,6 +220,7 @@ .btn-vortex-secondary { @apply text-white; @apply bg-pink-600; + @apply rounded-[var(--radius-field)]; @apply border-pink-600; @apply shadow-none; transition: scale 0.1s ease-in-out; @@ -215,11 +238,11 @@ } .btn-vortex-danger { - @apply bg-red-600; - @apply text-white; + @apply bg-red-800; + @apply text-red-100; @apply rounded-xl; @apply border; - @apply border-red-600; + @apply border-red-800; @apply shadow-none; transition: scale 0.1s ease-in-out; } @@ -229,7 +252,7 @@ } .btn-vortex-danger:hover { - @apply bg-white; + @apply bg-red-100; @apply text-red-800; @apply border-red-800; } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ddf8b2926..5681d7f1c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -58,6 +58,7 @@ "numora": "^3.0.2", "numora-react": "3.0.3", "qrcode.react": "^4.2.0", + "radix-ui": "^1.4.3", "react": "=19.2.0", "react-dom": "=19.2.0", "react-hook-form": "^7.65.0", diff --git a/apps/frontend/src/assets/business-handshake.svg b/apps/frontend/src/assets/business-handshake.svg new file mode 100644 index 000000000..e554b8c96 --- /dev/null +++ b/apps/frontend/src/assets/business-handshake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/coins/COP.png b/apps/frontend/src/assets/coins/COP.png new file mode 100644 index 000000000..f6826a475 Binary files /dev/null and b/apps/frontend/src/assets/coins/COP.png differ diff --git a/apps/frontend/src/assets/coins/EU.png b/apps/frontend/src/assets/coins/EU.png new file mode 100644 index 000000000..1636f1313 Binary files /dev/null and b/apps/frontend/src/assets/coins/EU.png differ diff --git a/apps/frontend/src/assets/coins/EUR.svg b/apps/frontend/src/assets/coins/EUR.svg deleted file mode 100644 index 9275f9dfa..000000000 --- a/apps/frontend/src/assets/coins/EUR.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/coins/EURC.png b/apps/frontend/src/assets/coins/EURC.png deleted file mode 100644 index e0881a4f7..000000000 Binary files a/apps/frontend/src/assets/coins/EURC.png and /dev/null differ diff --git a/apps/frontend/src/assets/coins/MXN.png b/apps/frontend/src/assets/coins/MXN.png new file mode 100644 index 000000000..93760c1f1 Binary files /dev/null and b/apps/frontend/src/assets/coins/MXN.png differ diff --git a/apps/frontend/src/assets/coins/USD.png b/apps/frontend/src/assets/coins/USD.png new file mode 100644 index 000000000..d24e4cf5f Binary files /dev/null and b/apps/frontend/src/assets/coins/USD.png differ diff --git a/apps/frontend/src/assets/coins/placeholder.svg b/apps/frontend/src/assets/coins/placeholder.svg index d90614e7c..e278354f2 100644 --- a/apps/frontend/src/assets/coins/placeholder.svg +++ b/apps/frontend/src/assets/coins/placeholder.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/apps/frontend/src/assets/document_ready.svg b/apps/frontend/src/assets/document_ready.svg new file mode 100644 index 000000000..b8533a6f8 --- /dev/null +++ b/apps/frontend/src/assets/document_ready.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/document_verified.svg b/apps/frontend/src/assets/document_verified.svg new file mode 100644 index 000000000..5e2ba19dd --- /dev/null +++ b/apps/frontend/src/assets/document_verified.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/exclamation_mark_error.svg b/apps/frontend/src/assets/exclamation_mark_error.svg new file mode 100644 index 000000000..4cfb13b03 --- /dev/null +++ b/apps/frontend/src/assets/exclamation_mark_error.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/quote-request-preview.mov b/apps/frontend/src/assets/videos/quote-request-preview.mov similarity index 100% rename from apps/frontend/src/assets/quote-request-preview.mov rename to apps/frontend/src/assets/videos/quote-request-preview.mov diff --git a/apps/frontend/src/components/AlertBanner/index.tsx b/apps/frontend/src/components/AlertBanner/index.tsx new file mode 100644 index 000000000..eab2b40e6 --- /dev/null +++ b/apps/frontend/src/components/AlertBanner/index.tsx @@ -0,0 +1,31 @@ +import { cn } from "../../helpers/cn"; + +interface AlertBannerProps { + icon: React.ReactNode; + title: string; + description?: string; + children?: React.ReactNode; + className?: string; +} + +export function AlertBanner({ icon, title, description, children, className }: AlertBannerProps) { + return ( +
+
+ {icon} +
+

{title}

+ {description &&

{description}

} +
+
+ {children} +
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 077ee0488..95fbc3d7f 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,28 +1,28 @@ -import { Trans, useTranslation } from "react-i18next"; +import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; -import { cn } from "../../helpers/cn"; -import { Spinner } from "../Spinner"; +import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; +import { DoneScreen } from "./DoneScreen"; +import { FailureKycScreen } from "./FailureKycScreen"; +import { FailureScreen } from "./FailureScreen"; +import { FillingScreen } from "./FillingScreen"; +import { LinkReadyScreen } from "./LinkReadyScreen"; +import { LoadingScreen } from "./LoadingScreen"; +import { OpeningLinkScreen } from "./OpeningLinkScreen"; +import { PollingScreen } from "./PollingScreen"; export const AlfredpayKycFlow = () => { - const { t } = useTranslation(); const actor = useAlfredpayKycActor(); const state = useAlfredpayKycSelector(); - const openLink = () => { - actor?.send({ type: "OPEN_LINK" }); - }; - - const completedFilling = () => { - actor?.send({ type: "COMPLETED_FILLING" }); - }; - - const toggleBusiness = () => { - actor?.send({ type: "TOGGLE_BUSINESS" }); - }; - - const userAccept = () => { - actor?.send({ type: "USER_ACCEPT" }); - }; + const confirmSuccess = useCallback(() => actor?.send({ type: "CONFIRM_SUCCESS" }), [actor]); + const openLink = useCallback(() => actor?.send({ type: "OPEN_LINK" }), [actor]); + const completedFilling = useCallback(() => actor?.send({ type: "COMPLETED_FILLING" }), [actor]); + const toggleBusiness = useCallback(() => actor?.send({ type: "TOGGLE_BUSINESS" }), [actor]); + const userAccept = useCallback(() => actor?.send({ type: "USER_ACCEPT" }), [actor]); + const userRetry = useCallback(() => actor?.send({ type: "USER_RETRY" }), [actor]); + const userCancel = useCallback(() => actor?.send({ type: "USER_CANCEL" }), [actor]); + const retryProcess = useCallback(() => actor?.send({ type: "RETRY_PROCESS" }), [actor]); + const cancelProcess = useCallback(() => actor?.send({ type: "CANCEL_PROCESS" }), [actor]); if (!actor || !state) return null; @@ -35,133 +35,57 @@ export const AlfredpayKycFlow = () => { stateValue === "GettingKycLink" || stateValue === "Retrying" ) { - return ( -
- -

{t("components.alfredpayKycFlow.loading")}

-
- ); + return ; } if (stateValue === "PollingStatus") { - return ( -
- -

{t("components.alfredpayKycFlow.verifyingStatus", { kycOrKyb })}

-

{t("components.alfredpayKycFlow.verifyingStatusDescription")}

-
- ); + return ; } if (stateValue === "LinkReady") { - return ( -
-

{t("components.alfredpayKycFlow.completeProcess", { kycOrKyb })}

- -
- ); + return ; } if (stateValue === "OpeningLink") { - return ( -
- -

{t("components.alfredpayKycFlow.openingLink")}

-
- ); + return ; } if (stateValue === "FillingKyc" || stateValue === "FinishingFilling") { - const isSubmitting = stateValue === "FinishingFilling"; - return ( -
-

{t("components.alfredpayKycFlow.completeInNewWindow", { kycOrKyb })}

- -
+ ); } + if (stateValue === "VerificationDone") { + return ; + } + if (stateValue === "Done") { - return ( -
-

{t("components.alfredpayKycFlow.completed", { kycOrKyb })}

-

{t("components.alfredpayKycFlow.accountVerified")}

- {/* Will not be rendered as the sub-state machine will stop and go to main kyc one */} -
- ); + return ; } if (stateValue === "FailureKyc") { return ( -
-

{t("components.alfredpayKycFlow.failed", { kycOrKyb })}

-

{context.error?.message || "An unknown error occurred."}

-
- - -
-
+ ); } if (stateValue === "Failure") { - return ( -
-

{t("components.alfredpayKycFlow.systemError")}

-

{context.error?.message || "An unknown error occurred."}

-
- - -
-
- ); + return ; } if (stateValue === "CostumerDefinition") { return ( -
-

{t("components.alfredpayKycFlow.continueWithPartner", { kycOrKyb })}

- -

- {context.business ? ( - - }} - i18nKey="components.alfredpayKycFlow.registerAsIndividual" - /> - ) : ( - - }} - i18nKey="components.alfredpayKycFlow.registerAsBusiness" - /> - )} -

-
+ ); } diff --git a/apps/frontend/src/components/Alfredpay/CustomerDefinitionScreen.tsx b/apps/frontend/src/components/Alfredpay/CustomerDefinitionScreen.tsx new file mode 100644 index 000000000..822967ba6 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/CustomerDefinitionScreen.tsx @@ -0,0 +1,56 @@ +import { memo } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import businessHandshake from "../../assets/business-handshake.svg"; +import livenessCheck from "../../assets/liveness-check.svg"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface CustomerDefinitionScreenProps { + kycOrKyb: string; + isBusiness: boolean; + onAccept: () => void; + onToggleBusiness: () => void; +} + +const toggleLinkClass = + "cursor-pointer border-0 bg-transparent p-0 font-[inherit] text-blue-600 underline touch-manipulation [@media(hover:hover)]:hover:text-blue-800"; + +export const CustomerDefinitionScreen = memo( + ({ kycOrKyb, isBusiness, onAccept, onToggleBusiness }: CustomerDefinitionScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + {isBusiness ? ( + Business Handshake + ) : ( + Liveness Check + )} + +

+ {t("components.alfredpayKycFlow.continueWithPartner", { kycOrKyb })} +

+ +

+ + }} + i18nKey={ + isBusiness ? "components.alfredpayKycFlow.registerAsIndividual" : "components.alfredpayKycFlow.registerAsBusiness" + } + /> +

+ + + + +
+ ); + } +); + +CustomerDefinitionScreen.displayName = "CustomerDefinitionScreen"; diff --git a/apps/frontend/src/components/Alfredpay/DoneScreen.tsx b/apps/frontend/src/components/Alfredpay/DoneScreen.tsx new file mode 100644 index 000000000..510ff7703 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/DoneScreen.tsx @@ -0,0 +1,32 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import documentVerified from "../../assets/document_verified.svg"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface DoneScreenProps { + kycOrKyb: string; + onContinue?: () => void; +} + +export const DoneScreen = memo(({ kycOrKyb, onContinue }: DoneScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + Business Handshake +

{t("components.alfredpayKycFlow.completed", { kycOrKyb })}

+

{t("components.alfredpayKycFlow.accountVerified")}

+ {onContinue && ( + + + + )} +
+ ); +}); + +DoneScreen.displayName = "DoneScreen"; diff --git a/apps/frontend/src/components/Alfredpay/FailureKycScreen.tsx b/apps/frontend/src/components/Alfredpay/FailureKycScreen.tsx new file mode 100644 index 000000000..d44722395 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/FailureKycScreen.tsx @@ -0,0 +1,30 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +interface FailureKycScreenProps { + kycOrKyb: string; + errorMessage: string | undefined; + onRetry: () => void; + onCancel: () => void; +} + +export const FailureKycScreen = memo(({ kycOrKyb, errorMessage, onRetry, onCancel }: FailureKycScreenProps) => { + const { t } = useTranslation(); + + return ( +
+

{t("components.alfredpayKycFlow.failed", { kycOrKyb })}

+

{errorMessage ?? "An unknown error occurred."}

+
+ + +
+
+ ); +}); + +FailureKycScreen.displayName = "FailureKycScreen"; diff --git a/apps/frontend/src/components/Alfredpay/FailureScreen.tsx b/apps/frontend/src/components/Alfredpay/FailureScreen.tsx new file mode 100644 index 000000000..7a70b549f --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/FailureScreen.tsx @@ -0,0 +1,33 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import XError from "../../assets/exclamation_mark_error.svg"; +import { StepFooter } from "../StepFooter"; + +interface FailureScreenProps { + errorMessage: string | undefined; + onRetry: () => void; + onCancel: () => void; +} + +export const FailureScreen = memo(({ errorMessage, onRetry, onCancel }: FailureScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ Error icon +

{errorMessage ?? "An unknown error occurred."}

+ +
+ + +
+
+
+ ); +}); + +FailureScreen.displayName = "FailureScreen"; diff --git a/apps/frontend/src/components/Alfredpay/FillingScreen.tsx b/apps/frontend/src/components/Alfredpay/FillingScreen.tsx new file mode 100644 index 000000000..95f99c28e --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/FillingScreen.tsx @@ -0,0 +1,38 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import documentReady from "../../assets/document_ready.svg"; +import { MenuButtons } from "../MenuButtons"; +import { Spinner } from "../Spinner"; +import { StepFooter } from "../StepFooter"; + +interface FillingScreenProps { + kycOrKyb: string; + isSubmitting: boolean; + onCompletedFilling: () => void; +} + +export const FillingScreen = memo(({ kycOrKyb, isSubmitting, onCompletedFilling }: FillingScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + Business Handshake +

{t("components.alfredpayKycFlow.completeInNewWindow", { kycOrKyb })}

+ + + +
+ ); +}); + +FillingScreen.displayName = "FillingScreen"; diff --git a/apps/frontend/src/components/Alfredpay/LinkReadyScreen.tsx b/apps/frontend/src/components/Alfredpay/LinkReadyScreen.tsx new file mode 100644 index 000000000..3c226053a --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/LinkReadyScreen.tsx @@ -0,0 +1,29 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import livenessCheck from "../../assets/liveness-check.svg"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface LinkReadyScreenProps { + kycOrKyb: string; + onOpenLink: () => void; +} + +export const LinkReadyScreen = memo(({ kycOrKyb, onOpenLink }: LinkReadyScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ + Liveness Check +

{t("components.alfredpayKycFlow.completeProcess", { kycOrKyb })}

+ + + +
+ ); +}); + +LinkReadyScreen.displayName = "LinkReadyScreen"; diff --git a/apps/frontend/src/components/Alfredpay/LoadingScreen.tsx b/apps/frontend/src/components/Alfredpay/LoadingScreen.tsx new file mode 100644 index 000000000..5e5aa48f9 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/LoadingScreen.tsx @@ -0,0 +1,12 @@ +import { memo } from "react"; +import { Spinner } from "../Spinner"; + +export const LoadingScreen = memo(() => { + return ( +
+ +
+ ); +}); + +LoadingScreen.displayName = "LoadingScreen"; diff --git a/apps/frontend/src/components/Alfredpay/OpeningLinkScreen.tsx b/apps/frontend/src/components/Alfredpay/OpeningLinkScreen.tsx new file mode 100644 index 000000000..407edc5c6 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/OpeningLinkScreen.tsx @@ -0,0 +1,16 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { Spinner } from "../Spinner"; + +export const OpeningLinkScreen = memo(() => { + const { t } = useTranslation(); + + return ( +
+ +

{t("components.alfredpayKycFlow.openingLink")}

+
+ ); +}); + +OpeningLinkScreen.displayName = "OpeningLinkScreen"; diff --git a/apps/frontend/src/components/Alfredpay/PollingScreen.tsx b/apps/frontend/src/components/Alfredpay/PollingScreen.tsx new file mode 100644 index 000000000..470eb7d18 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/PollingScreen.tsx @@ -0,0 +1,21 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { Spinner } from "../Spinner"; + +interface PollingScreenProps { + kycOrKyb: string; +} + +export const PollingScreen = memo(({ kycOrKyb }: PollingScreenProps) => { + const { t } = useTranslation(); + + return ( +
+ +

{t("components.alfredpayKycFlow.verifyingStatus", { kycOrKyb })}

+

{t("components.alfredpayKycFlow.verifyingStatusDescription")}

+
+ ); +}); + +PollingScreen.displayName = "PollingScreen"; diff --git a/apps/frontend/src/components/AnimatedRemoveFiatAccountLabel/index.tsx b/apps/frontend/src/components/AnimatedRemoveFiatAccountLabel/index.tsx new file mode 100644 index 000000000..6d0eabda2 --- /dev/null +++ b/apps/frontend/src/components/AnimatedRemoveFiatAccountLabel/index.tsx @@ -0,0 +1,25 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { durations } from "../../constants/animations"; + +interface AnimatedLabelProps { + children: React.ReactNode; + motionKey: string; +} + +export function AnimatedRemoveFiatAccountLabel({ children, motionKey }: AnimatedLabelProps) { + const shouldReduceMotion = useReducedMotion(); + + return ( + + + {children} + + + ); +} diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index 4eaedbb91..cc4779498 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -28,7 +28,6 @@ export const AveniaKYBVerifyStep = ({ instructionsKey = "components.aveniaKYB.instructions", cancelButtonKey = "components.aveniaKYB.buttons.cancel" }: AveniaKYBVerifyStepProps) => { - const quote = useQuote(); const { t } = useTranslation(); return ( @@ -79,7 +78,7 @@ export const AveniaKYBVerifyStep = ({ - +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx index 2e23100c6..4d02236d7 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx @@ -2,7 +2,6 @@ import { isValidCnpj } from "@vortexfi/shared"; import { useTranslation } from "react-i18next"; import { useAveniaKycActor, useAveniaKycSelector } from "../../contexts/rampState"; import { useKYCForm } from "../../hooks/brla/useKYCForm"; -import { useQuote } from "../../stores/quote/useQuoteStore"; import { QuoteSummary } from "../QuoteSummary"; import { StepBackButton } from "../StepBackButton"; import { AveniaLivenessStep } from "../widget-steps/AveniaLivenessStep"; @@ -14,7 +13,6 @@ import { VerificationStatus } from "./VerificationStatus"; export const AveniaKYCForm = () => { const aveniaKycActor = useAveniaKycActor(); const aveniaState = useAveniaKycSelector(); - const quote = useQuote(); const { t } = useTranslation(); const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState?.context.kycFormData }); @@ -154,7 +152,7 @@ export const AveniaKYCForm = () => { {content} - {quote && } + ); }; diff --git a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx index 1df0d9896..00ebda4ff 100644 --- a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx @@ -77,7 +77,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany )} - + + + ); +} diff --git a/apps/frontend/src/components/QuoteSummary/index.tsx b/apps/frontend/src/components/QuoteSummary/index.tsx index 46ea5c9fc..8f2586f13 100644 --- a/apps/frontend/src/components/QuoteSummary/index.tsx +++ b/apps/frontend/src/components/QuoteSummary/index.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { cn } from "../../helpers/cn"; import { useTokenIcon } from "../../hooks/useTokenIcon"; import { formatPrice } from "../../sections/individuals/FeeComparison/helpers"; +import { useQuote } from "../../stores/quote/useQuoteStore"; import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../CollapsibleCard"; import { CurrencyExchange } from "../CurrencyExchange"; import { ToggleButton } from "../ToggleButton"; @@ -11,7 +12,6 @@ import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { TransactionId } from "../TransactionId"; interface QuoteSummaryProps { - quote: QuoteResponse; className?: string; } @@ -107,7 +107,11 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { ); }; -export const QuoteSummary = ({ quote, className }: QuoteSummaryProps) => { +export const QuoteSummary = ({ className }: QuoteSummaryProps) => { + const quote = useQuote(); + + if (!quote) return null; + return (
diff --git a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx index ab9904ee4..e62f5edea 100644 --- a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx +++ b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx @@ -6,16 +6,20 @@ import { getAddressForFormat, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, + isAlfredpayToken, RampDirection, TokenType } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { ALFREDPAY_FIAT_TOKEN_TO_COUNTRY } from "../../constants/fiatAccountMethods"; +import { useFiatAccountSelector } from "../../contexts/FiatAccountMachineContext"; import { useNetwork } from "../../contexts/network"; import { useMoneriumKycActor, useRampActor, useStellarKycSelector } from "../../contexts/rampState"; import { trimAddress } from "../../helpers/addressFormatter"; import { cn } from "../../helpers/cn"; +import { useFiatAccounts } from "../../hooks/alfredpay/useFiatAccounts"; import { useRampSubmission } from "../../hooks/ramp/useRampSubmission"; import { useVortexAccount } from "../../hooks/useVortexAccount"; import { navigateToCleanOrigin } from "../../lib/navigation"; @@ -200,6 +204,10 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className const isOnramp = quote?.rampType === RampDirection.BUY; const isOfframp = quote?.rampType === RampDirection.SELL; const fiatToken = useFiatToken(); + const selectedFiatAccountId = useFiatAccountSelector(s => s.context.selectedFiatAccountId); + const alfredpayCountry = isAlfredpayToken(fiatToken) ? (ALFREDPAY_FIAT_TOKEN_TO_COUNTRY[fiatToken] ?? null) : null; + const { data: fiatAccounts = [] } = useFiatAccounts(alfredpayCountry ?? "", { enabled: !!alfredpayCountry }); + const effectiveSelectedFiatAccountId = selectedFiatAccountId ?? fiatAccounts[0]?.fiatAccountId ?? null; const onChainToken = useOnChainToken(); const { selectedNetwork } = useNetwork(); @@ -218,7 +226,12 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className ) { return true; } - if (machineState === "QuoteReady" || machineState === "KycComplete") { + if (machineState === "QuoteReady") { + return false; + } + + if (machineState === "KycComplete") { + if (isAlfredpayToken(fiatToken) && !effectiveSelectedFiatAccountId) return true; return false; } @@ -251,8 +264,10 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className isOfframp, isOnramp, rampState?.ramp?.depositQrCode, + rampState?.ramp?.achPaymentData, anchorUrl, fiatToken, + effectiveSelectedFiatAccountId, stellarData, machineState, moneriumKycActor, @@ -279,7 +294,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className } if (machineState === "KycComplete") { - rampActor.send({ type: "PROCEED_TO_REGISTRATION" }); + rampActor.send({ selectedFiatAccountId: effectiveSelectedFiatAccountId ?? undefined, type: "PROCEED_TO_REGISTRATION" }); return; } diff --git a/apps/frontend/src/components/Spinner/index.tsx b/apps/frontend/src/components/Spinner/index.tsx index a388c7a04..1d94e2deb 100644 --- a/apps/frontend/src/components/Spinner/index.tsx +++ b/apps/frontend/src/components/Spinner/index.tsx @@ -19,7 +19,7 @@ export function Spinner({ }; const themeClasses = { - dark: "border-gray-600 ", + dark: "border-blue-700", light: "border-white" }; diff --git a/apps/frontend/src/components/StepFooter/index.tsx b/apps/frontend/src/components/StepFooter/index.tsx index e5ddd3d4d..9a7eebdd6 100644 --- a/apps/frontend/src/components/StepFooter/index.tsx +++ b/apps/frontend/src/components/StepFooter/index.tsx @@ -1,17 +1,18 @@ -import { QuoteResponse } from "@vortexfi/shared"; import { ReactNode } from "react"; import { cn } from "../../helpers/cn"; +import { useQuote } from "../../stores/quote/useQuoteStore"; import { QuoteSummary } from "../QuoteSummary"; interface StepFooterProps { - quote?: QuoteResponse; - aboveQuote?: boolean; children?: ReactNode; className?: string; + hideQuoteSummary?: boolean; } -export function StepFooter({ quote, aboveQuote, children, className }: StepFooterProps) { - const showAboveQuote = !!quote || aboveQuote; +export function StepFooter({ children, className, hideQuoteSummary = false }: StepFooterProps) { + const quote = useQuote(); + const showAboveQuote = hideQuoteSummary ? false : Boolean(quote); + return ( <> {children && ( @@ -21,7 +22,7 @@ export function StepFooter({ quote, aboveQuote, children, className }: StepFoote {children}
)} - {quote && } + {hideQuoteSummary ? <> : } ); } diff --git a/apps/frontend/src/components/ui/DropdownSelector.tsx b/apps/frontend/src/components/ui/DropdownSelector.tsx new file mode 100644 index 000000000..b0d8da26a --- /dev/null +++ b/apps/frontend/src/components/ui/DropdownSelector.tsx @@ -0,0 +1,88 @@ +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; +import { cn } from "../../helpers/cn"; + +interface DropdownSelectorProps { + label?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + triggerContent: ReactNode; + children: ReactNode; + isLoading?: boolean; + className?: string; +} + +export function DropdownSelector({ + label, + open, + onOpenChange, + triggerContent, + children, + isLoading, + className +}: DropdownSelectorProps) { + const prefersReducedMotion = useReducedMotion(); + + function handleBlur(e: React.FocusEvent) { + if (!e.currentTarget.contains(e.relatedTarget)) { + onOpenChange(false); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + onOpenChange(false); + } + } + + const dropdownVariants = prefersReducedMotion + ? { animate: {}, exit: {}, initial: {} } + : { + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -4 }, + initial: { opacity: 0, y: -4 } + }; + + return ( +
+ {label &&

{label}

} + + + + + {open && ( + + {children} + + )} + +
+ ); +} diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx new file mode 100644 index 000000000..18a56b396 --- /dev/null +++ b/apps/frontend/src/components/ui/select.tsx @@ -0,0 +1,157 @@ +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { Select as SelectPrimitive } from "radix-ui"; +import * as React from "react"; + +import { cn } from "../../helpers/cn"; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue +}; diff --git a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx index 15b9a790f..664a945f5 100644 --- a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import * as yup from "yup"; import { useRampActor } from "../../../contexts/rampState"; import { cn } from "../../../helpers/cn"; -import { useQuote } from "../../../stores/quote/useQuoteStore"; import { MenuButtons } from "../../MenuButtons"; import { StepFooter } from "../../StepFooter"; @@ -21,8 +20,6 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => { errorMessage: state.context.errorMessage, userEmail: state.context.userEmail })); - - const quote = useQuote(); const [email, setEmail] = useState(contextEmail || ""); const [localError, setLocalError] = useState(""); const [termsAccepted, setTermsAccepted] = useState(false); @@ -115,7 +112,7 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => { - + + ); +} + +interface FiatAccountSelectorProps { + fiatToken: FiatToken; +} + +export function FiatAccountSelector({ fiatToken }: FiatAccountSelectorProps) { + const { t } = useTranslation(); + const country = ALFREDPAY_FIAT_TOKEN_TO_COUNTRY[fiatToken]; + const fiatAccountActor = useFiatAccountActor(); + const selectedFiatAccountId = useFiatAccountSelector(s => s.context.selectedFiatAccountId); + const [userOpen, setUserOpen] = useState(false); + + const { data: accounts = [], isLoading } = useFiatAccounts(country ?? "", { enabled: !!country }); + + const selectedAccount = accounts.find(a => a.fiatAccountId === selectedFiatAccountId) ?? accounts[0] ?? null; + const open = userOpen || (!isLoading && accounts.length === 0); + + if (!country) return null; + + const accountType: FiatAccountTypeKey | null = selectedAccount + ? (ALFRED_TO_ACCOUNT_TYPE[selectedAccount.type] ?? null) + : null; + const Icon = accountType ? ACCOUNT_TYPE_ICONS[accountType] : null; + const last4 = selectedAccount?.fiatAccountFields.accountNumber.slice(-4); + + const triggerContent = selectedAccount ? ( + <> + {Icon &&