Browse Source
feat: add internationalization and RTL support across UI components and profile API requests
master
feat: add internationalization and RTL support across UI components and profile API requests
master
16 changed files with 1259 additions and 392 deletions
-
343src/app/new-match/new-match-client.tsx
-
37src/app/new-match/profile/page.tsx
-
95src/app/questions-list/[slug]/question-detail-client.tsx
-
153src/app/questions-list/questions-list-client.tsx
-
28src/components/Componentes/call-result-sheet.tsx
-
32src/components/Componentes/dismiss-reason-sheet.tsx
-
11src/components/Componentes/female-consent-sheet.tsx
-
45src/components/Componentes/female-outcome-sheet.test.tsx
-
391src/components/Componentes/female-outcome-sheet.tsx
-
21src/components/Componentes/outcome-selection-sheet.tsx
-
14src/components/Componentes/slider-slide-four.tsx
-
17src/components/Componentes/subscription-required-sheet.tsx
-
209src/components/Componentes/token-switcher.tsx
-
30src/hooks/marriage/use-profile-main.ts
-
65src/lib/marriage-field-formatter.test.ts
-
160src/lib/marriage-field-formatter.ts
@ -4,6 +4,14 @@ import { useEffect, useState } from "react"; |
|||
import { Ic } from "@/icons"; |
|||
import { getClientCookie, setClientCookie } from "@/lib/cookies"; |
|||
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; |
|||
import { |
|||
isLocale, |
|||
LANGUAGE_MAP, |
|||
type LanguageCode, |
|||
localeDirections, |
|||
locales, |
|||
localizePath, |
|||
} from "@/translations/config"; |
|||
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
|||
// ۱. توکنهای ثابت سیستم (System Preset Tokens)
|
|||
@ -17,9 +25,51 @@ export const MALE_EMAIL = "[email protected]"; |
|||
export const MALE_2_EMAIL = "[email protected]"; |
|||
|
|||
const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; |
|||
const LANGUAGE_COOKIE_NAME = "HABIB_LANGUAGE"; |
|||
const DEV_CUSTOM_TOKENS_STORAGE_KEY = "HABIB_DEV_CUSTOM_TOKENS_V1"; |
|||
const DEV_ACTIVE_NAME_STORAGE_KEY = "HABIB_DEV_ACTIVE_NAME_V1"; |
|||
const DEV_ACTIVE_EMOJI_STORAGE_KEY = "HABIB_DEV_ACTIVE_EMOJI_V1"; |
|||
const DEV_LOCALE_STORAGE_KEY = "HABIB_DEV_LOCALE_V1"; |
|||
const LANGUAGE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; |
|||
|
|||
export const LOCALE_FLAGS: Record<string, string> = { |
|||
fa: "🇮🇷", |
|||
en: "🇺🇸", |
|||
ar: "🇸🇦", |
|||
tr: "🇹🇷", |
|||
ur: "🇵🇰", |
|||
az: "🇦🇿", |
|||
bn: "🇧🇩", |
|||
zh: "🇨🇳", |
|||
da: "🇩🇰", |
|||
de: "🇩🇪", |
|||
es: "🇪🇸", |
|||
fr: "🇫🇷", |
|||
gu: "🇮🇳", |
|||
ha: "🇳🇬", |
|||
he: "🇮🇱", |
|||
hi: "🇮🇳", |
|||
id: "🇮🇩", |
|||
ks: "🇮🇳", |
|||
pt: "🇵🇹", |
|||
ru: "🇷🇺", |
|||
sw: "🇰🇪", |
|||
tg: "🇹🇯", |
|||
ul: "🇵🇰", |
|||
uz: "🇺🇿", |
|||
}; |
|||
|
|||
export const POPULAR_DEV_LOCALES: { |
|||
code: LanguageCode; |
|||
label: string; |
|||
flag: string; |
|||
}[] = [ |
|||
{ code: "fa", label: "فارسی", flag: "🇮🇷" }, |
|||
{ code: "en", label: "English", flag: "🇺🇸" }, |
|||
{ code: "ar", label: "العربية", flag: "🇸🇦" }, |
|||
{ code: "tr", label: "Türkçe", flag: "🇹🇷" }, |
|||
{ code: "ur", label: "اردو", flag: "🇵🇰" }, |
|||
]; |
|||
|
|||
type WindowWithHabibToken = Window & { |
|||
HABIB_TOKEN?: string; |
|||
@ -99,6 +149,7 @@ export function TokenSwitcher({ |
|||
const [currentToken, setCurrentToken] = useState<string>(""); |
|||
const [activeStoredName, setActiveStoredName] = useState<string>(""); |
|||
const [activeStoredEmoji, setActiveStoredEmoji] = useState<string>(""); |
|||
const [activeLocale, setActiveLocale] = useState<string>("fa"); |
|||
const [savedCustomAccounts, setSavedCustomAccounts] = useState< |
|||
DevAccountItem[] |
|||
>([]); |
|||
@ -119,6 +170,41 @@ export function TokenSwitcher({ |
|||
const norm = normalizeToken(rawToken); |
|||
setCurrentToken(norm); |
|||
|
|||
// Sync and detect dev locale for SSR
|
|||
const pathLocale = window.location.pathname.split("/")[1]; |
|||
const cookieLocale = |
|||
getClientCookie(LANGUAGE_COOKIE_NAME) ?? |
|||
getClientCookie("habib_language"); |
|||
let savedDevLocale: string | null = null; |
|||
try { |
|||
savedDevLocale = window.localStorage.getItem(DEV_LOCALE_STORAGE_KEY); |
|||
} catch {} |
|||
|
|||
const resolvedLocale = isLocale(pathLocale) |
|||
? pathLocale |
|||
: isLocale(cookieLocale) |
|||
? cookieLocale |
|||
: isLocale(savedDevLocale) |
|||
? savedDevLocale |
|||
: "fa"; |
|||
|
|||
setActiveLocale(resolvedLocale); |
|||
|
|||
// In dev mode, ensure cookie is actively set so SSR & proxy.ts have an authoritative locale
|
|||
const localeCookieOptions = { |
|||
maxAge: LANGUAGE_COOKIE_MAX_AGE, |
|||
path: "/", |
|||
}; |
|||
setClientCookie( |
|||
LANGUAGE_COOKIE_NAME, |
|||
resolvedLocale, |
|||
localeCookieOptions, |
|||
); |
|||
setClientCookie("habib_language", resolvedLocale, localeCookieOptions); |
|||
try { |
|||
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, resolvedLocale); |
|||
} catch {} |
|||
|
|||
try { |
|||
const savedName = window.localStorage.getItem( |
|||
DEV_ACTIVE_NAME_STORAGE_KEY, |
|||
@ -148,6 +234,38 @@ export function TokenSwitcher({ |
|||
return null; |
|||
} |
|||
|
|||
const persistLocale = (localeCode: string) => { |
|||
if (!isLocale(localeCode)) return; |
|||
const options = { maxAge: LANGUAGE_COOKIE_MAX_AGE, path: "/" }; |
|||
setClientCookie(LANGUAGE_COOKIE_NAME, localeCode, options); |
|||
setClientCookie("habib_language", localeCode, options); |
|||
try { |
|||
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, localeCode); |
|||
} catch (e) { |
|||
console.warn("Failed to persist dev locale:", e); |
|||
} |
|||
}; |
|||
|
|||
const handleSelectLocale = (newLocale: string) => { |
|||
if (!isLocale(newLocale)) return; |
|||
persistLocale(newLocale); |
|||
setActiveLocale(newLocale); |
|||
|
|||
if (typeof document !== "undefined") { |
|||
document.documentElement.lang = newLocale; |
|||
document.documentElement.dir = localeDirections[newLocale] ?? "ltr"; |
|||
document.body.setAttribute("data-lang", newLocale); |
|||
} |
|||
|
|||
if (typeof window !== "undefined") { |
|||
const currentPath = window.location.pathname; |
|||
const localizedPath = localizePath(currentPath, newLocale); |
|||
window.location.replace( |
|||
`${localizedPath}${window.location.search}${window.location.hash}`, |
|||
); |
|||
} |
|||
}; |
|||
|
|||
const persistCustomAccounts = (accounts: DevAccountItem[]) => { |
|||
setSavedCustomAccounts(accounts); |
|||
if (typeof window !== "undefined") { |
|||
@ -168,11 +286,16 @@ export function TokenSwitcher({ |
|||
const savedTokens = window.localStorage.getItem( |
|||
DEV_CUSTOM_TOKENS_STORAGE_KEY, |
|||
); |
|||
const savedLocale = |
|||
window.localStorage.getItem(DEV_LOCALE_STORAGE_KEY) || activeLocale; |
|||
window.localStorage.clear(); |
|||
window.sessionStorage.clear(); |
|||
if (savedTokens) { |
|||
window.localStorage.setItem(DEV_CUSTOM_TOKENS_STORAGE_KEY, savedTokens); |
|||
} |
|||
if (savedLocale) { |
|||
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, savedLocale); |
|||
} |
|||
if (keepName) { |
|||
window.localStorage.setItem(DEV_ACTIVE_NAME_STORAGE_KEY, keepName); |
|||
} |
|||
@ -221,6 +344,10 @@ export function TokenSwitcher({ |
|||
if (typeof window !== "undefined") { |
|||
safeClearStorage(resolvedTitle, resolvedEmoji); |
|||
|
|||
const targetLocale = |
|||
activeLocale && isLocale(activeLocale) ? activeLocale : "fa"; |
|||
persistLocale(targetLocale); |
|||
|
|||
setCachedMarriageEntryPath(null); |
|||
setClientCookie(TOKEN_COOKIE_NAME, cleanToken); |
|||
setClientCookie("habib_token", cleanToken); |
|||
@ -235,7 +362,7 @@ export function TokenSwitcher({ |
|||
setActiveStoredEmoji(resolvedEmoji); |
|||
setIsOpen(false); |
|||
|
|||
window.location.replace("/"); |
|||
window.location.replace(`/${targetLocale}`); |
|||
} |
|||
}; |
|||
|
|||
@ -368,14 +495,14 @@ export function TokenSwitcher({ |
|||
|
|||
return ( |
|||
<> |
|||
{/* Floating Badge (وقتی بسته است فقط نام حساب و ایموجی را نمایش میدهد) */} |
|||
{/* Floating Badge (وقتی بسته است نام حساب، ایموجی و زبان فعال SSR را نمایش میدهد) */} |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOpen(true)} |
|||
aria-label="تغییر توکن کاربر (تست)" |
|||
title="تغییر توکن کاربر (محیط توسعه)" |
|||
aria-label="تغییر توکن کاربر و زبان (تست)" |
|||
title="تغییر توکن کاربر و زبان مریج (محیط توسعه)" |
|||
className={[ |
|||
"fixed top-4 left-4 z-[99999] inline-flex items-center gap-2 rounded-[15px] px-3.5 py-2 text-xs font-extrabold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md max-w-[260px] cursor-pointer", |
|||
"fixed top-4 left-4 z-[99999] inline-flex items-center gap-2 rounded-[15px] px-3.5 py-2 text-xs font-extrabold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md max-w-[320px] cursor-pointer", |
|||
variant === "transparent" |
|||
? "bg-black/40 text-white hover:bg-black/50" |
|||
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100", |
|||
@ -386,6 +513,10 @@ export function TokenSwitcher({ |
|||
> |
|||
<Ic name="switchAccount" className="size-5 text-rose-500 shrink-0" /> |
|||
<span className="truncate">{getFloatingButtonLabel()}</span> |
|||
<span className="inline-flex items-center gap-1 rounded-md bg-rose-500/10 dark:bg-rose-500/20 px-1.5 py-0.5 text-[10px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/20 uppercase shrink-0"> |
|||
<span>{LOCALE_FLAGS[activeLocale] || "🌐"}</span> |
|||
<span>{activeLocale}</span> |
|||
</span> |
|||
</button> |
|||
|
|||
{isOpen && ( |
|||
@ -413,10 +544,10 @@ export function TokenSwitcher({ |
|||
</div> |
|||
<div> |
|||
<h3 className="font-extrabold text-slate-900 dark:text-white text-base leading-snug"> |
|||
انتخاب حساب کاربری (تست) |
|||
پنل توسعه مریج (اکانت و زبان) |
|||
</h3> |
|||
<p className="text-xs text-slate-500 dark:text-slate-400"> |
|||
تغییر سریع پروفایل، نامگذاری و مدیریت توکنها |
|||
مدیریت توکن احراز هویت، زبان فعال SSR و تست پروفایلها |
|||
</p> |
|||
</div> |
|||
</div> |
|||
@ -431,6 +562,70 @@ export function TokenSwitcher({ |
|||
|
|||
{/* Scrollable Content */} |
|||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4 min-h-0"> |
|||
{/* Dev Active Locale Selector Section */} |
|||
<div className="rounded-2xl border border-rose-200/80 dark:border-rose-900/50 bg-gradient-to-br from-rose-50/60 to-white dark:from-rose-950/20 dark:to-slate-900 p-3.5 space-y-2.5"> |
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="text-base"> |
|||
{LOCALE_FLAGS[activeLocale] || "🌐"} |
|||
</span> |
|||
<span className="text-xs font-extrabold text-slate-900 dark:text-white"> |
|||
زبان فعال در حالت توسعه (SSR / Locale) |
|||
</span> |
|||
</div> |
|||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-rose-500 text-white font-bold"> |
|||
{activeLocale.toUpperCase()} |
|||
</span> |
|||
</div> |
|||
|
|||
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed"> |
|||
تعیین زبان سراسری مریج برای SSR، ریدایرکتهای proxy.ts، |
|||
فراخوانیهای بکاند و دیکشنریها: |
|||
</p> |
|||
|
|||
{/* Quick Selection Pills */} |
|||
<div className="flex items-center gap-1.5 flex-wrap"> |
|||
{POPULAR_DEV_LOCALES.map((item) => { |
|||
const isSelected = activeLocale === item.code; |
|||
return ( |
|||
<button |
|||
key={item.code} |
|||
type="button" |
|||
onClick={() => handleSelectLocale(item.code)} |
|||
className={[ |
|||
"px-2.5 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1.5 transition-all cursor-pointer", |
|||
isSelected |
|||
? "bg-rose-500 text-white shadow-xs scale-105" |
|||
: "bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-750", |
|||
].join(" ")} |
|||
> |
|||
<span>{item.flag}</span> |
|||
<span>{item.label}</span> |
|||
</button> |
|||
); |
|||
})} |
|||
</div> |
|||
|
|||
{/* Dropdown for all 24 supported languages */} |
|||
<div className="flex items-center gap-2 pt-1 border-t border-rose-100 dark:border-slate-800"> |
|||
<span className="text-[11px] text-slate-500 dark:text-slate-400 shrink-0"> |
|||
سایر زبانها: |
|||
</span> |
|||
<select |
|||
value={activeLocale} |
|||
onChange={(e) => handleSelectLocale(e.target.value)} |
|||
className="flex-1 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-2.5 py-1.5 text-xs text-slate-800 dark:text-slate-200 outline-none focus:border-rose-500 cursor-pointer font-medium" |
|||
> |
|||
{locales.map((code) => ( |
|||
<option key={code} value={code}> |
|||
{LOCALE_FLAGS[code] || "🌐"} {LANGUAGE_MAP[code]} ( |
|||
{code}) |
|||
</option> |
|||
))} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Predefined System Accounts */} |
|||
<div className="space-y-2"> |
|||
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> |
|||
|
|||
@ -0,0 +1,65 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import { formatFieldLabel, titleFromKey } from "./marriage-field-formatter"; |
|||
import { dictionaries } from "@/translations/dictionaries"; |
|||
|
|||
describe("marriage-field-formatter: formatFieldLabel", () => { |
|||
it("directly respects dynamic Arabic labels returned from backend DB without hardcoded JSON dependency", () => { |
|||
const field = { |
|||
key: "personal_identity.place_of_birth", |
|||
label: "مكان الميلاد (الدولة والمدينة)", |
|||
value: { city: "Mashhad", country: "Iran" }, |
|||
}; |
|||
|
|||
// When viewing with Arabic dictionary, must directly display backend's Arabic title
|
|||
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>); |
|||
expect(labelAr).toBe("مكان الميلاد (الدولة والمدينة)"); |
|||
|
|||
// Must NOT be overridden with English "Place Of Birth"
|
|||
expect(labelAr).not.toBe("Place Of Birth"); |
|||
}); |
|||
|
|||
it("directly respects dynamic Arabic labels for place_of_upbringing", () => { |
|||
const field = { |
|||
key: "personal_identity.place_of_upbringing", |
|||
label: "مكان النشأة والترعرع (الدولة والمدينة)", |
|||
value: { city: "Mashhad", country: "Iran" }, |
|||
}; |
|||
|
|||
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>); |
|||
expect(labelAr).toBe("مكان النشأة والترعرع (الدولة والمدينة)"); |
|||
expect(labelAr).not.toBe("Place Of Upbringing"); |
|||
}); |
|||
|
|||
it("directly respects dynamically updated DB question text", () => { |
|||
const field = { |
|||
key: "personal_identity.place_of_birth", |
|||
label: "في أي دولة ومدينة ولدت؟", |
|||
value: "...", |
|||
}; |
|||
|
|||
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>); |
|||
expect(labelAr).toBe("في أي دولة ومدينة ولدت؟"); |
|||
}); |
|||
|
|||
it("converts Persian/Arabic labels to English title when dictionary is English", () => { |
|||
const field = { |
|||
key: "personal_identity.place_of_birth", |
|||
label: "مكان الميلاد (الدولة والمدينة)", |
|||
value: { city: "Mashhad", country: "Iran" }, |
|||
}; |
|||
|
|||
const labelEn = formatFieldLabel(field, dictionaries.en as Record<string, string>); |
|||
expect(labelEn).toBe("Place Of Birth"); |
|||
}); |
|||
|
|||
it("falls back to canonical / titleFromKey when backend label is missing", () => { |
|||
const field = { |
|||
key: "personal_identity.place_of_birth", |
|||
label: "", |
|||
value: "...", |
|||
}; |
|||
|
|||
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>); |
|||
expect(labelAr).toBe("مكان الميلاد"); |
|||
}); |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue