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