Skip to content

Commit bf436bd

Browse files
committed
Add i18n support and accessibility improvements for RealTimeBalanceSync; fix component tests
1 parent 62d8c40 commit bf436bd

5 files changed

Lines changed: 147 additions & 48 deletions

File tree

frontend/messages/en.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@
8383
"apiKeysTip": "Use the API Keys page to rotate your secret token periodically.",
8484
"webhookLogsTip": "Check Webhook Logs status if payments are not triggering your backend."
8585
},
86+
"realTimeBalanceSync": {
87+
"title": "Real-time Balances",
88+
"refreshButton": "Refresh",
89+
"syncing": "Syncing…",
90+
"sectionAriaLabel": "Real-time balance information",
91+
"balancesListAriaLabel": "Account balances",
92+
"emptyState": "No balances available.",
93+
"updatedLabel": "Updated",
94+
"balanceItemAriaLabel": "{asset} balance: {balance}",
95+
"liveRegion": {
96+
"syncing": "Syncing balances…",
97+
"error": "Balance sync error: {error}",
98+
"updatedAt": "Balances updated at {time}."
99+
}
100+
},
86101
"createPaymentPage": {
87102
"eyebrow": "Dashboard",
88103
"title": "Create Payment Link",

frontend/messages/es.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@
8383
"apiKeysTip": "Usa la pagina de Claves API para rotar tu token secreto periodicamente.",
8484
"webhookLogsTip": "Revisa Registros Webhook si los pagos no estan activando tu backend."
8585
},
86+
"realTimeBalanceSync": {
87+
"title": "Saldos en tiempo real",
88+
"refreshButton": "Actualizar",
89+
"syncing": "Sincronizando…",
90+
"sectionAriaLabel": "Informacion de saldo en tiempo real",
91+
"balancesListAriaLabel": "Saldos de la cuenta",
92+
"emptyState": "No hay saldos disponibles.",
93+
"updatedLabel": "Actualizado",
94+
"balanceItemAriaLabel": "{asset} saldo: {balance}",
95+
"liveRegion": {
96+
"syncing": "Sincronizando saldos…",
97+
"error": "Error de sincronizacion de saldos: {error}",
98+
"updatedAt": "Saldos actualizados a las {time}."
99+
}
100+
},
86101
"createPaymentPage": {
87102
"eyebrow": "Panel",
88103
"title": "Crear enlace de pago",

frontend/messages/pt.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@
8383
"apiKeysTip": "Use a pagina de Chaves API para rotacionar seu token secreto periodicamente.",
8484
"webhookLogsTip": "Confira os Logs de Webhook se os pagamentos nao estiverem acionando seu backend."
8585
},
86+
"realTimeBalanceSync": {
87+
"title": "Saldos em tempo real",
88+
"refreshButton": "Atualizar",
89+
"syncing": "Sincronizando…",
90+
"sectionAriaLabel": "Informacoes de saldo em tempo real",
91+
"balancesListAriaLabel": "Saldos da conta",
92+
"emptyState": "Nenhum saldo disponivel.",
93+
"updatedLabel": "Atualizado",
94+
"balanceItemAriaLabel": "{asset} saldo: {balance}",
95+
"liveRegion": {
96+
"syncing": "Sincronizando saldos…",
97+
"error": "Erro de sincronizacao de saldos: {error}",
98+
"updatedAt": "Saldos atualizados as {time}."
99+
}
100+
},
86101
"createPaymentPage": {
87102
"eyebrow": "Painel",
88103
"title": "Criar link de pagamento",

frontend/src/components/RealTimeBalanceSync.test.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,36 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
33
import "@testing-library/jest-dom";
44
import { RealTimeBalanceSync } from "./RealTimeBalanceSync";
55

6+
vi.mock("next-intl", () => {
7+
const translations: Record<string, string> = {
8+
"realTimeBalanceSync.title": "Real-time Balances",
9+
"realTimeBalanceSync.refreshButton": "Refresh",
10+
"realTimeBalanceSync.syncing": "Syncing…",
11+
"realTimeBalanceSync.sectionAriaLabel": "Real-time balance information",
12+
"realTimeBalanceSync.balancesListAriaLabel": "Account balances",
13+
"realTimeBalanceSync.emptyState": "No balances available.",
14+
"realTimeBalanceSync.updatedLabel": "Updated",
15+
"realTimeBalanceSync.balanceItemAriaLabel": "{asset} balance: {balance}",
16+
"realTimeBalanceSync.liveRegion.syncing": "Syncing balances…",
17+
"realTimeBalanceSync.liveRegion.error": "Balance sync error: {error}",
18+
"realTimeBalanceSync.liveRegion.updatedAt": "Balances updated at {time}.",
19+
};
20+
21+
return {
22+
__esModule: true,
23+
useTranslations: (namespace: string) => (key: string, params?: Record<string, string>) => {
24+
const template = translations[`${namespace}.${key}`] ?? key;
25+
if (!params) return template;
26+
return Object.entries(params).reduce(
27+
(result, [paramKey, paramValue]) =>
28+
result.replace(`{${paramKey}}`, String(paramValue)),
29+
template,
30+
);
31+
},
32+
useLocale: () => "en",
33+
};
34+
});
35+
636
vi.mock("framer-motion", async () => {
737
const actual = await vi.importActual("framer-motion");
838
return {
@@ -32,12 +62,9 @@ const mockBalances = [
3262
];
3363

3464
describe("RealTimeBalanceSync", () => {
35-
beforeEach(() => {
36-
vi.useFakeTimers();
37-
});
38-
3965
afterEach(() => {
4066
vi.restoreAllMocks();
67+
vi.useRealTimers();
4168
});
4269

4370
it("renders heading and refresh button", () => {
@@ -123,7 +150,10 @@ describe("RealTimeBalanceSync", () => {
123150
);
124151

125152
await waitFor(() => {
126-
expect(screen.getByText(/updated/i)).toBeInTheDocument();
153+
expect(screen.getByText("Updated")).toBeInTheDocument();
154+
expect(
155+
screen.getByText(/\d{1,2}:\d{2} (AM|PM)/, { selector: "time" }),
156+
).toBeInTheDocument();
127157
});
128158
});
129159

frontend/src/components/RealTimeBalanceSync.tsx

Lines changed: 67 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React, { useId } from "react";
44
import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion";
5+
import { useTranslations, useLocale } from "next-intl";
56
import { useBalanceSync } from "@/hooks/useBalanceSync";
67

78
interface RealTimeBalanceSyncProps {
@@ -52,6 +53,8 @@ export function RealTimeBalanceSync({
5253
className = "",
5354
}: RealTimeBalanceSyncProps) {
5455
const liveId = useId();
56+
const locale = useLocale();
57+
const t = useTranslations("realTimeBalanceSync");
5558
const shouldReduceMotion = useReducedMotion();
5659

5760
const {
@@ -60,23 +63,34 @@ export function RealTimeBalanceSync({
6063
lastUpdated,
6164
error,
6265
refresh,
63-
liveRegionText,
64-
ariaLabel,
6566
} = useBalanceSync(merchantId, apiKey, {
6667
address,
6768
horizonUrl,
6869
pollingInterval,
6970
enabled: true,
7071
});
7172

73+
const liveRegionText = isLoading
74+
? t("liveRegion.syncing")
75+
: error
76+
? t("liveRegion.error", { error })
77+
: lastUpdated
78+
? t("liveRegion.updatedAt", {
79+
time: lastUpdated.toLocaleTimeString(locale, {
80+
hour: "numeric",
81+
minute: "2-digit",
82+
}),
83+
})
84+
: "";
85+
7286
const animProps = shouldReduceMotion
7387
? { initial: false, animate: {}, variants: undefined }
7488
: { variants: containerVariants, initial: "hidden", animate: "visible" };
7589

7690
return (
7791
<motion.section
78-
className={`rounded-2xl border border-gray-200 bg-white p-4 shadow-sm ${className}`}
79-
aria-label={ariaLabel}
92+
className={`w-full rounded-3xl border border-slate-200 bg-white p-4 shadow-sm transition-all duration-200 ease-out ${className}`}
93+
aria-label={t("sectionAriaLabel")}
8094
aria-busy={isLoading}
8195
{...animProps}
8296
>
@@ -90,19 +104,19 @@ export function RealTimeBalanceSync({
90104
{liveRegionText}
91105
</div>
92106

93-
<div className="mb-3 flex items-center justify-between">
94-
<h2 className="text-sm font-semibold text-gray-800">
95-
Real-time Balances
107+
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
108+
<h2 className="text-sm font-semibold text-slate-900">
109+
{t("title")}
96110
</h2>
97111
<motion.button
98112
onClick={refresh}
99113
disabled={isLoading}
100114
whileTap={shouldReduceMotion ? undefined : { scale: 0.92 }}
101-
aria-label="Refresh balances"
115+
aria-label={t("refreshButton")}
102116
aria-describedby={liveId}
103-
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"
117+
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"
104118
>
105-
{isLoading ? "Syncing\u2026" : "Refresh"}
119+
{isLoading ? t("syncing") : t("refreshButton")}
106120
</motion.button>
107121
</div>
108122

@@ -127,62 +141,72 @@ export function RealTimeBalanceSync({
127141
key="empty"
128142
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
129143
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
130-
className="text-xs text-gray-500"
144+
className="rounded-2xl border border-slate-100 bg-slate-50 px-3 py-3 text-xs text-slate-500"
131145
aria-live="polite"
132146
>
133-
No balances available.
147+
{t("emptyState")}
134148
</motion.p>
135149
) : (
136150
<motion.ul
137151
role="list"
138-
aria-label="Account balances"
139-
className="divide-y divide-gray-100"
152+
aria-label={t("balancesListAriaLabel")}
153+
className="overflow-hidden rounded-2xl border border-slate-100 bg-slate-50 divide-y divide-slate-100"
140154
variants={shouldReduceMotion ? undefined : listVariants}
141155
initial="hidden"
142156
animate="visible"
143157
>
144158
<AnimatePresence mode="popLayout">
145-
{balances.map((b) => (
146-
<motion.li
147-
key={b.code}
148-
layout={shouldReduceMotion ? undefined : true}
149-
variants={shouldReduceMotion ? undefined : itemVariants}
150-
initial="hidden"
151-
animate="visible"
152-
exit="exit"
153-
transition={{ duration: 0.25, ease: "easeOut" }}
154-
className="flex items-center justify-between py-2"
155-
aria-label={`${b.code} balance: ${b.balance}`}
156-
>
157-
<span className="text-sm font-medium text-gray-700">{b.code}</span>
158-
<motion.span
159-
className="text-sm tabular-nums text-gray-900"
160-
key={`${b.code}-${b.balance}`}
161-
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
162-
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
163-
transition={{ duration: 0.3 }}
164-
>
165-
{parseFloat(b.balance).toLocaleString(undefined, {
166-
minimumFractionDigits: 2,
167-
maximumFractionDigits: 7,
159+
{balances.map((b) => {
160+
const formattedBalance = parseFloat(b.balance).toLocaleString(locale, {
161+
minimumFractionDigits: 2,
162+
maximumFractionDigits: 7,
163+
});
164+
165+
return (
166+
<motion.li
167+
key={b.code}
168+
layout={shouldReduceMotion ? undefined : true}
169+
variants={shouldReduceMotion ? undefined : itemVariants}
170+
initial="hidden"
171+
animate="visible"
172+
exit="exit"
173+
transition={{ duration: 0.25, ease: "easeOut" }}
174+
className="flex items-center justify-between gap-4 py-3 px-3"
175+
aria-label={t("balanceItemAriaLabel", {
176+
asset: b.code,
177+
balance: formattedBalance,
168178
})}
169-
</motion.span>
170-
</motion.li>
171-
))}
179+
>
180+
<span className="text-sm font-medium text-slate-700">{b.code}</span>
181+
<motion.span
182+
className="text-sm tabular-nums text-slate-900"
183+
key={`${b.code}-${b.balance}`}
184+
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
185+
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
186+
transition={{ duration: 0.3 }}
187+
>
188+
{formattedBalance}
189+
</motion.span>
190+
</motion.li>
191+
);
192+
})}
172193
</AnimatePresence>
173194
</motion.ul>
174195
)}
175196

176197
{lastUpdated && (
177198
<motion.p
178-
className="mt-3 text-xs text-gray-400"
199+
className="mt-3 text-xs text-slate-400"
179200
initial={shouldReduceMotion ? undefined : { opacity: 0 }}
180201
animate={shouldReduceMotion ? undefined : { opacity: 1 }}
181202
transition={{ delay: 0.2, duration: 0.3 }}
182203
>
183-
<span aria-hidden="true">Updated </span>
204+
<span className="font-medium text-slate-500">{t("updatedLabel")}</span>{" "}
184205
<time dateTime={lastUpdated.toISOString()}>
185-
{lastUpdated.toLocaleTimeString()}
206+
{lastUpdated.toLocaleTimeString(locale, {
207+
hour: "numeric",
208+
minute: "2-digit",
209+
})}
186210
</time>
187211
</motion.p>
188212
)}

0 commit comments

Comments
 (0)