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
5 changes: 4 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
"codeInputPlaceholder": "000000",
"verifyButton": "Verify & Enable",
"verifying": "Verifying…",
"retryButton": "Retry",
"stepAnnouncement": "Step {current} of {total}",
"successTitle": "Two-Factor Authentication Enabled",
"successDescription": "Your account is now protected. You will be prompted for a code on each login.",
"error": {
"setupFailed": "Failed to start 2FA setup. Please try again.",
"invalidCode": "Invalid code. Please try again.",
"codeLength": "Please enter the 6-digit code from your authenticator app."
"codeLength": "Please enter the 6-digit code from your authenticator app.",
"networkFailure": "Couldn't reach the server. Check your connection and retry — your code and QR setup are still here."
}
},
"localeSwitcher": {
Expand Down
5 changes: 4 additions & 1 deletion frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
"codeInputPlaceholder": "000000",
"verifyButton": "Verificar y activar",
"verifying": "Verificando…",
"retryButton": "Reintentar",
"stepAnnouncement": "Paso {current} de {total}",
"successTitle": "Autenticacion en dos pasos activada",
"successDescription": "Tu cuenta esta ahora protegida. Se te pedira un codigo en cada inicio de sesion.",
"error": {
"setupFailed": "No se pudo iniciar la configuracion de 2FA. Intentalo de nuevo.",
"invalidCode": "Codigo invalido. Intentalo de nuevo.",
"codeLength": "Ingresa el codigo de 6 digitos de tu aplicacion de autenticacion."
"codeLength": "Ingresa el codigo de 6 digitos de tu aplicacion de autenticacion.",
"networkFailure": "No se pudo conectar con el servidor. Revisa tu conexion y reintenta — tu codigo y la configuracion QR siguen aqui."
}
},
"localeSwitcher": {
Expand Down
5 changes: 4 additions & 1 deletion frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
"codeInputPlaceholder": "000000",
"verifyButton": "Verificar e ativar",
"verifying": "Verificando…",
"retryButton": "Tentar novamente",
"stepAnnouncement": "Etapa {current} de {total}",
"successTitle": "Autenticacao em duas etapas ativada",
"successDescription": "Sua conta esta agora protegida. Voce sera solicitado um codigo em cada login.",
"error": {
"setupFailed": "Falha ao iniciar a configuracao de 2FA. Tente novamente.",
"invalidCode": "Codigo invalido. Tente novamente.",
"codeLength": "Insira o codigo de 6 digitos do seu aplicativo de autenticacao."
"codeLength": "Insira o codigo de 6 digitos do seu aplicativo de autenticacao.",
"networkFailure": "Nao foi possivel conectar ao servidor. Verifique sua conexao e tente novamente — seu codigo e a configuracao QR continuam aqui."
}
},
"localeSwitcher": {
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/components/AnalyticsCards.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import "@testing-library/jest-dom/vitest";
import AnalyticsCards from "./AnalyticsCards";

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import "@testing-library/jest-dom/vitest";
import AnalyticsCards from "./AnalyticsCards";
Expand Down Expand Up @@ -53,6 +57,9 @@ function mockFetchSuccess() {
.mockResolvedValueOnce({ ok: true, json: async () => PAYMENTS_RESPONSE });
}

describe("AnalyticsCards", () => {
beforeEach(() => {
vi.clearAllMocks();
function setViewport(width: number) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("max-width") && width <= 640,
Expand Down Expand Up @@ -184,6 +191,30 @@ describe("AnalyticsCards", () => {

await waitFor(() => screen.getByText("Total Volume (7D)"));
fireEvent.click(screen.getByText("Total Volume (7D)").closest("button")!);

const dialog = screen.getByRole("dialog");
const closeButton = screen.getByTestId("modal-close");

// Only the close button is focusable inside this dialog's body (plain text detail).
closeButton.focus();
expect(document.activeElement).toBe(closeButton);

fireEvent.keyDown(document, { key: "Tab" });
expect(document.activeElement).toBe(closeButton);
void dialog;
});

it("restores focus to the triggering card when closed", async () => {
mockFetchSuccess();
render(<AnalyticsCards />);

await waitFor(() => screen.getByText("Total Volume (7D)"));
const trigger = screen.getByText("Total Volume (7D)").closest("button")!;
trigger.focus();
fireEvent.click(trigger);

fireEvent.keyDown(document, { key: "Escape" });
expect(document.activeElement).toBe(trigger);
expect(screen.getByTestId("analytics-card-dialog")).toBeInTheDocument();

fireEvent.keyDown(document, { key: "Escape" });
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/AnalyticsCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
useDisplayPreferences,
formatAmount,
} from "@/lib/display-preferences";
import { Modal } from "./ui/Modal";

interface MetricsResponse {
total_volume: number;
Expand Down Expand Up @@ -71,6 +72,7 @@ export default function AnalyticsCards() {
const [successRate, setSuccessRate] = useState<number>(0);
const [activeIntents, setActiveIntents] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [openCardId, setOpenCardId] = useState<string | null>(null);
const [networkError, setNetworkError] = useState(false);
const [openCardId, setOpenCardId] = useState<string | null>(null);
const [retryNonce, setRetryNonce] = useState(0);
Expand Down Expand Up @@ -188,8 +190,32 @@ export default function AnalyticsCards() {
);
}

const cards: CardDetail[] = [
{
id: "total-volume",
label: "Total Volume (7D)",
value: formatAmount(totalVolume, locale, hideCents),
description: "Total payment volume processed across all confirmed transactions in the last 7 days.",
},
{
id: "success-rate",
label: "Success Rate",
value: `${successRate.toFixed(1)}%`,
description: "Share of resolved payments (confirmed vs. confirmed + failed/refunded) in the last 7 days. Pending payments aren't counted until they resolve.",
},
{
id: "active-intents",
label: "Active intents",
value: String(activeIntents),
description: "Payments currently awaiting confirmation. These are not yet counted in the success rate above.",
},
];

const openCard = cards.find((c) => c.id === openCardId) ?? null;

return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => (
{networkError && (
<div
role="alert"
Expand Down Expand Up @@ -239,6 +265,18 @@ export default function AnalyticsCards() {
</button>
))}

<Modal
isOpen={openCard !== null}
onClose={() => setOpenCardId(null)}
title={openCard?.label ?? ""}
>
{openCard && (
<div className="flex flex-col gap-3">
<p className="text-3xl font-bold tracking-tight">{openCard.value}</p>
<p className="text-sm text-slate-300">{openCard.description}</p>
</div>
)}
</Modal>
{isMobile ? (
<AnalyticsCardDrawer
card={openCard}
Expand Down
Loading