diff --git a/apps/frontend/App.css b/apps/frontend/App.css index 18689ced0..32a2e9c11 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -31,7 +31,8 @@ --radius: 0.625rem; /* QuoteSummary layout */ - --quote-summary-height: 70px; + --quote-summary-height: 88px; + --widget-min-height: 506px; } @layer base { @@ -249,7 +250,7 @@ @layer utilities { .bottom-above-quote { - bottom: calc(var(--quote-summary-height) + 2rem); + bottom: calc(var(--quote-summary-height) + 1rem); } .shadow-custom { diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index ba073d461..4eaedbb91 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -1,7 +1,7 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid"; import { Trans, useTranslation } from "react-i18next"; import { useQuote } from "../../../stores/quote/useQuoteStore"; -import { QuoteSummary } from "../../QuoteSummary"; +import { StepFooter } from "../../StepFooter"; interface AveniaKYBVerifyStepProps { titleKey: string; @@ -32,7 +32,7 @@ export const AveniaKYBVerifyStep = ({ const { t } = useTranslation(); return ( -
+
@@ -79,7 +79,7 @@ export const AveniaKYBVerifyStep = ({
-
+
-
- {quote && } +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx index b917d13b8..3113f3116 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx @@ -57,8 +57,8 @@ export const AveniaKYBForm = () => { ]; return ( -
-
+
+
{quote && } diff --git a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx index a1478294b..2e23100c6 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx @@ -145,8 +145,8 @@ export const AveniaKYCForm = () => { } return ( -
-
+
+
diff --git a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx index bded3809d..08ee12598 100644 --- a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx @@ -5,6 +5,8 @@ import { Trans, useTranslation } from "react-i18next"; import { KYCFormData } from "../../../hooks/brla/useKYCForm"; import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; import { AveniaKycActorRef } from "../../../machines/types"; + +import { StepFooter } from "../../StepFooter"; import { AveniaField, AveniaFieldProps, ExtendedAveniaFieldOptions } from "../AveniaField"; interface AveniaVerificationFormProps { @@ -31,7 +33,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany {!isCompany && ( -
+
@@ -75,26 +77,24 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany
)}
-
-
- -
-
+ + +
); diff --git a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx index e961280a3..019f782ae 100644 --- a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx +++ b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx @@ -1,12 +1,15 @@ -import { CameraIcon, CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; +import { DocumentTextIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { AveniaDocumentType } from "@vortexfi/shared"; -import { motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; +import { durations, easings } from "../../../constants/animations"; import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; import { AveniaKycActorRef } from "../../../machines/types"; import { BrlaService } from "../../../services/api"; import { KycLevel2Toggle } from "../../KycLevel2Toggle"; +import { StepFooter } from "../../StepFooter"; const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15 MB const ALLOWED_TYPES = ["image/png", "image/jpeg", "application/pdf"]; @@ -36,6 +39,7 @@ async function uploadFileAsBuffer(file: File, url: string) { export const DocumentUpload: React.FC = ({ aveniaKycActor, taxId }) => { const { t } = useTranslation(); const { buttonProps, isMaintenanceDisabled } = useMaintenanceAwareButton(); + const shouldReduceMotion = useReducedMotion(); const [docType, setDocType] = useState(AveniaDocumentType.DRIVERS_LICENSE); @@ -169,60 +173,108 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, {label} {fileName || t("components.documentUpload.helperText")} - {valid && } + + {valid && ( + + + + )} + ); + const fieldTransition = shouldReduceMotion ? { duration: 0 } : { duration: durations.normal, ease: easings.easeOutCubic }; + return ( - { + e.preventDefault(); + handleSubmit(); + }} + transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow, ease: easings.easeOutCubic }} > -

{t("components.documentUpload.title")}

-

{t("components.documentUpload.description")}

- - - -
- {docType === AveniaDocumentType.ID && ( - <> - {renderField( - t("components.documentUpload.fields.rgFront"), - e => handleFileChange(e, setFront, setFrontValid), - frontValid, - DocumentTextIcon, - front?.name - )} - {renderField( - t("components.documentUpload.fields.rgBack"), - e => handleFileChange(e, setBack, setBackValid), - backValid, - DocumentTextIcon, - back?.name +
+

{t("components.documentUpload.title")}

+

{t("components.documentUpload.description")}

+ + + +
+ + {docType === AveniaDocumentType.ID ? ( + + {renderField( + t("components.documentUpload.fields.rgFront"), + e => handleFileChange(e, setFront, setFrontValid), + frontValid, + DocumentTextIcon, + front?.name + )} + {renderField( + t("components.documentUpload.fields.rgBack"), + e => handleFileChange(e, setBack, setBackValid), + backValid, + DocumentTextIcon, + back?.name + )} + + ) : ( + + {renderField( + t("components.documentUpload.fields.cnhDocument"), + e => handleFileChange(e, setFront, setFrontValid), + frontValid, + DocumentTextIcon, + front?.name + )} + )} - - )} - {docType === AveniaDocumentType.DRIVERS_LICENSE && - renderField( - t("components.documentUpload.fields.cnhDocument"), - e => handleFileChange(e, setFront, setFrontValid), - frontValid, - DocumentTextIcon, - front?.name + +
+ + + {error && ( + + {error} + )} +
- {error &&

{error}

} - -
+ -
- + + ); }; diff --git a/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx b/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx index 98b8c9f86..dbd90f627 100644 --- a/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx +++ b/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx @@ -2,6 +2,7 @@ import { motion } from "motion/react"; import React from "react"; import { useTranslation } from "react-i18next"; import { AveniaKycActorRef, SelectedAveniaData } from "../../../machines/types"; + import { KycStatus } from "../../../services/signingService"; import { Spinner } from "../../Spinner"; @@ -16,7 +17,7 @@ export const VerificationStatus: React.FC = ({ aveniaKy return ( diff --git a/apps/frontend/src/components/CollapsibleCard/index.tsx b/apps/frontend/src/components/CollapsibleCard/index.tsx index 51f67eb0f..a7aeb86c9 100644 --- a/apps/frontend/src/components/CollapsibleCard/index.tsx +++ b/apps/frontend/src/components/CollapsibleCard/index.tsx @@ -1,6 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { createContext, forwardRef, ReactNode, useContext, useId, useState } from "react"; import { durations, easings } from "../../constants/animations"; +import { cn } from "../../helpers/cn"; interface CollapsibleCardProps { children: ReactNode; @@ -71,8 +72,12 @@ const CollapsibleDetails = ({ children, className = "" }: CollapsibleDetailsProp return (
@@ -83,7 +88,7 @@ const CollapsibleDetails = ({ children, className = "" }: CollapsibleDetailsProp exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 10 }} id={detailsId} initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 10 }} - transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow, ease: easings.easeOutCubic }} + transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.normal, ease: easings.easeOutCubic }} > {children} diff --git a/apps/frontend/src/components/QuoteSummary/index.tsx b/apps/frontend/src/components/QuoteSummary/index.tsx index 2eb2886f9..dad1c1d9a 100644 --- a/apps/frontend/src/components/QuoteSummary/index.tsx +++ b/apps/frontend/src/components/QuoteSummary/index.tsx @@ -8,8 +8,6 @@ import { ToggleButton } from "../ToggleButton"; import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { TransactionId } from "../TransactionId"; -export const QUOTE_SUMMARY_COLLAPSED_HEIGHT = 88; - interface QuoteSummaryProps { quote: QuoteResponse; className?: string; @@ -86,7 +84,8 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { showNetworkOverlay={!!inputIcon.network} tokenSymbol={quote.inputCurrency} /> - {quote.inputAmount} {quote.inputCurrency.toUpperCase()} + {new Intl.NumberFormat("en", { maximumFractionDigits: 2 }).format(Number(quote.inputAmount))}{" "} + {quote.inputCurrency.toUpperCase()}
@@ -100,14 +99,13 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { showNetworkOverlay={!!outputIcon.network} tokenSymbol={quote.outputCurrency} /> - ~ {quote.outputAmount} {quote.outputCurrency.toUpperCase()} + ~ {new Intl.NumberFormat("en", { maximumFractionDigits: 2 }).format(Number(quote.outputAmount))}{" "} + {quote.outputCurrency.toUpperCase()}
-
- -
+ ); }; diff --git a/apps/frontend/src/components/StepFooter/index.tsx b/apps/frontend/src/components/StepFooter/index.tsx new file mode 100644 index 000000000..e5ddd3d4d --- /dev/null +++ b/apps/frontend/src/components/StepFooter/index.tsx @@ -0,0 +1,27 @@ +import { QuoteResponse } from "@vortexfi/shared"; +import { ReactNode } from "react"; +import { cn } from "../../helpers/cn"; +import { QuoteSummary } from "../QuoteSummary"; + +interface StepFooterProps { + quote?: QuoteResponse; + aboveQuote?: boolean; + children?: ReactNode; + className?: string; +} + +export function StepFooter({ quote, aboveQuote, children, className }: StepFooterProps) { + const showAboveQuote = !!quote || aboveQuote; + return ( + <> + {children && ( +
+ {children} +
+ )} + {quote && } + + ); +} diff --git a/apps/frontend/src/components/TransactionId/index.tsx b/apps/frontend/src/components/TransactionId/index.tsx index f040cef65..1b799870a 100644 --- a/apps/frontend/src/components/TransactionId/index.tsx +++ b/apps/frontend/src/components/TransactionId/index.tsx @@ -24,7 +24,13 @@ export const TransactionId = ({ return (
{displayLabel}
- +
); }; diff --git a/apps/frontend/src/components/menus/SettingsMenu/index.tsx b/apps/frontend/src/components/menus/SettingsMenu/index.tsx index 294f03719..7eb07b023 100644 --- a/apps/frontend/src/components/menus/SettingsMenu/index.tsx +++ b/apps/frontend/src/components/menus/SettingsMenu/index.tsx @@ -75,13 +75,13 @@ export const SettingsMenu = () => {
{isAuthenticated && userEmail && ( <> -
-
+
+
- {userEmail} + {userEmail}
-
+
)} diff --git a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx index a82bf12d1..15b9a790f 100644 --- a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx @@ -6,7 +6,7 @@ import { useRampActor } from "../../../contexts/rampState"; import { cn } from "../../../helpers/cn"; import { useQuote } from "../../../stores/quote/useQuoteStore"; import { MenuButtons } from "../../MenuButtons"; -import { QuoteSummary } from "../../QuoteSummary"; +import { StepFooter } from "../../StepFooter"; const emailSchema = yup.string().email().required(); @@ -43,11 +43,11 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => { }; return ( -
+
-
+

{t("components.authEmailStep.title")}

{t("components.authEmailStep.description")}

@@ -113,17 +113,18 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => {
- -
-
- -
-
- {quote && } + + +
); }; diff --git a/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx b/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx index 281316e99..b97933c08 100644 --- a/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx @@ -41,7 +41,7 @@ export function AuthOTPStep({ className }: AuthOTPStepProps) { }, [errorMessage]); return ( -
+
diff --git a/apps/frontend/src/components/widget-steps/AveniaLivenessStep/index.tsx b/apps/frontend/src/components/widget-steps/AveniaLivenessStep/index.tsx index 57bc59720..f1c88c233 100644 --- a/apps/frontend/src/components/widget-steps/AveniaLivenessStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AveniaLivenessStep/index.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import livenessCheck from "../../../assets/liveness-check.svg"; import { AveniaKycActorRef, SelectedAveniaData } from "../../../machines/types"; +import { StepFooter } from "../../StepFooter"; interface AveniaLivenessStepProps { aveniaKycActor: AveniaKycActorRef; @@ -39,7 +40,7 @@ export const AveniaLivenessStep: React.FC = ({ aveniaSt }; return ( -
+
= ({ aveniaSt )} -
+
{livenessCheckOpened ? ( = ({ aveniaSt )}
-
+
); }; diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index 6e8ecbb42..64c807bd9 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -13,7 +13,7 @@ import { useSigningBoxState } from "../../../hooks/useSigningBoxState"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { usePixId, useTaxId } from "../../../stores/quote/useQuoteFormStore"; import { useQuote } from "../../../stores/quote/useQuoteStore"; -import { QUOTE_SUMMARY_COLLAPSED_HEIGHT, QuoteSummary } from "../../QuoteSummary"; +import { StepFooter } from "../../StepFooter"; import { DetailsStepActions } from "./DetailsStepActions"; import { DetailsStepForm } from "./DetailsStepForm"; import { DetailsStepHeader } from "./DetailsStepHeader"; @@ -112,10 +112,7 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { return ( -
+
@@ -134,19 +131,15 @@ export const DetailsStep = ({ className }: DetailsStepProps) => {
)}
-
+ -
+ - {quote && }
); diff --git a/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx b/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx index 276f29a88..07fdd2c8f 100644 --- a/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useMoneriumKycActor, useRampActor } from "../../../contexts/rampState"; import { cn } from "../../../helpers/cn"; import { navigateToCleanOrigin } from "../../../lib/navigation"; +import { StepFooter } from "../../StepFooter"; interface MoneriumRedirectStepProps { className?: string; @@ -25,11 +26,11 @@ export function MoneriumRedirectStep({ className }: MoneriumRedirectStepProps) { }; return ( -
-
+
+

{t("components.moneriumRedirect.description")}

-
+ @@ -39,7 +40,7 @@ export function MoneriumRedirectStep({ className }: MoneriumRedirectStepProps) { > {t("components.moneriumRedirect.goToPartner")} -
+
); } diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx index 8b5c0892c..9dbdfdd2c 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx @@ -10,6 +10,7 @@ import { useRampSummaryActions } from "../../../stores/rampSummary"; import { MenuButtons } from "../../MenuButtons"; import { RampSubmitButton } from "../../RampSubmitButton/RampSubmitButton"; import { SigningBoxButton, SigningBoxContent } from "../../SigningBox/SigningBoxContent"; +import { StepFooter } from "../../StepFooter"; import { TransactionTokensDisplay } from "./TransactionTokensDisplay"; export const SummaryStep: FC = () => { @@ -114,11 +115,13 @@ export const SummaryStep: FC = () => { ); return ( -
+
-

{headerText}

- {content} -
{actions}
+
+

{headerText}

+
{content}
+
+ {actions}
); }; diff --git a/apps/frontend/src/hooks/quote/schema.ts b/apps/frontend/src/hooks/quote/schema.ts index 7bd3d8cd6..f7f6a199d 100644 --- a/apps/frontend/src/hooks/quote/schema.ts +++ b/apps/frontend/src/hooks/quote/schema.ts @@ -1,4 +1,4 @@ -import { FiatToken, OnChainToken, RampDirection } from "@vortexfi/shared"; +import { FiatToken, OnChainToken, OnChainTokenSymbol, RampDirection } from "@vortexfi/shared"; import { useTranslation } from "react-i18next"; import * as Yup from "yup"; import { useRampDirection } from "../../stores/rampDirectionStore"; @@ -6,7 +6,7 @@ import { useRampDirection } from "../../stores/rampDirectionStore"; export type QuoteFormValues = { inputAmount: string; outputAmount?: string; - onChainToken: OnChainToken; + onChainToken: OnChainTokenSymbol; fiatToken: FiatToken; slippage?: number; deadline?: number; diff --git a/apps/frontend/src/hooks/quote/useQuoteService.ts b/apps/frontend/src/hooks/quote/useQuoteService.ts index 5f71280d8..f82e5f799 100644 --- a/apps/frontend/src/hooks/quote/useQuoteService.ts +++ b/apps/frontend/src/hooks/quote/useQuoteService.ts @@ -1,4 +1,4 @@ -import { FiatToken, OnChainToken } from "@vortexfi/shared"; +import { FiatToken, OnChainTokenSymbol } from "@vortexfi/shared"; import Big from "big.js"; import { useCallback, useEffect } from "react"; import { useEventsContext } from "../../contexts/events"; @@ -13,7 +13,7 @@ import { useRampDirection } from "../../stores/rampDirectionStore"; // if you don't want to get a new quote - you get outputAmount through useQuoteStore // This is not optimal, and introduce too much cognitive load -export const useQuoteService = (inputAmount: string | undefined, onChainToken: OnChainToken, fiatToken: FiatToken) => { +export const useQuoteService = (inputAmount: string | undefined, onChainToken: OnChainTokenSymbol, fiatToken: FiatToken) => { const { trackEvent } = useEventsContext(); const { selectedNetwork } = useNetwork(); const rampType = useRampDirection(); diff --git a/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts b/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts index 6b7433335..682eb4261 100644 --- a/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts +++ b/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts @@ -1,29 +1,36 @@ +import { RampSearchParams } from "./../../types/searchParams"; import { useRampComponentState } from "./useRampComponentState"; -import { hasAllQuoteRefreshParams } from "./useRampNavigation"; + +function isExternalFlow(params: RampSearchParams): boolean { + return !!params.externalSessionId; +} /** * Hook to determine if the Quote component is currently displayed in the Ramp page. * Mirrors the logic from useRampNavigation to check if quoteComponent would be returned. */ export const useIsQuoteComponentDisplayed = (): boolean => { - const { searchParams, rampState, rampMachineState } = useRampComponentState(); + const { rampState, rampMachineState, searchParams } = useRampComponentState(); - // Quote is NOT shown if quoteId exists in URL or all quote refresh params are present - if (searchParams.quoteId || hasAllQuoteRefreshParams(searchParams)) { + if (rampState?.ramp?.currentPhase === "complete") { return false; } - // Quote is NOT shown if in complete phase (shows success) - if (rampState?.ramp?.currentPhase === "complete") { + if (rampState?.ramp?.currentPhase === "failed") { return false; } - // Quote is NOT shown if in failed phase (shows failure) - if (rampState?.ramp?.currentPhase === "failed") { + // Automatic Quote Refresh Ramp + if (isExternalFlow(searchParams) && (rampMachineState.value === "LoadingQuote" || rampMachineState.value === "QuoteReady")) { return false; } - if (rampState === undefined && rampMachineState.value === "Idle") { + if ( + (rampMachineState.value === "Idle" || + rampMachineState.value === "LoadingQuote" || + rampMachineState.value === "QuoteReady") && + !searchParams.quoteId + ) { return true; } diff --git a/apps/frontend/src/hooks/ramp/useRampNavigation.ts b/apps/frontend/src/hooks/ramp/useRampNavigation.ts index 863647462..dfea88477 100644 --- a/apps/frontend/src/hooks/ramp/useRampNavigation.ts +++ b/apps/frontend/src/hooks/ramp/useRampNavigation.ts @@ -1,18 +1,7 @@ -import { ReactNode, useCallback, useMemo } from "react"; -import { RampSearchParams } from "../../types/searchParams"; +import { ReactNode, useCallback } from "react"; +import { useIsQuoteComponentDisplayed } from "./useIsQuoteComponentDisplayed"; import { useRampComponentState } from "./useRampComponentState"; -/** - * Checks if all required URL parameters are present for automatic quote creation. - * When these params are present, the Quote selection form should be skipped. - * - * Required params: cryptoLocked, fiat, inputAmount, network, rampType - * These match the params checked in useRampUrlParams.ts for auto-creating a quote. - */ -export const hasAllQuoteRefreshParams = (params: RampSearchParams): boolean => { - return Boolean(params.cryptoLocked && params.fiat && params.inputAmount && params.network && params.rampType); -}; - export const useRampNavigation = ( successComponent: ReactNode, failureComponent: ReactNode, @@ -20,9 +9,8 @@ export const useRampNavigation = ( formComponent: ReactNode, quoteComponent: ReactNode ) => { - const { searchParams, rampState, rampMachineState } = useRampComponentState(); - - const shouldSkipQuoteForm = useMemo(() => searchParams.quoteId || hasAllQuoteRefreshParams(searchParams), [searchParams]); + const { rampState, rampMachineState } = useRampComponentState(); + const isQuoteDisplayed = useIsQuoteComponentDisplayed(); const getCurrentComponent = useCallback(() => { if (rampState?.ramp?.currentPhase === "complete") { @@ -37,11 +25,7 @@ export const useRampNavigation = ( return progressComponent; } - if (shouldSkipQuoteForm) { - return formComponent; - } - - if (rampMachineState.value === "Idle") { + if (isQuoteDisplayed) { return quoteComponent; } @@ -49,12 +33,12 @@ export const useRampNavigation = ( }, [ rampState, rampMachineState.value, - shouldSkipQuoteForm, successComponent, failureComponent, progressComponent, formComponent, - quoteComponent + quoteComponent, + isQuoteDisplayed ]); return { diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index 8cd96bb1b..bc54071e7 100644 --- a/apps/frontend/src/hooks/useRampUrlParams.ts +++ b/apps/frontend/src/hooks/useRampUrlParams.ts @@ -2,16 +2,22 @@ import { AssetHubToken, DestinationType, EPaymentMethod, + type EvmNetworks, EvmToken, FiatToken, + getEvmTokenConfig, + getEvmTokensLoadedSnapshot, + isNetworkEVM, Networks, OnChainToken, + OnChainTokenSymbol, PaymentMethod, QuoteResponse, - RampDirection + RampDirection, + subscribeEvmTokensLoaded } from "@vortexfi/shared"; import Big from "big.js"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { getFirstEnabledFiatToken, isFiatTokenEnabled } from "../config/tokenAvailability"; import { useNetwork } from "../contexts/network"; import { useRampActor } from "../contexts/rampState"; @@ -33,7 +39,7 @@ interface RampUrlParams { moneriumCode?: string; fiat?: FiatToken; countryCode?: string; - cryptoLocked?: OnChainToken; + cryptoLocked?: OnChainTokenSymbol; paymentMethod?: PaymentMethod; walletLocked?: string; callbackUrl?: string; @@ -58,7 +64,7 @@ function findFiatToken(fiatToken?: string): FiatToken | undefined { return foundToken; } -function findOnChainToken(tokenStr?: string, networkType?: Networks | string): OnChainToken | undefined { +function findOnChainToken(tokenStr?: string, networkType?: Networks | string): OnChainTokenSymbol | undefined { if (!tokenStr || !networkType) { return undefined; } @@ -76,15 +82,15 @@ function findOnChainToken(tokenStr?: string, networkType?: Networks | string): O const [_, tokenValue] = matchedToken; return tokenValue as unknown as OnChainToken; } else { - const evmTokenEntries = Object.entries(EvmToken); - const matchedToken = evmTokenEntries.find(([_, token]) => token.toUpperCase() === tokenStr); - - if (!matchedToken) { - return EvmToken.USDC; + if (isNetworkEVM(networkType as Networks)) { + const dynamicConfig = getEvmTokenConfig(); + const networkTokens = dynamicConfig[networkType as EvmNetworks]; + if (networkTokens && tokenStr in networkTokens) { + return tokenStr; + } } - const [_, tokenValue] = matchedToken; - return tokenValue as OnChainToken; + return EvmToken.USDC; } } @@ -96,6 +102,12 @@ function getNetworkFromParam(param?: string): Networks | undefined { return undefined; } +function stripSurroundingQuotes(str?: string | null): string | null { + // TanStack Router JSON-serializes strings, so inputAmount=%22100%22 decodes to "100" + // with literal quote chars via raw URLSearchParams. + return str?.replace(/^["']|["']$/g, "") || null; +} + const mapFiatToDestination = (fiatToken: FiatToken): DestinationType => { const destinationMap: Record = { ARS: EPaymentMethod.CBU, @@ -108,7 +120,7 @@ const mapFiatToDestination = (fiatToken: FiatToken): DestinationType => { interface QuoteParams { inputAmount?: Big; - onChainToken: OnChainToken; + onChainToken: OnChainTokenSymbol; fiatToken: FiatToken; selectedNetwork: DestinationType; rampType: RampDirection; @@ -119,8 +131,8 @@ interface QuotePayload { fromDestination: DestinationType; toDestination: DestinationType; inputAmount: string; - inputCurrency: OnChainToken | FiatToken; - outputCurrency: OnChainToken | FiatToken; + inputCurrency: OnChainTokenSymbol | FiatToken; + outputCurrency: OnChainTokenSymbol | FiatToken; } const createQuotePayload = (params: QuoteParams): QuotePayload => { @@ -168,25 +180,31 @@ export enum RampUrlParamsKeys { } export const useRampUrlParams = (): RampUrlParams => { + console.log("useRampUrlParams"); const params = useMemo(() => new URLSearchParams(window.location.search), []); const { selectedNetwork } = useNetwork(); const rampDirectionStore = useRampDirection(); + const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot); const urlParams = useMemo(() => { const rampDirectionParam = params.get(RampUrlParamsKeys.RAMP_TYPE)?.toUpperCase(); + const fiatParam = params.get(RampUrlParamsKeys.FIAT)?.toUpperCase(); + const cryptoLockedParam = params.get(RampUrlParamsKeys.CRYPTO_LOCKED)?.toUpperCase(); + const countryCodeParam = params.get(RampUrlParamsKeys.COUNTRY_CODE)?.toUpperCase(); + + const moneriumCode = params.get(RampUrlParamsKeys.MONERIUM_CODE)?.toLowerCase(); const networkParam = params.get(RampUrlParamsKeys.NETWORK)?.toLowerCase(); - const inputAmountParam = params.get(RampUrlParamsKeys.INPUT_AMOUNT); + const providedQuoteId = params.get(RampUrlParamsKeys.PROVIDED_QUOTE_ID)?.toLowerCase(); + const paymentMethodParam = params.get(RampUrlParamsKeys.PAYMENT_METHOD)?.toLowerCase() as PaymentMethod | undefined; + + const rawInputAmount = params.get(RampUrlParamsKeys.INPUT_AMOUNT); + const inputAmountParam = stripSurroundingQuotes(rawInputAmount); + const partnerIdParam = params.get(RampUrlParamsKeys.PARTNER_ID); const apiKeyParam = params.get(RampUrlParamsKeys.API_KEY); - const moneriumCode = params.get(RampUrlParamsKeys.MONERIUM_CODE)?.toLowerCase(); - const providedQuoteId = params.get(RampUrlParamsKeys.PROVIDED_QUOTE_ID)?.toLowerCase(); - const fiatParam = params.get(RampUrlParamsKeys.FIAT)?.toUpperCase(); - const cryptoLockedParam = params.get(RampUrlParamsKeys.CRYPTO_LOCKED)?.toUpperCase(); - const paymentMethodParam = params.get(RampUrlParamsKeys.PAYMENT_METHOD) as PaymentMethod | undefined; const walletLockedParam = params.get(RampUrlParamsKeys.WALLET_LOCKED); const callbackUrlParam = params.get(RampUrlParamsKeys.CALLBACK_URL); const externalSessionIdParam = params.get(RampUrlParamsKeys.EXTERNAL_SESSION_ID); - const countryCodeParam = params.get(RampUrlParamsKeys.COUNTRY_CODE)?.toUpperCase(); const rampDirection = rampDirectionParam === RampDirection.BUY || rampDirectionParam === RampDirection.SELL @@ -202,6 +220,7 @@ export const useRampUrlParams = (): RampUrlParams => { callbackUrl: callbackUrlParam || undefined, countryCode: countryCodeParam || undefined, cryptoLocked, + evmTokensLoaded, externalSessionId: externalSessionIdParam || undefined, fiat, inputAmount: inputAmountParam || undefined, @@ -213,7 +232,8 @@ export const useRampUrlParams = (): RampUrlParams => { rampDirection, walletLocked: walletLockedParam || undefined }; - }, [params, rampDirectionStore, selectedNetwork]); + // evmTokensLoaded: triggers re-evaluation of cryptoLocked when dynamic tokens (e.g. WETH, WBTC) finish loading from SquidRouter + }, [params, rampDirectionStore, selectedNetwork, evmTokensLoaded]); return urlParams; }; @@ -411,4 +431,10 @@ export const useSetRampUrlParams = () => { handleFiatToken, moneriumCode ]); + + useEffect(() => { + if (cryptoLocked) { + setOnChainToken(cryptoLocked); + } + }, [cryptoLocked, setOnChainToken]); }; diff --git a/apps/frontend/src/hooks/useSyncFormToUrl.ts b/apps/frontend/src/hooks/useSyncFormToUrl.ts new file mode 100644 index 000000000..d5b996de9 --- /dev/null +++ b/apps/frontend/src/hooks/useSyncFormToUrl.ts @@ -0,0 +1,40 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { useNetwork } from "../contexts/network"; +import { useFiatToken, useInputAmount, useOnChainToken } from "../stores/quote/useQuoteFormStore"; +import { useRampDirection } from "../stores/rampDirectionStore"; + +export const useSyncFormToUrl = () => { + const inputAmount = useInputAmount(); + const onChainToken = useOnChainToken(); + const fiatToken = useFiatToken(); + const rampDirection = useRampDirection(); + const { selectedNetwork } = useNetwork(); + const navigate = useNavigate(); + const searchParams = useSearch({ strict: false }) as Record; + + useEffect(() => { + const newValues: Record = { + cryptoLocked: onChainToken, + fiat: fiatToken, + inputAmount: inputAmount || undefined, + network: selectedNetwork, + rampType: rampDirection + }; + + const alreadyInSync = Object.entries(newValues).every( + ([key, value]) => (value === undefined && !(key in searchParams)) || String(searchParams[key] ?? "") === value + ); + + if (alreadyInSync) return; + + navigate({ + replace: true, + search: { + ...searchParams, + ...newValues + }, + to: "." + }); + }, [inputAmount, onChainToken, fiatToken, rampDirection, selectedNetwork, navigate, searchParams]); +}; diff --git a/apps/frontend/src/pages/quote/index.tsx b/apps/frontend/src/pages/quote/index.tsx index 509fc3037..eb1ef3685 100644 --- a/apps/frontend/src/pages/quote/index.tsx +++ b/apps/frontend/src/pages/quote/index.tsx @@ -4,9 +4,11 @@ import { PoweredBy } from "../../components/PoweredBy"; import { Offramp } from "../../components/Ramp/Offramp"; import { Onramp } from "../../components/Ramp/Onramp"; import { RampToggle } from "../../components/RampToggle"; +import { useSyncFormToUrl } from "../../hooks/useSyncFormToUrl"; import { useRampDirection, useRampDirectionToggle } from "../../stores/rampDirectionStore"; export const Quote = () => { + useSyncFormToUrl(); const activeSwapDirection = useRampDirection(); const onSwapDirectionToggle = useRampDirectionToggle(); diff --git a/apps/frontend/src/sections/business/WhyVortexWidget/index.tsx b/apps/frontend/src/sections/business/WhyVortexWidget/index.tsx index 06afadc6f..4e5d5449d 100644 --- a/apps/frontend/src/sections/business/WhyVortexWidget/index.tsx +++ b/apps/frontend/src/sections/business/WhyVortexWidget/index.tsx @@ -105,12 +105,14 @@ export const WhyVortexWidget = () => {
-
- {t("pages.business.hero.comingSoon")} -
- +
diff --git a/apps/frontend/src/services/api/quote.service.ts b/apps/frontend/src/services/api/quote.service.ts index 4d6e4faf5..56540f595 100644 --- a/apps/frontend/src/services/api/quote.service.ts +++ b/apps/frontend/src/services/api/quote.service.ts @@ -3,9 +3,10 @@ import { DestinationType, FiatToken, getNetworkFromDestination, - OnChainToken, + OnChainTokenSymbol, PaymentMethod, QuoteResponse, + RampCurrency, RampDirection } from "@vortexfi/shared"; import { apiRequest } from "./api-client"; @@ -35,8 +36,8 @@ export class QuoteService { from: DestinationType, to: DestinationType, inputAmount: string, - inputCurrency: OnChainToken | FiatToken, - outputCurrency: OnChainToken | FiatToken, + inputCurrency: OnChainTokenSymbol | FiatToken, + outputCurrency: OnChainTokenSymbol | FiatToken, apiKey?: string, partnerId?: string, paymentMethod?: PaymentMethod, @@ -52,9 +53,9 @@ export class QuoteService { countryCode, from, inputAmount, - inputCurrency, + inputCurrency: inputCurrency as RampCurrency, network, - outputCurrency, + outputCurrency: outputCurrency as RampCurrency, paymentMethod, rampType, to diff --git a/apps/frontend/src/services/signingService.tsx b/apps/frontend/src/services/signingService.tsx index 17aba3b78..ee5975636 100644 --- a/apps/frontend/src/services/signingService.tsx +++ b/apps/frontend/src/services/signingService.tsx @@ -20,9 +20,6 @@ interface SignerServiceSep10Response { masterClientPublic: string; } -type BrlaOfframpState = "BURN" | "MONEY-TRANSFER"; -type OfframpStatus = "QUEUED" | "POSTED" | "SUCCESS" | "FAILED"; - export enum KycStatus { PENDING = "PENDING", REJECTED = "REJECTED", @@ -31,11 +28,6 @@ export enum KycStatus { export type KycStatusType = keyof typeof KycStatus; -interface BrlaOfframpStatus { - type: BrlaOfframpState; - status: OfframpStatus; -} - type TaxIdType = "CPF" | "CNPJ"; export interface RegisterSubaccountPayload { diff --git a/apps/frontend/src/stores/quote/useQuoteFormStore.ts b/apps/frontend/src/stores/quote/useQuoteFormStore.ts index 6df6457f0..607a8774f 100644 --- a/apps/frontend/src/stores/quote/useQuoteFormStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteFormStore.ts @@ -5,6 +5,7 @@ import { getOnChainTokenDetails, Networks, OnChainToken, + OnChainTokenSymbol, RampDirection } from "@vortexfi/shared"; import { create } from "zustand"; @@ -42,7 +43,7 @@ const defaultOnChainToken = interface RampFormState { inputAmount: string; - onChainToken: OnChainToken; + onChainToken: OnChainTokenSymbol; fiatToken: FiatToken; lastConstraintDirection: RampDirection; taxId?: string; @@ -52,7 +53,7 @@ interface RampFormState { interface RampFormActions { actions: { setInputAmount: (amount?: string) => void; - setOnChainToken: (token: OnChainToken) => void; + setOnChainToken: (token: OnChainTokenSymbol) => void; setFiatToken: (token: FiatToken) => void; setConstraintDirection: (direction: RampDirection) => void; handleNetworkChange: (network: Networks) => void; @@ -93,7 +94,7 @@ export const useQuoteFormStore = create()( setConstraintDirection: (direction: RampDirection) => set({ lastConstraintDirection: direction }), setFiatToken: (token: FiatToken) => set({ fiatToken: token }), setInputAmount: (amount?: string) => set({ inputAmount: amount }), - setOnChainToken: (token: OnChainToken) => set({ onChainToken: token }), + setOnChainToken: (token: OnChainTokenSymbol) => set({ onChainToken: token }), setPixId: (pixId: string) => set({ pixId }), setTaxId: (taxId: string) => set({ taxId }) } diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 9cf217466..24f25958f 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -3,6 +3,7 @@ import { EPaymentMethod, FiatToken, OnChainToken, + OnChainTokenSymbol, QuoteError, QuoteResponse, RampDirection @@ -14,7 +15,7 @@ import { QuoteService } from "../../services/api"; interface QuoteParams { inputAmount?: Big; - onChainToken: OnChainToken; + onChainToken: OnChainTokenSymbol; fiatToken: FiatToken; selectedNetwork: DestinationType; rampType: RampDirection; @@ -27,8 +28,8 @@ interface QuotePayload { fromDestination: DestinationType; toDestination: DestinationType; inputAmount: string; - inputCurrency: OnChainToken | FiatToken; - outputCurrency: OnChainToken | FiatToken; + inputCurrency: OnChainTokenSymbol | FiatToken; + outputCurrency: OnChainTokenSymbol | FiatToken; } interface QuoteActions { diff --git a/apps/frontend/src/types/phases.ts b/apps/frontend/src/types/phases.ts index ab8a3d221..0a0535f62 100644 --- a/apps/frontend/src/types/phases.ts +++ b/apps/frontend/src/types/phases.ts @@ -2,7 +2,7 @@ import { EphemeralAccount, FiatToken, Networks, - OnChainToken, + OnChainTokenSymbol, PaymentData, PresignedTx, QuoteResponse, @@ -24,7 +24,7 @@ export interface RampState { export interface RampExecutionInput { quote: QuoteResponse; - onChainToken: OnChainToken; + onChainToken: OnChainTokenSymbol; fiatToken: FiatToken; sourceOrDestinationAddress: string; // The source address for offramps, destination address for onramps moneriumWalletAddress?: string; // Only needed for Monerium offramps to non-EVM chains (e.g. Monerium -> Assethub) diff --git a/apps/frontend/src/types/searchParams.ts b/apps/frontend/src/types/searchParams.ts index 15fc59cb5..9f8a2cae7 100644 --- a/apps/frontend/src/types/searchParams.ts +++ b/apps/frontend/src/types/searchParams.ts @@ -1,4 +1,3 @@ -import { EPaymentMethod, RampDirection } from "@vortexfi/shared"; import { z } from "zod"; /** @@ -22,9 +21,9 @@ export const rampSearchSchema = z.object({ inputAmount: stringOrNumberParam, network: z.string().optional(), partnerId: z.string().optional(), - paymentMethod: z.nativeEnum(EPaymentMethod).optional().catch(undefined), + paymentMethod: z.string().optional(), quoteId: z.string().optional(), - rampType: z.nativeEnum(RampDirection).optional().catch(undefined), + rampType: z.string().optional(), walletAddressLocked: z.string().optional() }); diff --git a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts index d2a6640e3..039cdbb1b 100644 --- a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts @@ -45,6 +45,19 @@ const state: DynamicEvmTokensState = { tokensByNetwork: {} as Record>> }; +const evmTokenListeners = new Set<() => void>(); + +export function subscribeEvmTokensLoaded(onStoreChange: () => void): () => void { + evmTokenListeners.add(onStoreChange); + return () => { + evmTokenListeners.delete(onStoreChange); + }; +} + +export function getEvmTokensLoadedSnapshot(): boolean { + return state.isLoaded; +} + /** * Iterates over all EVM networks and calls the callback for each. */ @@ -283,13 +296,26 @@ export async function initializeEvmTokens(): Promise { state.tokensByNetwork = mergeWithStaticConfig(groupedTokens); state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; + for (const listener of evmTokenListeners) { + try { + listener(); + } catch (listenerErr) { + logger.current.error("[DynamicEvmTokens] Error in EVM token listener", listenerErr); + } + } } catch (err) { console.error("[DynamicEvmTokens] Failed to fetch tokens from SquidRouter, using fallback:", err); - state.tokensByNetwork = buildFallbackFromStaticConfig(); state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; } + for (const listener of evmTokenListeners) { + try { + listener(); + } catch (listenerErr) { + logger.current.error("[DynamicEvmTokens] Error in EVM token listener", listenerErr); + } + } } /** diff --git a/packages/shared/src/tokens/types/base.ts b/packages/shared/src/tokens/types/base.ts index 6d2c0b1fc..19250c380 100644 --- a/packages/shared/src/tokens/types/base.ts +++ b/packages/shared/src/tokens/types/base.ts @@ -20,6 +20,8 @@ export enum AssetHubToken { } export type OnChainToken = EvmToken | AssetHubToken; +/** Includes dynamic tokens (e.g. WETH, WBTC) loaded at runtime from SquidRouter */ +export type OnChainTokenSymbol = OnChainToken | (string & {}); export type NablaToken = OnChainToken; // Combines fiat currencies with tokens in one type diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index 6fd5d4a0c..82f3b2de8 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -9,7 +9,7 @@ import { evmTokenConfig } from "../evm/config"; import { getEvmTokenConfig } from "../evm/dynamicEvmTokens"; import { moonbeamTokenConfig } from "../moonbeam/config"; import { stellarTokenConfig } from "../stellar/config"; -import { AssetHubToken, FiatToken, OnChainToken, RampCurrency } from "../types/base"; +import { AssetHubToken, FiatToken, OnChainToken, OnChainTokenSymbol, RampCurrency } from "../types/base"; import { EvmToken, EvmTokenDetails } from "../types/evm"; import { MoonbeamTokenDetails } from "../types/moonbeam"; import { PendulumTokenDetails } from "../types/pendulum"; @@ -22,7 +22,7 @@ import { FiatTokenDetails, OnChainTokenDetails } from "./typeGuards"; */ export function getOnChainTokenDetails( network: Networks, - onChainToken: OnChainToken, + onChainToken: OnChainTokenSymbol, dynamicEvmTokenConfig?: Record>> ): OnChainTokenDetails | undefined { const normalizedOnChainToken = normalizeTokenSymbol(onChainToken); @@ -51,7 +51,7 @@ export function getOnChainTokenDetails( */ export function getOnChainTokenDetailsOrDefault( network: Networks, - onChainToken: OnChainToken, + onChainToken: OnChainTokenSymbol, dynamicEvmTokenConfig?: Record>> ): OnChainTokenDetails { // AXLUSDC doesn't exist Ethereum @@ -67,7 +67,6 @@ export function getOnChainTokenDetailsOrDefault( return maybeOnChainTokenDetails; } - logger.current.error(`Invalid input token type: ${onChainToken}`); if (network === Networks.AssetHub) { const firstAvailableToken = Object.values(assetHubTokenConfig)[0]; if (!firstAvailableToken) {