diff --git a/apps/frontend/src/components/menus/HistoryMenu/HistoryMenuButton/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/HistoryMenuButton/index.tsx index 7550f31bb..6276f924b 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/HistoryMenuButton/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/HistoryMenuButton/index.tsx @@ -1,8 +1,16 @@ import { ClockIcon } from "@heroicons/react/24/outline"; +import { useSelector } from "@xstate/react"; +import { useRampActor } from "../../../../contexts/rampState"; import { useRampHistoryStore } from "../../../../stores/rampHistoryStore"; export function HistoryMenuButton() { + const rampActor = useRampActor(); const { isActive, actions } = useRampHistoryStore(); + const isAuthenticated = useSelector(rampActor, state => state.context.isAuthenticated); + + if (!isAuthenticated) { + return null; + } return ( <> diff --git a/apps/frontend/src/components/menus/SettingsMenu/index.tsx b/apps/frontend/src/components/menus/SettingsMenu/index.tsx index 510ef99bb..294f03719 100644 --- a/apps/frontend/src/components/menus/SettingsMenu/index.tsx +++ b/apps/frontend/src/components/menus/SettingsMenu/index.tsx @@ -34,7 +34,7 @@ const MenuItem = ({ label, onClick, icon, disabled }: MenuItemProps) => { }; export const SettingsMenu = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const isOpen = useSettingsMenuState(); const { closeMenu } = useSettingsMenuActions(); const rampActor = useRampActor(); @@ -63,7 +63,7 @@ export const SettingsMenu = () => { }, { label: t("menus.settings.item.termsAndConditions"), - onClick: () => handleExternalLink("https://www.vortexfinance.co/terms-conditions") + onClick: () => handleExternalLink(`https://www.vortexfinance.co/${i18n.language}/terms-and-conditions`) }, { label: t("menus.settings.item.imprint"), diff --git a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx index ef3374ac8..a82bf12d1 100644 --- a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx @@ -5,6 +5,7 @@ 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 { QuoteSummary } from "../../QuoteSummary"; const emailSchema = yup.string().email().required(); @@ -43,6 +44,9 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => { return (
+
+ +

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

diff --git a/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx b/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx index b603ac541..281316e99 100644 --- a/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx @@ -6,6 +6,7 @@ import { useRampActor } from "../../../contexts/rampState"; import { cn } from "../../../helpers/cn"; import { useQuote } from "../../../stores/quote/useQuoteStore"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "../../InputOTP"; +import { MenuButtons } from "../../MenuButtons"; import { QuoteSummary } from "../../QuoteSummary"; export interface AuthOTPStepProps { @@ -41,6 +42,10 @@ export function AuthOTPStep({ className }: AuthOTPStepProps) { return (
+
+ +
+

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

@@ -56,6 +61,7 @@ export function AuthOTPStep({ className }: AuthOTPStepProps) {
- + - - - + - + @@ -85,15 +91,6 @@ export function AuthOTPStep({ className }: AuthOTPStepProps) { {isVerifying && (

{t("components.authOTPStep.status.verifying")}

)} - -
diff --git a/apps/frontend/src/hooks/useStepBackNavigation.ts b/apps/frontend/src/hooks/useStepBackNavigation.ts index 3e336b46a..b224398d3 100644 --- a/apps/frontend/src/hooks/useStepBackNavigation.ts +++ b/apps/frontend/src/hooks/useStepBackNavigation.ts @@ -15,14 +15,21 @@ export const useStepBackNavigation = () => { const searchParams = useSearch({ strict: false }); const isExternalProviderEntry = !!searchParams.externalSessionId; const hasQuoteIdInUrl = !!searchParams.quoteId; + const isAuthStep = + rampState === "CheckAuth" || + rampState === "EnterEmail" || + rampState === "CheckingEmail" || + rampState === "RequestingOTP" || + rampState === "EnterOTP" || + rampState === "VerifyingOTP"; // When user removes quoteId from URL while in QuoteReady state (and they entered via form), - // send GO_BACK to return to Idle/Quote form + // send GO_BACK to return to Idle/Quote form. useEffect(() => { - if (!hasQuoteIdInUrl) { + if (!hasQuoteIdInUrl && rampState === "QuoteReady" && enteredViaForm) { rampActor.send({ type: "GO_BACK" }); } - }, [rampActor, hasQuoteIdInUrl]); + }, [rampActor, hasQuoteIdInUrl, rampState, enteredViaForm]); const shouldHide = rampState === "RampFollowUp" || @@ -50,7 +57,7 @@ export const useStepBackNavigation = () => { const isQuoteReady = rampState === "QuoteReady"; const isIdle = rampState === "Idle"; - if (isQuoteReady || isIdle) { + if (isQuoteReady || isIdle || isAuthStep) { navigate({ replace: true, search: {}, to: "." }); } diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 68643f006..2c91b77be 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -60,6 +60,7 @@ const initialRampContext: RampContext = { isSep24Redo: false, partnerId: undefined, paymentData: undefined, + postAuthTarget: undefined, quote: undefined, quoteId: undefined, quoteLocked: undefined, @@ -337,6 +338,20 @@ export const rampMachine = setup({ states: { CheckAuth: { always: [ + { + actions: assign({ + postAuthTarget: undefined + }), + guard: ({ context }) => context.isAuthenticated && context.postAuthTarget === "RegisterRamp", + target: "RegisterRamp" + }, + { + actions: assign({ + postAuthTarget: undefined + }), + guard: ({ context }) => context.isAuthenticated && context.postAuthTarget === "QuoteReady", + target: "QuoteReady" + }, { guard: ({ context }) => context.isAuthenticated, target: "QuoteReady" @@ -344,7 +359,28 @@ export const rampMachine = setup({ { target: "EnterEmail" } - ] + ], + on: { + GO_BACK: [ + { + actions: assign({ + errorMessage: undefined + }), + guard: ({ context }) => context.postAuthTarget === "RegisterRamp", + target: "KycComplete" + }, + { + actions: assign({ + enteredViaForm: undefined, + errorMessage: undefined, + postAuthTarget: undefined, + quote: undefined, + quoteId: undefined + }), + target: "Idle" + } + ] + } }, CheckingEmail: { invoke: { @@ -359,6 +395,27 @@ export const rampMachine = setup({ target: "EnterEmail" }, src: "checkEmail" + }, + on: { + GO_BACK: [ + { + actions: assign({ + errorMessage: undefined + }), + guard: ({ context }) => context.postAuthTarget === "RegisterRamp", + target: "KycComplete" + }, + { + actions: assign({ + enteredViaForm: undefined, + errorMessage: undefined, + postAuthTarget: undefined, + quote: undefined, + quoteId: undefined + }), + target: "Idle" + } + ] } }, EnterEmail: { @@ -369,6 +426,25 @@ export const rampMachine = setup({ }), target: "CheckingEmail" }, + GO_BACK: [ + { + actions: assign({ + errorMessage: undefined + }), + guard: ({ context }) => context.postAuthTarget === "RegisterRamp", + target: "KycComplete" + }, + { + actions: assign({ + enteredViaForm: undefined, + errorMessage: undefined, + postAuthTarget: undefined, + quote: undefined, + quoteId: undefined + }), + target: "Idle" + } + ], SET_QUOTE: { actions: assign({ quoteId: ({ event }) => event.quoteId, @@ -401,6 +477,14 @@ export const rampMachine = setup({ }), target: "CheckingEmail" }, + GO_BACK: [ + { + actions: assign({ + errorMessage: undefined + }), + target: "EnterEmail" + } + ], VERIFY_OTP: { actions: assign({ errorMessage: undefined }), target: "VerifyingOTP" @@ -431,12 +515,6 @@ export const rampMachine = setup({ } }, Idle: { - always: [ - { - guard: ({ context }) => !context.isAuthenticated, - target: "CheckAuth" - } - ], on: { INITIAL_QUOTE_FETCH_FAILED: { target: "InitialFetchFailed" @@ -470,9 +548,21 @@ export const rampMachine = setup({ GO_BACK: { target: "QuoteReady" }, - PROCEED_TO_REGISTRATION: { - target: "RegisterRamp" - }, + PROCEED_TO_REGISTRATION: [ + { + actions: assign({ + postAuthTarget: () => "RegisterRamp" + }), + guard: ({ context }) => !context.isAuthenticated, + target: "CheckAuth" + }, + { + actions: assign({ + postAuthTarget: undefined + }), + target: "RegisterRamp" + } + ], // This will trigger a quoteRefresher after some seconds REFRESH_FAILED: { actions: [{ type: "refreshQuoteActionWithDelay" }] @@ -513,13 +603,24 @@ export const rampMachine = setup({ input: ({ event, context }) => ({ quoteId: (event as Extract).quoteId || context.quoteId! }), - onDone: { - actions: assign({ - isQuoteExpired: ({ event }) => event.output.isExpired, - quote: ({ event }) => event.output.quote - }), - target: "QuoteReady" - }, + onDone: [ + { + actions: assign({ + isQuoteExpired: ({ event }) => event.output.isExpired, + postAuthTarget: () => "QuoteReady", + quote: ({ event }) => event.output.quote + }), + guard: ({ context }) => !context.isAuthenticated && context.enteredViaForm === true, + target: "CheckAuth" + }, + { + actions: assign({ + isQuoteExpired: ({ event }) => event.output.isExpired, + quote: ({ event }) => event.output.quote + }), + target: "QuoteReady" + } + ], onError: { actions: assign({ isQuoteExpired: true, @@ -640,6 +741,27 @@ export const rampMachine = setup({ target: "EnterEmail" }, src: "requestOTP" + }, + on: { + GO_BACK: [ + { + actions: assign({ + errorMessage: undefined + }), + guard: ({ context }) => context.postAuthTarget === "RegisterRamp", + target: "KycComplete" + }, + { + actions: assign({ + enteredViaForm: undefined, + errorMessage: undefined, + postAuthTarget: undefined, + quote: undefined, + quoteId: undefined + }), + target: "Idle" + } + ] } }, Resetting: { @@ -719,25 +841,49 @@ export const rampMachine = setup({ code: (event as any).code, email: context.userEmail! }), - onDone: { - actions: [ - assign({ - errorMessage: undefined, - isAuthenticated: true, - userId: ({ event }) => event.output.userId - }), - ({ event, context }) => { - // Store tokens in localStorage for session persistence - AuthService.storeTokens({ - accessToken: event.output.accessToken, - refreshToken: event.output.refreshToken, - userEmail: context.userEmail, - userId: event.output.userId - }); - } - ], - target: "QuoteReady" - }, + onDone: [ + { + actions: [ + assign({ + errorMessage: undefined, + isAuthenticated: true, + postAuthTarget: undefined, + userId: ({ event }) => event.output.userId + }), + ({ event, context }) => { + // Store tokens in localStorage for session persistence + AuthService.storeTokens({ + accessToken: event.output.accessToken, + refreshToken: event.output.refreshToken, + userEmail: context.userEmail, + userId: event.output.userId + }); + } + ], + guard: ({ context }) => context.postAuthTarget === "RegisterRamp", + target: "RegisterRamp" + }, + { + actions: [ + assign({ + errorMessage: undefined, + isAuthenticated: true, + postAuthTarget: undefined, + userId: ({ event }) => event.output.userId + }), + ({ event, context }) => { + // Store tokens in localStorage for session persistence + AuthService.storeTokens({ + accessToken: event.output.accessToken, + refreshToken: event.output.refreshToken, + userEmail: context.userEmail, + userId: event.output.userId + }); + } + ], + target: "QuoteReady" + } + ], onError: { actions: assign({ errorMessage: "Invalid OTP code. Please try again." @@ -745,6 +891,14 @@ export const rampMachine = setup({ target: "EnterOTP" }, src: "verifyOTP" + }, + on: { + GO_BACK: { + actions: assign({ + errorMessage: undefined + }), + target: "EnterEmail" + } } } } diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 1048c8f31..64a474082 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -41,6 +41,7 @@ export interface RampContext { userEmail?: string; userId?: string; isAuthenticated: boolean; + postAuthTarget?: "QuoteReady" | "RegisterRamp"; } export type RampMachineEvents = diff --git a/docs/architecture/ramp-machine-widget-flow.md b/docs/architecture/ramp-machine-widget-flow.md new file mode 100644 index 000000000..b5d0cb53c --- /dev/null +++ b/docs/architecture/ramp-machine-widget-flow.md @@ -0,0 +1,159 @@ +# Ramp Machine + Widget Card Flow + +This document maps how the top-level XState machine (`ramp.machine.ts`) drives what the widget renders, and which UI actions send events back into the machine. + +## Source of truth +- Machine: `apps/frontend/src/machines/ramp.machine.ts` +- KYC node: `apps/frontend/src/machines/kyc.states.ts` +- Widget rendering switch: `apps/frontend/src/pages/widget/index.tsx` +- URL/bootstrap events: `apps/frontend/src/hooks/useRampUrlParams.ts` +- Main CTA behavior: `apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx` + +## High-level flow (machine) +```mermaid +flowchart TD + A[Idle] -->|SET_QUOTE| B[LoadingQuote] + B -->|quote loaded + unauth + enteredViaForm| AA[CheckAuth] + B -->|quote loaded otherwise| C[QuoteReady] + C -->|CONFIRM| D[RampRequested] + + D -->|kycNeeded = true| E[KYC] + D -->|kycNeeded = false and BRL| F[KycComplete] + + E -->|child KYC done| F + E -->|child KYC error| X[KycFailure] + X --> R[Resetting] + + F -->|PROCEED_TO_REGISTRATION + authenticated| G[RegisterRamp] + F -->|PROCEED_TO_REGISTRATION + unauthenticated| AA[CheckAuth] + F -->|GO_BACK| C + + G --> H[UpdateRamp] + + H -->|SELL onDone| I[StartRamp] + H -->|BUY onDone| H + H -->|PAYMENT_CONFIRMED 'BUY'| I + + I -->|callbackUrl present| J[RedirectCallback] + I -->|no callbackUrl| K[RampFollowUp] + + K -->|FINISH_OFFRAMPING| R + J -->|after 5s + cleanup| A + + C -->|GO_BACK| A + A -->|INITIAL_QUOTE_FETCH_FAILED| Q[InitialFetchFailed] + + R -->|urlCleaner done| A + + AA -->|authenticated + postAuthTarget=RegisterRamp| G + AA -->|authenticated + postAuthTarget=QuoteReady| C + AA -->|authenticated| C + AA -->|not authenticated| AB[EnterEmail] + AB --> AC[CheckingEmail] + AC --> AD[RequestingOTP] + AD --> AE[EnterOTP] + AE --> AF[VerifyingOTP] + AF -->|success + postAuthTarget=RegisterRamp| G + AF -->|success otherwise| C + AF -->|error| AE + + G -->|error| Z[Error] + H -->|error| Z + I -->|error| Z +``` + +## Widget card resolution order (important) +`WidgetContent` picks the first matching branch in this order: + +1. `ErrorStep` if machine matches `Error` +2. `RampFollowUpRedirectStep` if machine matches `RedirectCallback` +3. `AuthEmailStep` for `CheckAuth | EnterEmail | CheckingEmail | RequestingOTP` +4. `AuthOTPStep` for `EnterOTP | VerifyingOTP` +5. `MoneriumRedirectStep` if Monerium child actor exists and child state is `Redirect` +6. `SummaryStep` for `KycComplete | RegisterRamp | UpdateRamp | StartRamp` +7. Avenia branch if Avenia child actor exists: + - `AveniaKYBFlow` when CNPJ + `kybUrls` present + - else `AveniaKYBForm` (CNPJ) + - else `AveniaKYCForm` (CPF) +8. `InitialQuoteFailedStep` for `InitialFetchFailed` +9. fallback: `DetailsStep` + +## KYC subflow and cards +```mermaid +flowchart TD + KYC[KYC.Deciding] -->|fiat = BRL| AV[Avenia child machine] + KYC -->|fiat = EURC and BUY| MO[Monerium child machine] + KYC -->|otherwise| ST[Stellar child machine] + + AV -->|done without error| VC[VerificationComplete] + MO -->|done with authToken| VC + ST -->|done with paymentData| VC + + VC --> KC[KycComplete] + + AV -->|error| KF[KycFailure] + MO -->|error| KF + ST -->|error| KF + + MO -. child state Redirect .-> MR[MoneriumRedirectStep card] + + AV -. child exists .-> AC[Avenia cards] + AC --> AKYC[AveniaKYCForm] + AC --> AKYB[AveniaKYBForm] + AC --> AKYBF[AveniaKYBFlow] +``` + +## State-to-card map +| Machine state / condition | Card shown | +|---|---| +| `Error` | `ErrorStep` | +| `RedirectCallback` | `RampFollowUpRedirectStep` | +| `CheckAuth`, `EnterEmail`, `CheckingEmail`, `RequestingOTP` | `AuthEmailStep` | +| `EnterOTP`, `VerifyingOTP` | `AuthOTPStep` | +| Monerium child actor state `Redirect` | `MoneriumRedirectStep` | +| `KycComplete`, `RegisterRamp`, `UpdateRamp`, `StartRamp` | `SummaryStep` | +| Avenia actor exists + CNPJ + `kybUrls` | `AveniaKYBFlow` | +| Avenia actor exists + CNPJ (no `kybUrls`) | `AveniaKYBForm` | +| Avenia actor exists + CPF | `AveniaKYCForm` | +| `InitialFetchFailed` | `InitialQuoteFailedStep` | +| everything else | `DetailsStep` | + +## Key UI -> machine events +- `DetailsStep` submit -> `CONFIRM` (via `useRampSubmission`) and `SET_ADDRESS` +- `RampSubmitButton`: + - in `QuoteReady` -> `CONFIRM` + - in `KycComplete` -> `PROCEED_TO_REGISTRATION` + - default -> `SummaryConfirm` + - in `UpdateRamp` on onramp -> `PAYMENT_CONFIRMED` + - if quote expired -> `RESET_RAMP` +- `AuthEmailStep` -> `ENTER_EMAIL` +- `AuthOTPStep` -> `VERIFY_OTP` +- Error/initial-failure/retry actions -> `RESET_RAMP` +- Back button (`StepBackButton`) primarily sends `GO_BACK` (with Avenia-specific child events in document/liveness/KYB sub-steps) + +## URL/bootstrap interactions +`useSetRampUrlParams` seeds machine state at widget load: +- `SET_QUOTE_PARAMS` +- `SET_EXTERNAL_ID` (if provided) +- `SET_QUOTE` (provided quoteId or fetched quote) +- `INITIAL_QUOTE_FETCH_FAILED` on quote fetch failure + +This is why many sessions start in `LoadingQuote`/`QuoteReady` rather than plain `Idle`. + +## Auth gating change +- The initial `Idle -> CheckAuth` auto-transition was removed. +- For `/widget` entry coming from Quote form (`enteredViaForm`), auth can happen directly after `LoadingQuote` and before `QuoteReady`. +- Auth is also deferred to `KycComplete -> PROCEED_TO_REGISTRATION` when needed. +- `postAuthTarget` tracks whether post-auth continuation should be `QuoteReady` or `RegisterRamp`. +- `GO_BACK` behavior in auth states: + - `CheckAuth`, `EnterEmail`, `CheckingEmail`, `RequestingOTP`: back to `KycComplete` when `postAuthTarget=RegisterRamp`, otherwise reset to `Idle` (Quote form path). + - `EnterOTP`, `VerifyingOTP`: back to `EnterEmail`. + +## Practical reading model +When debugging what card should show, check in this order: +1. Top-level ramp state (`rampActor.getSnapshot().value`) +2. Whether Monerium child is in `Redirect` +3. Whether Avenia child exists and its context (`taxId`, `kybUrls`, `kybStep`) +4. Whether auth gating states (`CheckAuth`...`VerifyingOTP`) currently match + +The render priority order can override expectations from raw machine state (for example, a Monerium `Redirect` child card can appear before generic details/some other fallback views). diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html index ea3c14ea1..4b73a1f64 100644 --- a/supabase/templates/magic_link.html +++ b/supabase/templates/magic_link.html @@ -143,8 +143,8 @@

VORTEX

diff --git a/supabase/templates/signup.html b/supabase/templates/signup.html index e81108425..700b9ed90 100644 --- a/supabase/templates/signup.html +++ b/supabase/templates/signup.html @@ -145,8 +145,8 @@

Confirm your email address