From f2a0d7fe70939360f9bbebd6349b0e9dfd438f64 Mon Sep 17 00:00:00 2001
From: Akatenvictor <“akatenvictor@gmail.com”>
Date: Fri, 24 Jul 2026 22:34:25 +0100
Subject: [PATCH 1/2] feat(frontend): implement internationalization (i18n)
support for KYC Submission Form
Add complete i18n translations for the KYC form module across English,
Spanish, and Portuguese locales. Extract hardcoded strings from the KYC
page into a client component with translation support.
- Add kycForm namespace with 40+ translation keys to en/es/pt messages
- Add kycPage namespace for the KYC page header and info section
- Update KycSubmissionForm to use useTranslations('kycForm') namespace
- Create KycPageContent client component for translated page content
- Update tests to work with i18n mock and AnimatePresence mode='wait'
---
frontend/messages/en.json | 52 +++++++
frontend/messages/es.json | 52 +++++++
frontend/messages/pt.json | 52 +++++++
frontend/src/app/(authenticated)/kyc/page.tsx | 28 +---
frontend/src/components/KycPageContent.tsx | 31 ++++
.../src/components/KycSubmissionForm.test.tsx | 32 +++--
frontend/src/components/KycSubmissionForm.tsx | 134 +++++++++---------
7 files changed, 282 insertions(+), 99 deletions(-)
create mode 100644 frontend/src/components/KycPageContent.tsx
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index af549d57..7a3b0ad7 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -481,5 +481,57 @@
"genericHint": "This should only take a moment.",
"iframeTitle": "Anchor deposit form",
"genericError": "Deposit failed"
+ },
+ "kycPage": {
+ "title": "KYC Verification",
+ "description": "Complete the verification process to comply with regulatory requirements and unlock full access to platform features.",
+ "whyKyc": "Why KYC?",
+ "reasonComply": "Comply with regulatory requirements",
+ "reasonLimits": "Increase transaction limits",
+ "reasonFeatures": "Access premium features",
+ "reasonSecurity": "Enhanced account security"
+ },
+ "kycForm": {
+ "formTitle": "KYC Submission Form",
+ "step": "Step",
+ "of": "of",
+ "steps": "Steps",
+ "completed": "completed",
+ "current": "current",
+ "upcoming": "upcoming",
+ "personalInfo": "Personal Information",
+ "addressInfo": "Address Information",
+ "documents": "Documents",
+ "review": "Review",
+ "firstName": "First Name",
+ "lastName": "Last Name",
+ "email": "Email",
+ "nationality": "Nationality",
+ "dateOfBirth": "Date of Birth",
+ "street": "Street",
+ "city": "City",
+ "state": "State",
+ "postalCode": "Postal Code",
+ "country": "Country",
+ "idType": "ID Type",
+ "selectIdType": "Select ID type",
+ "passport": "Passport",
+ "driversLicense": "Driver's License",
+ "nationalId": "National ID",
+ "idNumber": "ID Number",
+ "idFront": "ID Front",
+ "idBack": "ID Back",
+ "selfie": "Selfie",
+ "required": "Required",
+ "validationError": "Please fill in all required fields",
+ "back": "Back",
+ "next": "Next",
+ "submit": "Submit KYC",
+ "submitting": "Submitting...",
+ "submitError": "Submission failed",
+ "submitAnother": "Submit Another",
+ "successTitle": "KYC Submitted Successfully!",
+ "successDescription": "Your KYC verification has been submitted and is under review.",
+ "dash": "—"
}
}
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index d070f9ad..c5535002 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -481,5 +481,57 @@
"genericHint": "Esto solo deberia tomar un momento.",
"iframeTitle": "Formulario de deposito del anchor",
"genericError": "El deposito fallo"
+ },
+ "kycPage": {
+ "title": "Verificacion KYC",
+ "description": "Completa el proceso de verificacion para cumplir con los requisitos regulatorios y desbloquear el acceso completo a las funciones de la plataforma.",
+ "whyKyc": "Por que KYC?",
+ "reasonComply": "Cumplir con los requisitos regulatorios",
+ "reasonLimits": "Aumentar los limites de transaccion",
+ "reasonFeatures": "Acceder a funciones premium",
+ "reasonSecurity": "Seguridad mejorada de la cuenta"
+ },
+ "kycForm": {
+ "formTitle": "Formulario de envio KYC",
+ "step": "Paso",
+ "of": "de",
+ "steps": "Pasos",
+ "completed": "completado",
+ "current": "actual",
+ "upcoming": "proximo",
+ "personalInfo": "Informacion Personal",
+ "addressInfo": "Informacion de Direccion",
+ "documents": "Documentos",
+ "review": "Revisar",
+ "firstName": "Nombre",
+ "lastName": "Apellido",
+ "email": "Correo electronico",
+ "nationality": "Nacionalidad",
+ "dateOfBirth": "Fecha de nacimiento",
+ "street": "Calle",
+ "city": "Ciudad",
+ "state": "Estado",
+ "postalCode": "Codigo postal",
+ "country": "Pais",
+ "idType": "Tipo de ID",
+ "selectIdType": "Seleccionar tipo de ID",
+ "passport": "Pasaporte",
+ "driversLicense": "Licencia de conducir",
+ "nationalId": "ID Nacional",
+ "idNumber": "Numero de ID",
+ "idFront": "Frente del ID",
+ "idBack": "Reverso del ID",
+ "selfie": "Foto selfie",
+ "required": "Requerido",
+ "validationError": "Por favor completa todos los campos requeridos",
+ "back": "Atras",
+ "next": "Siguiente",
+ "submit": "Enviar KYC",
+ "submitting": "Enviando...",
+ "submitError": "Error al enviar",
+ "submitAnother": "Enviar otro",
+ "successTitle": "KYC enviado exitosamente!",
+ "successDescription": "Tu verificacion KYC ha sido enviada y esta en revision.",
+ "dash": "—"
}
}
diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json
index 29e8446d..76411c77 100644
--- a/frontend/messages/pt.json
+++ b/frontend/messages/pt.json
@@ -481,5 +481,57 @@
"genericHint": "Isso deve levar apenas um momento.",
"iframeTitle": "Formulario de deposito do anchor",
"genericError": "Falha no deposito"
+ },
+ "kycPage": {
+ "title": "Verificacao KYC",
+ "description": "Complete o processo de verificacao para atender aos requisitos regulatorios e desbloquear o acesso completo aos recursos da plataforma.",
+ "whyKyc": "Por que KYC?",
+ "reasonComply": "Atender aos requisitos regulatorios",
+ "reasonLimits": "Aumentar os limites de transacao",
+ "reasonFeatures": "Acessar recursos premium",
+ "reasonSecurity": "Seguranca aprimorada da conta"
+ },
+ "kycForm": {
+ "formTitle": "Formulario de envio KYC",
+ "step": "Passo",
+ "of": "de",
+ "steps": "Passos",
+ "completed": "concluido",
+ "current": "atual",
+ "upcoming": "proximo",
+ "personalInfo": "Informacoes Pessoais",
+ "addressInfo": "Informacoes de Endereco",
+ "documents": "Documentos",
+ "review": "Revisar",
+ "firstName": "Nome",
+ "lastName": "Sobrenome",
+ "email": "E-mail",
+ "nationality": "Nacionalidade",
+ "dateOfBirth": "Data de nascimento",
+ "street": "Rua",
+ "city": "Cidade",
+ "state": "Estado",
+ "postalCode": "Codigo postal",
+ "country": "Pais",
+ "idType": "Tipo de ID",
+ "selectIdType": "Selecionar tipo de ID",
+ "passport": "Passaporte",
+ "driversLicense": "Carteira de motorista",
+ "nationalId": "ID Nacional",
+ "idNumber": "Numero do ID",
+ "idFront": "Frente do ID",
+ "idBack": "Verso do ID",
+ "selfie": "Foto selfie",
+ "required": "Obrigatorio",
+ "validationError": "Por favor preencha todos os campos obrigatorios",
+ "back": "Voltar",
+ "next": "Proximo",
+ "submit": "Enviar KYC",
+ "submitting": "Enviando...",
+ "submitError": "Falha no envio",
+ "submitAnother": "Enviar outro",
+ "successTitle": "KYC enviado com sucesso!",
+ "successDescription": "Sua verificacao KYC foi enviada e esta em revisao.",
+ "dash": "—"
}
}
diff --git a/frontend/src/app/(authenticated)/kyc/page.tsx b/frontend/src/app/(authenticated)/kyc/page.tsx
index ed21e7db..77586800 100644
--- a/frontend/src/app/(authenticated)/kyc/page.tsx
+++ b/frontend/src/app/(authenticated)/kyc/page.tsx
@@ -1,5 +1,5 @@
import { Metadata } from "next";
-import KycSubmissionForm from "@/components/KycSubmissionForm";
+import KycPageContent from "@/components/KycPageContent";
export const metadata: Metadata = {
title: "KYC Verification | PLUTO",
@@ -7,29 +7,5 @@ export const metadata: Metadata = {
};
export default function KycPage() {
- return (
-
-
-
KYC Verification
-
- Complete the verification process to comply with regulatory requirements
- and unlock full access to platform features.
-
-
-
-
-
-
-
-
-
Why KYC?
-
- - • Comply with regulatory requirements
- - • Increase transaction limits
- - • Access premium features
- - • Enhanced account security
-
-
-
- );
+ return ;
}
diff --git a/frontend/src/components/KycPageContent.tsx b/frontend/src/components/KycPageContent.tsx
new file mode 100644
index 00000000..7c84c4fc
--- /dev/null
+++ b/frontend/src/components/KycPageContent.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import KycSubmissionForm from "@/components/KycSubmissionForm";
+
+export default function KycPageContent() {
+ const t = useTranslations("kycPage");
+
+ return (
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+
+
+
+
+
{t("whyKyc")}
+
+ - • {t("reasonComply")}
+ - • {t("reasonLimits")}
+ - • {t("reasonFeatures")}
+ - • {t("reasonSecurity")}
+
+
+
+ );
+}
diff --git a/frontend/src/components/KycSubmissionForm.test.tsx b/frontend/src/components/KycSubmissionForm.test.tsx
index 2a6dd412..909c41d7 100644
--- a/frontend/src/components/KycSubmissionForm.test.tsx
+++ b/frontend/src/components/KycSubmissionForm.test.tsx
@@ -19,7 +19,8 @@ vi.mock("next-intl", () => ({
}));
// framer-motion: render plain HTML elements so jsdom doesn't choke on
-// animation APIs. AnimatePresence just renders its children.
+// animation APIs. AnimatePresence uses mode="wait" to ensure only the
+// current step renders at a time.
vi.mock("framer-motion", async () => {
const React = await import("react");
const motion = new Proxy(
@@ -43,8 +44,14 @@ vi.mock("framer-motion", async () => {
);
return {
motion,
- AnimatePresence: ({ children }: { children: React.ReactNode }) =>
- React.createElement(React.Fragment, null, children),
+ AnimatePresence: ({ children, mode }: { children: React.ReactNode; mode?: string }) => {
+ // mode="wait" means only render the last child (current step)
+ if (mode === "wait") {
+ const childArray = React.Children.toArray(children);
+ return React.createElement(React.Fragment, null, childArray[childArray.length - 1]);
+ }
+ return React.createElement(React.Fragment, null, children);
+ },
type: {},
};
});
@@ -79,6 +86,13 @@ function navigateToStep(targetIndex: number) {
}
}
+/** Navigate without re-filling personal step (use when already past step 1). */
+function clickNext(times: number) {
+ for (let i = 0; i < times; i++) {
+ fireEvent.click(screen.getByText("next"));
+ }
+}
+
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("KycSubmissionForm", () => {
@@ -148,7 +162,7 @@ describe("KycSubmissionForm", () => {
navigateToStep(1);
expect(screen.getByText("2 of 4")).toBeInTheDocument();
- navigateToStep(2);
+ clickNext(1);
expect(screen.getByText("3 of 4")).toBeInTheDocument();
});
@@ -296,7 +310,7 @@ describe("KycSubmissionForm", () => {
fireEvent.click(screen.getByText("submit"));
await waitFor(() => {
- expect(screen.getByText("successTitle")).toBeInTheDocument();
+ expect(screen.getAllByText("successTitle").length).toBeGreaterThanOrEqual(1);
});
expect(mockToastSuccess).toHaveBeenCalled();
});
@@ -353,7 +367,7 @@ describe("KycSubmissionForm", () => {
fireEvent.click(screen.getByText("submit"));
await waitFor(() => {
- expect(screen.getByText("successTitle")).toBeInTheDocument();
+ expect(screen.getAllByText("successTitle").length).toBeGreaterThanOrEqual(1);
});
fireEvent.click(screen.getByText("submitAnother"));
@@ -410,7 +424,7 @@ describe("KycSubmissionForm", () => {
it("marks the form container with role=region", () => {
render(React.createElement(KycSubmissionForm));
- expect(screen.getByRole("region")).toBeInTheDocument();
+ expect(screen.getByRole("region", { name: "formTitle" })).toBeInTheDocument();
});
// ── Screen reader support ─────────────────────────────────────────────────
@@ -528,8 +542,8 @@ describe("KycSubmissionForm", () => {
fireEvent.click(screen.getByText("next")); // → address
fireEvent.click(screen.getByText("next")); // → documents
fireEvent.click(screen.getByText("next")); // → review
- // city was not filled, so it renders the placeholder dash
- const dashes = screen.getAllByText("—");
+ // city was not filled, so it renders the placeholder dash (mock returns key)
+ const dashes = screen.getAllByText("dash");
expect(dashes.length).toBeGreaterThan(0);
});
diff --git a/frontend/src/components/KycSubmissionForm.tsx b/frontend/src/components/KycSubmissionForm.tsx
index 844d9e45..a59cfc00 100644
--- a/frontend/src/components/KycSubmissionForm.tsx
+++ b/frontend/src/components/KycSubmissionForm.tsx
@@ -20,6 +20,12 @@ const STEP_LABEL_KEYS: Record = {
review: "review",
};
+const STEP_STATUS_KEYS: Record = {
+ completed: "completed",
+ current: "current",
+ upcoming: "upcoming",
+};
+
const stepVariants: Variants = {
enter: (dir: number) => ({
x: dir > 0 ? 48 : -48,
@@ -78,7 +84,7 @@ function Field({
}
function KycSubmissionForm() {
- const t = useTranslations();
+ const t = useTranslations("kycForm");
const uid = useId();
const [state, dispatch] = useReducer(kycFlowReducer, initialKycFlowState);
const [direction, setDirection] = useState(1);
@@ -91,8 +97,8 @@ function KycSubmissionForm() {
const errs: Record = {};
if (state.currentStep === "personal") {
- if (!state.personal.firstName.trim()) errs.firstName = t("required") || "Required";
- if (!state.personal.lastName.trim()) errs.lastName = t("required") || "Required";
+ if (!state.personal.firstName.trim()) errs.firstName = t("required");
+ if (!state.personal.lastName.trim()) errs.lastName = t("required");
}
setStepErrors(errs);
@@ -101,7 +107,7 @@ function KycSubmissionForm() {
const goNext = useCallback(() => {
if (!validateCurrentStep()) {
- setAnnouncement(t("validationError") || "Please fill required fields");
+ setAnnouncement(t("validationError"));
return;
}
if (stepIndex < TOTAL_STEPS - 1) {
@@ -110,7 +116,7 @@ function KycSubmissionForm() {
setStepErrors({});
const nextStep = STEPS[stepIndex + 1]!;
setAnnouncement(
- `${t("step") || "Step"} ${stepIndex + 2} ${t("of") || "of"} ${TOTAL_STEPS}: ${t(STEP_LABEL_KEYS[nextStep]) || nextStep}`,
+ `${t("step")} ${stepIndex + 2} ${t("of")} ${TOTAL_STEPS}: ${t(STEP_LABEL_KEYS[nextStep])}`,
);
}
}, [validateCurrentStep, stepIndex, t]);
@@ -125,7 +131,7 @@ function KycSubmissionForm() {
const handleSubmit = useCallback(async () => {
dispatch({ type: "SUBMIT" });
- setAnnouncement(t("submitting") || "Submitting...");
+ setAnnouncement(t("submitting"));
try {
const res = await fetch("/api/kyc", {
@@ -141,11 +147,11 @@ function KycSubmissionForm() {
}),
});
- if (!res.ok) throw new Error(t("submitError") || "Submission failed");
+ if (!res.ok) throw new Error(t("submitError"));
dispatch({ type: "SUBMIT_SUCCESS", submittedAt: new Date().toISOString() });
- setAnnouncement(t("successTitle") || "KYC submitted successfully!");
- toast.success(t("successTitle") || "KYC submitted successfully!");
+ setAnnouncement(t("successTitle"));
+ toast.success(t("successTitle"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
dispatch({ type: "SUBMIT_FAILURE", error: msg });
@@ -159,7 +165,7 @@ function KycSubmissionForm() {
{announcement}
@@ -195,10 +201,7 @@ function KycSubmissionForm() {
{t("successTitle")}
-
- {t("successDescription") ||
- "Your KYC verification has been submitted and is under review."}
-
+
{t("successDescription")}