Browse Source
feat: add multi-language localization support with locale files, translation formatters, and question components
master
feat: add multi-language localization support with locale files, translation formatters, and question components
master
32 changed files with 734 additions and 115 deletions
-
3src/app/questions-list/[slug]/question-detail-client.tsx
-
66src/components/Componentes/question-card.test.tsx
-
3src/components/Componentes/question-card.tsx
-
66src/components/Componentes/required-steps-card-locale.test.tsx
-
2src/translations/config.ts
-
39src/translations/format-estimate.test.ts
-
35src/translations/format-estimate.ts
-
19src/translations/locales/ar.json
-
19src/translations/locales/az.json
-
19src/translations/locales/bn.json
-
41src/translations/locales/da.json
-
41src/translations/locales/de.json
-
19src/translations/locales/en.json
-
41src/translations/locales/es.json
-
19src/translations/locales/fa.json
-
41src/translations/locales/fr.json
-
41src/translations/locales/gu.json
-
41src/translations/locales/ha.json
-
19src/translations/locales/he.json
-
41src/translations/locales/hi.json
-
19src/translations/locales/id.json
-
19src/translations/locales/ks.json
-
19src/translations/locales/pt.json
-
19src/translations/locales/ru.json
-
19src/translations/locales/sw.json
-
19src/translations/locales/tg.json
-
19src/translations/locales/tr.json
-
19src/translations/locales/ul.json
-
19src/translations/locales/ur.json
-
19src/translations/locales/uz.json
-
41src/translations/locales/zh.json
-
3src/translations/path_to_english.json
@ -0,0 +1,66 @@ |
|||||
|
import { render, screen } from "@testing-library/react"; |
||||
|
import { describe, expect, it, vi } from "vitest"; |
||||
|
import QuestionCard from "./question-card"; |
||||
|
import type { QuestionListItem } from "@/lib/schema-adapter"; |
||||
|
import ar from "@/translations/locales/ar.json"; |
||||
|
import fa from "@/translations/locales/fa.json"; |
||||
|
|
||||
|
let mockDictionary: Record<string, string> = ar; |
||||
|
let mockLocale = "ar"; |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: () => ({ |
||||
|
dictionary: mockDictionary, |
||||
|
locale: mockLocale, |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
const mockItem: QuestionListItem = { |
||||
|
slug: "personal_identity", |
||||
|
title: "البيانات الشخصية والتعريفية", |
||||
|
estimate: "2 min", |
||||
|
progress: 100, |
||||
|
icon: "profile", |
||||
|
required: true, |
||||
|
summary: "", |
||||
|
checkpoints: [], |
||||
|
questions: [], |
||||
|
}; |
||||
|
|
||||
|
describe("QuestionCard - Time Estimate Localization", () => { |
||||
|
it("translates '2 min' to Arabic '2 دقيقة' in Arabic locale", () => { |
||||
|
mockDictionary = ar; |
||||
|
mockLocale = "ar"; |
||||
|
|
||||
|
render(<QuestionCard item={mockItem} />); |
||||
|
|
||||
|
// In Arabic: "تقدير الوقت: 2 دقيقة"
|
||||
|
expect(screen.getByText("تقدير الوقت: 2 دقيقة")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("translates '3 min' to Arabic '3 دقائق' in Arabic locale", () => { |
||||
|
mockDictionary = ar; |
||||
|
mockLocale = "ar"; |
||||
|
|
||||
|
const item3Min: QuestionListItem = { |
||||
|
...mockItem, |
||||
|
title: "التعليم، والمهنة، والوضع المالي", |
||||
|
estimate: "3 min", |
||||
|
}; |
||||
|
|
||||
|
render(<QuestionCard item={item3Min} />); |
||||
|
|
||||
|
// In Arabic: "تقدير الوقت: 3 دقائق"
|
||||
|
expect(screen.getByText("تقدير الوقت: 3 دقائق")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("translates '2 min' to Persian '۲ دقیقه' in Persian locale", () => { |
||||
|
mockDictionary = fa; |
||||
|
mockLocale = "fa"; |
||||
|
|
||||
|
render(<QuestionCard item={mockItem} />); |
||||
|
|
||||
|
// In Persian: "زمان تقریبی: ۲ دقیقه"
|
||||
|
expect(screen.getByText("زمان تقریبی: ۲ دقیقه")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,66 @@ |
|||||
|
import { cleanup, render, screen } from "@testing-library/react"; |
||||
|
import { afterEach, describe, expect, it, vi } from "vitest"; |
||||
|
import RequiredStepsCard from "./required-steps-card"; |
||||
|
import fr from "@/translations/locales/fr.json"; |
||||
|
import fa from "@/translations/locales/fa.json"; |
||||
|
import ar from "@/translations/locales/ar.json"; |
||||
|
|
||||
|
let mockDictionary: Record<string, string> = fr; |
||||
|
|
||||
|
vi.mock("@/translations/provider", () => ({ |
||||
|
useI18n: () => ({ |
||||
|
dictionary: mockDictionary, |
||||
|
locale: "fr", |
||||
|
}), |
||||
|
})); |
||||
|
|
||||
|
describe("RequiredStepsCard - Locale Translations", () => { |
||||
|
afterEach(() => { |
||||
|
cleanup(); |
||||
|
}); |
||||
|
|
||||
|
it("renders French translations correctly when incomplete (0/9)", () => { |
||||
|
mockDictionary = fr; |
||||
|
render(<RequiredStepsCard completed={0} total={9} />); |
||||
|
|
||||
|
expect(screen.getByText("Étapes obligatoires")).toBeDefined(); |
||||
|
expect( |
||||
|
screen.getByText( |
||||
|
"Veuillez compléter les informations requises afin que nous puissions trouver les profils qui vous correspondent", |
||||
|
), |
||||
|
).toBeDefined(); |
||||
|
expect(screen.getByText("0/9")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("renders French translations correctly when completed (9/9)", () => { |
||||
|
mockDictionary = fr; |
||||
|
render(<RequiredStepsCard completed={9} total={9} />); |
||||
|
|
||||
|
expect(screen.getByText("Étapes obligatoires")).toBeDefined(); |
||||
|
expect( |
||||
|
screen.getByText( |
||||
|
"Vous pouvez maintenant envoyer votre demande afin que nous commencions à chercher le profil qui vous convient", |
||||
|
), |
||||
|
).toBeDefined(); |
||||
|
expect(screen.getByText("9/9")).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("renders Persian translations correctly", () => { |
||||
|
mockDictionary = fa; |
||||
|
render(<RequiredStepsCard completed={0} total={9} />); |
||||
|
|
||||
|
expect(screen.getByText("مراحل ضروری")).toBeDefined(); |
||||
|
expect( |
||||
|
screen.getByText( |
||||
|
"لطفا اطلاعات ضروری را کامل کنید تا بتوانیم گزینههای مناسب را پیدا کنیم", |
||||
|
), |
||||
|
).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it("renders Arabic translations correctly", () => { |
||||
|
mockDictionary = ar; |
||||
|
render(<RequiredStepsCard completed={0} total={9} />); |
||||
|
|
||||
|
expect(screen.getByText("الخطوات المطلوبة")).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { formatEstimateTime } from "./format-estimate"; |
||||
|
import ar from "./locales/ar.json"; |
||||
|
import en from "./locales/en.json"; |
||||
|
import fa from "./locales/fa.json"; |
||||
|
|
||||
|
describe("formatEstimateTime", () => { |
||||
|
it("translates estimates correctly in Arabic", () => { |
||||
|
expect(formatEstimateTime("2 min", ar)).toBe("2 دقيقة"); |
||||
|
expect(formatEstimateTime("3 min", ar)).toBe("3 دقائق"); |
||||
|
expect(formatEstimateTime("4 min", ar)).toBe("4 دقائق"); |
||||
|
expect(formatEstimateTime("5 min", ar)).toBe("5 دقائق"); |
||||
|
expect(formatEstimateTime("16 min", ar)).toBe("16 دقيقة"); |
||||
|
expect(formatEstimateTime("min", ar)).toBe("دقيقة"); |
||||
|
}); |
||||
|
|
||||
|
it("translates estimates correctly in Persian", () => { |
||||
|
expect(formatEstimateTime("2 min", fa)).toBe("۲ دقیقه"); |
||||
|
expect(formatEstimateTime("3 min", fa)).toBe("۳ دقیقه"); |
||||
|
expect(formatEstimateTime("4 min", fa)).toBe("۴ دقیقه"); |
||||
|
expect(formatEstimateTime("16 min", fa)).toBe("۱۶ دقیقه"); |
||||
|
expect(formatEstimateTime("min", fa)).toBe("دقیقه"); |
||||
|
}); |
||||
|
|
||||
|
it("handles English locale properly", () => { |
||||
|
expect(formatEstimateTime("2 min", en)).toBe("2 min"); |
||||
|
expect(formatEstimateTime("3 min", en)).toBe("3 min"); |
||||
|
expect(formatEstimateTime("16 min", en)).toBe("16 min"); |
||||
|
expect(formatEstimateTime("min", en)).toBe("min"); |
||||
|
}); |
||||
|
|
||||
|
it("falls back gracefully for unknown counts or missing keys", () => { |
||||
|
const mockT = { min: "دقيقة" }; |
||||
|
expect(formatEstimateTime("99 min", mockT)).toBe("99 دقيقة"); |
||||
|
expect(formatEstimateTime("", mockT)).toBe(""); |
||||
|
expect(formatEstimateTime(null, mockT)).toBe(""); |
||||
|
expect(formatEstimateTime("unformatted string", mockT)).toBe("unformatted string"); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,35 @@ |
|||||
|
/** |
||||
|
* Localizes section estimated time strings (e.g., "2 min", "3 min", "16 min", "5 minutes") |
||||
|
* using the current translation dictionary. |
||||
|
*/ |
||||
|
export function formatEstimateTime( |
||||
|
estimate: string | undefined | null, |
||||
|
t: Record<string, string>, |
||||
|
): string { |
||||
|
if (!estimate) return ""; |
||||
|
const trimmed = estimate.trim(); |
||||
|
if (!trimmed) return ""; |
||||
|
|
||||
|
// 1. Direct match in dictionary (e.g., "2 min", "5 minutes", "15 min")
|
||||
|
if (t[trimmed]) return t[trimmed]; |
||||
|
|
||||
|
// 2. Pattern match: "<number> min" or "<number> minutes"
|
||||
|
const match = trimmed.match(/^(\d+)\s*(?:min|minutes?)$/i); |
||||
|
if (match) { |
||||
|
const count = match[1]; |
||||
|
|
||||
|
// Try "{count} min" (e.g. "2 min")
|
||||
|
const minKey = `${count} min`; |
||||
|
if (t[minKey]) return t[minKey]; |
||||
|
|
||||
|
// Try "{count} minutes" (e.g. "2 minutes")
|
||||
|
const minutesKey = `${count} minutes`; |
||||
|
if (t[minutesKey]) return t[minutesKey]; |
||||
|
|
||||
|
// Fallback: "{count} <unit>"
|
||||
|
const unit = t["min"] || "min"; |
||||
|
return `${count} ${unit}`; |
||||
|
} |
||||
|
|
||||
|
return trimmed; |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue