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 @@