diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ef8ebb23..b7272c71 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -83,6 +83,21 @@ "apiKeysTip": "Use the API Keys page to rotate your secret token periodically.", "webhookLogsTip": "Check Webhook Logs status if payments are not triggering your backend." }, + "realTimeBalanceSync": { + "title": "Real-time Balances", + "refreshButton": "Refresh", + "syncing": "Syncing…", + "sectionAriaLabel": "Real-time balance information", + "balancesListAriaLabel": "Account balances", + "emptyState": "No balances available.", + "updatedLabel": "Updated", + "balanceItemAriaLabel": "{asset} balance: {balance}", + "liveRegion": { + "syncing": "Syncing balances…", + "error": "Balance sync error: {error}", + "updatedAt": "Balances updated at {time}." + } + }, "createPaymentPage": { "eyebrow": "Dashboard", "title": "Create Payment Link", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6abd3b17..84cdd26b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -83,6 +83,21 @@ "apiKeysTip": "Usa la pagina de Claves API para rotar tu token secreto periodicamente.", "webhookLogsTip": "Revisa Registros Webhook si los pagos no estan activando tu backend." }, + "realTimeBalanceSync": { + "title": "Saldos en tiempo real", + "refreshButton": "Actualizar", + "syncing": "Sincronizando…", + "sectionAriaLabel": "Informacion de saldo en tiempo real", + "balancesListAriaLabel": "Saldos de la cuenta", + "emptyState": "No hay saldos disponibles.", + "updatedLabel": "Actualizado", + "balanceItemAriaLabel": "{asset} saldo: {balance}", + "liveRegion": { + "syncing": "Sincronizando saldos…", + "error": "Error de sincronizacion de saldos: {error}", + "updatedAt": "Saldos actualizados a las {time}." + } + }, "createPaymentPage": { "eyebrow": "Panel", "title": "Crear enlace de pago", diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 5451c12f..1a6992e1 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -83,6 +83,21 @@ "apiKeysTip": "Use a pagina de Chaves API para rotacionar seu token secreto periodicamente.", "webhookLogsTip": "Confira os Logs de Webhook se os pagamentos nao estiverem acionando seu backend." }, + "realTimeBalanceSync": { + "title": "Saldos em tempo real", + "refreshButton": "Atualizar", + "syncing": "Sincronizando…", + "sectionAriaLabel": "Informacoes de saldo em tempo real", + "balancesListAriaLabel": "Saldos da conta", + "emptyState": "Nenhum saldo disponivel.", + "updatedLabel": "Atualizado", + "balanceItemAriaLabel": "{asset} saldo: {balance}", + "liveRegion": { + "syncing": "Sincronizando saldos…", + "error": "Erro de sincronizacao de saldos: {error}", + "updatedAt": "Saldos atualizados as {time}." + } + }, "createPaymentPage": { "eyebrow": "Painel", "title": "Criar link de pagamento", diff --git a/frontend/src/components/RealTimeBalanceSync.test.tsx b/frontend/src/components/RealTimeBalanceSync.test.tsx index 189d815b..5c517907 100644 --- a/frontend/src/components/RealTimeBalanceSync.test.tsx +++ b/frontend/src/components/RealTimeBalanceSync.test.tsx @@ -3,6 +3,36 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { RealTimeBalanceSync } from "./RealTimeBalanceSync"; +vi.mock("next-intl", () => { + const translations: Record = { + "realTimeBalanceSync.title": "Real-time Balances", + "realTimeBalanceSync.refreshButton": "Refresh", + "realTimeBalanceSync.syncing": "Syncing…", + "realTimeBalanceSync.sectionAriaLabel": "Real-time balance information", + "realTimeBalanceSync.balancesListAriaLabel": "Account balances", + "realTimeBalanceSync.emptyState": "No balances available.", + "realTimeBalanceSync.updatedLabel": "Updated", + "realTimeBalanceSync.balanceItemAriaLabel": "{asset} balance: {balance}", + "realTimeBalanceSync.liveRegion.syncing": "Syncing balances…", + "realTimeBalanceSync.liveRegion.error": "Balance sync error: {error}", + "realTimeBalanceSync.liveRegion.updatedAt": "Balances updated at {time}.", + }; + + return { + __esModule: true, + useTranslations: (namespace: string) => (key: string, params?: Record) => { + const template = translations[`${namespace}.${key}`] ?? key; + if (!params) return template; + return Object.entries(params).reduce( + (result, [paramKey, paramValue]) => + result.replace(`{${paramKey}}`, String(paramValue)), + template, + ); + }, + useLocale: () => "en", + }; +}); + vi.mock("framer-motion", async () => { const actual = await vi.importActual("framer-motion"); return { @@ -32,12 +62,9 @@ const mockBalances = [ ]; describe("RealTimeBalanceSync", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it("renders heading and refresh button", () => { @@ -123,7 +150,10 @@ describe("RealTimeBalanceSync", () => { ); await waitFor(() => { - expect(screen.getByText(/updated/i)).toBeInTheDocument(); + expect(screen.getByText("Updated")).toBeInTheDocument(); + expect( + screen.getByText(/\d{1,2}:\d{2} (AM|PM)/, { selector: "time" }), + ).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/RealTimeBalanceSync.tsx b/frontend/src/components/RealTimeBalanceSync.tsx index 22482dea..d77115a5 100644 --- a/frontend/src/components/RealTimeBalanceSync.tsx +++ b/frontend/src/components/RealTimeBalanceSync.tsx @@ -2,6 +2,7 @@ import React, { useId } from "react"; import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion"; +import { useTranslations, useLocale } from "next-intl"; import { useBalanceSync } from "@/hooks/useBalanceSync"; interface RealTimeBalanceSyncProps { @@ -52,6 +53,8 @@ export function RealTimeBalanceSync({ className = "", }: RealTimeBalanceSyncProps) { const liveId = useId(); + const locale = useLocale(); + const t = useTranslations("realTimeBalanceSync"); const shouldReduceMotion = useReducedMotion(); const { @@ -60,8 +63,6 @@ export function RealTimeBalanceSync({ lastUpdated, error, refresh, - liveRegionText, - ariaLabel, } = useBalanceSync(merchantId, apiKey, { address, horizonUrl, @@ -69,14 +70,27 @@ export function RealTimeBalanceSync({ enabled: true, }); + const liveRegionText = isLoading + ? t("liveRegion.syncing") + : error + ? t("liveRegion.error", { error }) + : lastUpdated + ? t("liveRegion.updatedAt", { + time: lastUpdated.toLocaleTimeString(locale, { + hour: "numeric", + minute: "2-digit", + }), + }) + : ""; + const animProps = shouldReduceMotion ? { initial: false, animate: {}, variants: undefined } : { variants: containerVariants, initial: "hidden", animate: "visible" }; return ( @@ -90,19 +104,19 @@ export function RealTimeBalanceSync({ {liveRegionText} -
-

- Real-time Balances +
+

+ {t("title")}

- {isLoading ? "Syncing\u2026" : "Refresh"} + {isLoading ? t("syncing") : t("refreshButton")}
@@ -127,62 +141,72 @@ export function RealTimeBalanceSync({ key="empty" initial={shouldReduceMotion ? undefined : { opacity: 0 }} animate={shouldReduceMotion ? undefined : { opacity: 1 }} - className="text-xs text-gray-500" + className="rounded-2xl border border-slate-100 bg-slate-50 px-3 py-3 text-xs text-slate-500" aria-live="polite" > - No balances available. + {t("emptyState")} ) : ( - {balances.map((b) => ( - - {b.code} - - {parseFloat(b.balance).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 7, + {balances.map((b) => { + const formattedBalance = parseFloat(b.balance).toLocaleString(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 7, + }); + + return ( + - - ))} + > + {b.code} + + {formattedBalance} + + + ); + })} )} {lastUpdated && ( - + {t("updatedLabel")}{" "} )}