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
15 changes: 15 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 35 additions & 5 deletions frontend/src/components/RealTimeBalanceSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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<string, string>) => {
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 {
Expand Down Expand Up @@ -32,12 +62,9 @@ const mockBalances = [
];

describe("RealTimeBalanceSync", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it("renders heading and refresh button", () => {
Expand Down Expand Up @@ -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();
});
});

Expand Down
110 changes: 67 additions & 43 deletions frontend/src/components/RealTimeBalanceSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -52,6 +53,8 @@ export function RealTimeBalanceSync({
className = "",
}: RealTimeBalanceSyncProps) {
const liveId = useId();
const locale = useLocale();
const t = useTranslations("realTimeBalanceSync");
const shouldReduceMotion = useReducedMotion();

const {
Expand All @@ -60,23 +63,34 @@ export function RealTimeBalanceSync({
lastUpdated,
error,
refresh,
liveRegionText,
ariaLabel,
} = useBalanceSync(merchantId, apiKey, {
address,
horizonUrl,
pollingInterval,
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 (
<motion.section
className={`rounded-2xl border border-gray-200 bg-white p-4 shadow-sm ${className}`}
aria-label={ariaLabel}
className={`w-full rounded-3xl border border-slate-200 bg-white p-4 shadow-sm transition-all duration-200 ease-out ${className}`}
aria-label={t("sectionAriaLabel")}
aria-busy={isLoading}
{...animProps}
>
Expand All @@ -90,19 +104,19 @@ export function RealTimeBalanceSync({
{liveRegionText}
</div>

<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">
Real-time Balances
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-sm font-semibold text-slate-900">
{t("title")}
</h2>
<motion.button
onClick={refresh}
disabled={isLoading}
whileTap={shouldReduceMotion ? undefined : { scale: 0.92 }}
aria-label="Refresh balances"
aria-label={t("refreshButton")}
aria-describedby={liveId}
className="rounded-md px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-400"
className="inline-flex items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-semibold text-sky-600 transition hover:border-sky-300 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? "Syncing\u2026" : "Refresh"}
{isLoading ? t("syncing") : t("refreshButton")}
</motion.button>
</div>

Expand All @@ -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")}
</motion.p>
) : (
<motion.ul
role="list"
aria-label="Account balances"
className="divide-y divide-gray-100"
aria-label={t("balancesListAriaLabel")}
className="overflow-hidden rounded-2xl border border-slate-100 bg-slate-50 divide-y divide-slate-100"
variants={shouldReduceMotion ? undefined : listVariants}
initial="hidden"
animate="visible"
>
<AnimatePresence mode="popLayout">
{balances.map((b) => (
<motion.li
key={b.code}
layout={shouldReduceMotion ? undefined : true}
variants={shouldReduceMotion ? undefined : itemVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ duration: 0.25, ease: "easeOut" }}
className="flex items-center justify-between py-2"
aria-label={`${b.code} balance: ${b.balance}`}
>
<span className="text-sm font-medium text-gray-700">{b.code}</span>
<motion.span
className="text-sm tabular-nums text-gray-900"
key={`${b.code}-${b.balance}`}
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
transition={{ duration: 0.3 }}
>
{parseFloat(b.balance).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 7,
{balances.map((b) => {
const formattedBalance = parseFloat(b.balance).toLocaleString(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 7,
});

return (
<motion.li
key={b.code}
layout={shouldReduceMotion ? undefined : true}
variants={shouldReduceMotion ? undefined : itemVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ duration: 0.25, ease: "easeOut" }}
className="flex items-center justify-between gap-4 py-3 px-3"
aria-label={t("balanceItemAriaLabel", {
asset: b.code,
balance: formattedBalance,
})}
</motion.span>
</motion.li>
))}
>
<span className="text-sm font-medium text-slate-700">{b.code}</span>
<motion.span
className="text-sm tabular-nums text-slate-900"
key={`${b.code}-${b.balance}`}
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
transition={{ duration: 0.3 }}
>
{formattedBalance}
</motion.span>
</motion.li>
);
})}
</AnimatePresence>
</motion.ul>
)}

{lastUpdated && (
<motion.p
className="mt-3 text-xs text-gray-400"
className="mt-3 text-xs text-slate-400"
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
>
<span aria-hidden="true">Updated </span>
<span className="font-medium text-slate-500">{t("updatedLabel")}</span>{" "}
<time dateTime={lastUpdated.toISOString()}>
{lastUpdated.toLocaleTimeString()}
{lastUpdated.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
})}
</time>
</motion.p>
)}
Expand Down
Loading