Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,5 +452,25 @@
"dismissLabel": "Dismiss notification: {message}",
"notificationLabel": "Notification: {message}",
"timestampLabel": "Timestamp: {timestamp}"
},
"fiatOnramp": {
"title": "Buy / Deposit Funds",
"triggerLabel": "Buy / Deposit",
"description": "Deposit fiat via a Stellar anchor (SEP-0024). Funds arrive as tokens directly in your connected wallet.",
"selectAsset": "Select Asset",
"amountLabel": "Amount",
"amountOptional": "(optional)",
"amountPlaceholder": "e.g. {amount} {asset}",
"anchorDomainLabel": "Anchor Domain",
"anchorDomainPlaceholder": "e.g. testanchor.stellar.org",
"continueButton": "Continue to Anchor",
"footerNote": "Secured by Stellar Network - SEP-0024 Standard",
"stepConnecting": "Connecting to anchor...",
"stepAuth": "Waiting for wallet signature...",
"stepGenerating": "Preparing your deposit form...",
"authHint": "Please sign the challenge transaction in your wallet to securely connect to {domain}.",
"genericHint": "This should only take a moment.",
"iframeTitle": "Anchor deposit form",
"genericError": "Deposit failed"
}
}
}
20 changes: 20 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,5 +452,25 @@
"dismissLabel": "Descartar notificación: {message}",
"notificationLabel": "Notificación: {message}",
"timestampLabel": "Marca de tiempo: {timestamp}"
},
"fiatOnramp": {
"title": "Comprar / Depositar Fondos",
"triggerLabel": "Comprar / Depositar",
"description": "Deposita fiat a traves de un anchor de Stellar (SEP-0024). Los fondos llegan como tokens directamente a tu billetera conectada.",
"selectAsset": "Seleccionar Activo",
"amountLabel": "Monto",
"amountOptional": "(opcional)",
"amountPlaceholder": "ej. {amount} {asset}",
"anchorDomainLabel": "Dominio del Anchor",
"anchorDomainPlaceholder": "ej. testanchor.stellar.org",
"continueButton": "Continuar al Anchor",
"footerNote": "Protegido por la red Stellar - Estandar SEP-0024",
"stepConnecting": "Conectando con el anchor...",
"stepAuth": "Esperando firma de la billetera...",
"stepGenerating": "Preparando tu formulario de deposito...",
"authHint": "Firma la transaccion de desafio en tu billetera para conectarte de forma segura a {domain}.",
"genericHint": "Esto solo deberia tomar un momento.",
"iframeTitle": "Formulario de deposito del anchor",
"genericError": "El deposito fallo"
}
}
20 changes: 20 additions & 0 deletions frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,5 +452,25 @@
"dismissLabel": "Dispensar notificacao: {message}",
"notificationLabel": "Notificacao: {message}",
"timestampLabel": "Carimbo de data/hora: {timestamp}"
},
"fiatOnramp": {
"title": "Comprar / Depositar Fundos",
"triggerLabel": "Comprar / Depositar",
"description": "Deposite fiat atraves de um anchor Stellar (SEP-0024). Os fundos chegam como tokens diretamente na sua carteira conectada.",
"selectAsset": "Selecionar Ativo",
"amountLabel": "Valor",
"amountOptional": "(opcional)",
"amountPlaceholder": "ex. {amount} {asset}",
"anchorDomainLabel": "Dominio do Anchor",
"anchorDomainPlaceholder": "ex. testanchor.stellar.org",
"continueButton": "Continuar para o Anchor",
"footerNote": "Protegido pela rede Stellar - Padrao SEP-0024",
"stepConnecting": "Conectando ao anchor...",
"stepAuth": "Aguardando assinatura da carteira...",
"stepGenerating": "Preparando seu formulario de deposito...",
"authHint": "Assine a transacao de desafio na sua carteira para se conectar com seguranca a {domain}.",
"genericHint": "Isso deve levar apenas um momento.",
"iframeTitle": "Formulario de deposito do anchor",
"genericError": "Falha no deposito"
}
}
4 changes: 3 additions & 1 deletion frontend/src/app/(authenticated)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import DashboardSkeleton from "@/components/DashboardSkeleton";
import Link from "next/link";
import {
Expand All @@ -15,6 +16,7 @@ import PaymentsTabs from "@/components/PaymentsTabs";
import FiatOnrampModal from "@/components/FiatOnrampModal";

export default function DashboardPage() {
const t = useTranslations("fiatOnramp");
const [isFirstKeyModalOpen, setIsFirstKeyModalOpen] = useState(false);
const [isOnrampModalOpen, setIsOnrampModalOpen] = useState(false);
const hydrated = useMerchantHydrated();
Expand Down Expand Up @@ -91,7 +93,7 @@ export default function DashboardPage() {
d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Buy / Deposit
{t("triggerLabel")}
</button>

<Link
Expand Down
47 changes: 40 additions & 7 deletions frontend/src/components/FiatOnrampModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,42 @@ import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { NextIntlClientProvider } from "next-intl";

import FiatOnrampModal from "./FiatOnrampModal";

const messages = {
fiatOnramp: {
title: "Buy / Deposit Funds",
triggerLabel: "Buy / Deposit",
description:
"Deposit fiat via a Stellar anchor (SEP-0024). Funds arrive as tokens directly in your connected wallet.",
selectAsset: "Select Asset",
amountLabel: "Amount",
amountOptional: "(optional)",
amountPlaceholder: "e.g. {amount} {asset}",
anchorDomainLabel: "Anchor Domain",
anchorDomainPlaceholder: "e.g. testanchor.stellar.org",
continueButton: "Continue to Anchor",
footerNote: "Secured by Stellar Network - SEP-0024 Standard",
stepConnecting: "Connecting to anchor...",
stepAuth: "Waiting for wallet signature...",
stepGenerating: "Preparing your deposit form...",
authHint: "Please sign the challenge transaction in your wallet to securely connect to {domain}.",
genericHint: "This should only take a moment.",
iframeTitle: "Anchor deposit form",
genericError: "Deposit failed",
},
};

function renderModal(props: { isOpen: boolean; onClose: () => void }) {
return render(
<NextIntlClientProvider locale="en" messages={messages}>
<FiatOnrampModal {...props} />
</NextIntlClientProvider>,
);
}

// ─── framer-motion mock ───────────────────────────────────────────────────────
// jsdom has no layout engine so framer-motion's measurement APIs fail; strip
// animation-only props and render plain HTML so the DOM stays inspectable.
Expand Down Expand Up @@ -56,20 +89,20 @@ describe("FiatOnrampModal", () => {
});

it("does not render when closed", () => {
render(<FiatOnrampModal isOpen={false} onClose={vi.fn()} />);
renderModal({ isOpen: false, onClose: vi.fn() });
expect(screen.queryByText("Buy / Deposit Funds")).not.toBeInTheDocument();
});

it("renders the asset selection form when open", () => {
render(<FiatOnrampModal isOpen onClose={vi.fn()} />);
renderModal({ isOpen: true, onClose: vi.fn() });
expect(screen.getByText("Buy / Deposit Funds")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /USDC/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /SRT/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Continue to Anchor" })).toBeInTheDocument();
});

it("lets the user switch the selected asset", () => {
render(<FiatOnrampModal isOpen onClose={vi.fn()} />);
renderModal({ isOpen: true, onClose: vi.fn() });
const usdcButton = screen.getByRole("button", { name: /USDC/ });
const srtButton = screen.getByRole("button", { name: /SRT/ });

Expand All @@ -83,7 +116,7 @@ describe("FiatOnrampModal", () => {
});

it("walks through connecting, auth, and generating loading states before showing the interactive iframe", async () => {
render(<FiatOnrampModal isOpen onClose={vi.fn()} />);
renderModal({ isOpen: true, onClose: vi.fn() });

fireEvent.click(screen.getByRole("button", { name: "Continue to Anchor" }));

Expand Down Expand Up @@ -112,7 +145,7 @@ describe("FiatOnrampModal", () => {
});

it("passes the entered amount through to initiateDeposit", async () => {
render(<FiatOnrampModal isOpen onClose={vi.fn()} />);
renderModal({ isOpen: true, onClose: vi.fn() });

fireEvent.change(screen.getByLabelText(/Amount/), { target: { value: "250" } });
fireEvent.click(screen.getByRole("button", { name: "Continue to Anchor" }));
Expand All @@ -131,7 +164,7 @@ describe("FiatOnrampModal", () => {
it("shows an inline error and returns to the select step when the wallet is unavailable", async () => {
mockGetFreighterPublicKey.mockRejectedValue(new Error("Freighter is not installed"));

render(<FiatOnrampModal isOpen onClose={vi.fn()} />);
renderModal({ isOpen: true, onClose: vi.fn() });
fireEvent.click(screen.getByRole("button", { name: "Continue to Anchor" }));

await waitFor(() => {
Expand All @@ -144,7 +177,7 @@ describe("FiatOnrampModal", () => {

it("resets state and calls onClose when closed", () => {
const onClose = vi.fn();
render(<FiatOnrampModal isOpen onClose={onClose} />);
renderModal({ isOpen: true, onClose });

fireEvent.click(screen.getByTestId("modal-close"));

Expand Down
48 changes: 22 additions & 26 deletions frontend/src/components/FiatOnrampModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import {
Expand Down Expand Up @@ -33,14 +34,16 @@ const SUPPORTED_ASSETS = [
];

type Step = "SELECT" | "CONNECTING" | "AUTH" | "GENERATING" | "INTERACTIVE";
type BusyStep = Exclude<Step, "SELECT" | "INTERACTIVE">;

const STEP_MESSAGE: Record<Exclude<Step, "SELECT" | "INTERACTIVE">, string> = {
CONNECTING: "Connecting to anchor…",
AUTH: "Waiting for wallet signature…",
GENERATING: "Preparing your deposit form…",
const STEP_MESSAGE_KEY: Record<BusyStep, "stepConnecting" | "stepAuth" | "stepGenerating"> = {
CONNECTING: "stepConnecting",
AUTH: "stepAuth",
GENERATING: "stepGenerating",
};

export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProps) {
const t = useTranslations("fiatOnramp");
const [step, setStep] = useState<Step>("SELECT");
const [amount, setAmount] = useState("");
const [anchorDomain, setAnchorDomain] = useState(DEFAULT_ANCHOR);
Expand Down Expand Up @@ -96,15 +99,15 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
setInteractiveUrl(url);
setStep("INTERACTIVE");
} catch (err) {
const message = err instanceof Error ? err.message : "Deposit failed";
const message = err instanceof Error ? err.message : t("genericError");
toast.error(message);
setError(message);
setStep("SELECT");
}
}, [anchorDomain, amount, selectedAsset]);
}, [anchorDomain, amount, selectedAsset, t]);

return (
<Modal isOpen={isOpen} onClose={handleClose} title="Buy / Deposit Funds">
<Modal isOpen={isOpen} onClose={handleClose} title={t("title")}>
<AnimatePresence mode="wait">
{step === "SELECT" && (
<motion.div
Expand All @@ -115,10 +118,7 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
transition={{ duration: 0.18 }}
className="flex flex-col gap-6"
>
<p className="text-sm text-slate-400">
Deposit fiat via a Stellar anchor (SEP-0024). Funds arrive as tokens directly
in your connected wallet.
</p>
<p className="text-sm text-slate-400">{t("description")}</p>

{error && (
<div
Expand All @@ -131,7 +131,7 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp

<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Select Asset
{t("selectAsset")}
</label>
<div className="grid grid-cols-2 gap-4">
{SUPPORTED_ASSETS.map((asset) => (
Expand All @@ -158,7 +158,7 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp

<div className="space-y-2">
<label htmlFor="onramp-amount" className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Amount <span className="normal-case text-slate-600">(optional)</span>
{t("amountLabel")} <span className="normal-case text-slate-600">{t("amountOptional")}</span>
</label>
<input
id="onramp-amount"
Expand All @@ -168,13 +168,13 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
onChange={(e) => setAmount(e.target.value)}
disabled={isBusy}
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-mint disabled:cursor-not-allowed disabled:opacity-50"
placeholder={`e.g. 100 ${selectedAsset.code}`}
placeholder={t("amountPlaceholder", { amount: "100", asset: selectedAsset.code })}
/>
</div>

<div className="space-y-2">
<label htmlFor="onramp-anchor" className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Anchor Domain
{t("anchorDomainLabel")}
</label>
<input
id="onramp-anchor"
Expand All @@ -183,7 +183,7 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
onChange={(e) => setAnchorDomain(e.target.value)}
disabled={isBusy}
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-mint disabled:cursor-not-allowed disabled:opacity-50"
placeholder="e.g. testanchor.stellar.org"
placeholder={t("anchorDomainPlaceholder")}
/>
</div>

Expand All @@ -196,10 +196,10 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
{isBusy ? (
<>
<Spinner size="sm" className="text-black" />
{STEP_MESSAGE[step as Exclude<Step, "SELECT" | "INTERACTIVE">]}
{t(STEP_MESSAGE_KEY[step as BusyStep])}
</>
) : (
"Continue to Anchor"
t("continueButton")
)}
</button>
</motion.div>
Expand All @@ -222,11 +222,9 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
<Spinner size="lg" className="text-mint" />
</div>
</div>
<h3 className="text-lg font-bold text-white">{STEP_MESSAGE[step]}</h3>
<h3 className="text-lg font-bold text-white">{t(STEP_MESSAGE_KEY[step as BusyStep])}</h3>
<p className="mt-2 text-sm text-slate-400">
{step === "AUTH"
? `Please sign the challenge transaction in your wallet to securely connect to ${anchorDomain}.`
: "This should only take a moment."}
{step === "AUTH" ? t("authHint", { domain: anchorDomain }) : t("genericHint")}
</p>
</motion.div>
)}
Expand All @@ -249,7 +247,7 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
)}
<iframe
src={interactiveUrl}
title="Anchor deposit form"
title={t("iframeTitle")}
className="h-full w-full"
onLoad={() => setIframeLoading(false)}
/>
Expand All @@ -258,9 +256,7 @@ export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProp
</AnimatePresence>

{step !== "INTERACTIVE" && (
<p className="mt-6 text-center text-xs text-slate-500">
Secured by Stellar Network • SEP-0024 Standard
</p>
<p className="mt-6 text-center text-xs text-slate-500">{t("footerNote")}</p>
)}
</Modal>
);
Expand Down
Loading