dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
31 changed files with 5033 additions and 1575 deletions
Showing only changes of commit e769132f84 - Show all commits

1
global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css';

1759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@gsap/react": "^2.1.2",
@ -54,18 +56,22 @@
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.0.1",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.5",
"autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"eslint": "^9.0.0",
"eslint-config-next": "^16.1.6",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.6",
"postcss-preset-env": "^11.1.3",
"tailwindcss": "^4.1.18",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.5"
}
}

View File

@ -449,6 +449,7 @@ export default function AffiliateManagementPage() {
// Create Modal Component
function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit<Affiliate, 'id' | 'createdAt'> & { logoFile?: File }) => void }) {
const { t } = useTranslation();
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [url, setUrl] = useState('')
@ -702,6 +703,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
onClose: () => void;
onUpdate: (affiliate: Affiliate & { logoFile?: File; removeLogo?: boolean }) => void
}) {
const { t } = useTranslation();
const [name, setName] = useState(affiliate.name)
const [description, setDescription] = useState(affiliate.description)
const [url, setUrl] = useState(affiliate.url)
@ -964,6 +966,7 @@ function DeleteConfirmModal({ affiliateName, onClose, onConfirm, isDeleting }: {
onConfirm: () => void;
isDeleting: boolean;
}) {
const { t } = useTranslation();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-md mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl p-4 sm:p-6">

View File

@ -32,35 +32,52 @@ export default function AddLanguageModal({
if (!isRendered) return null;
return (
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}>
<div className={`mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl transform transition-all duration-200 ${
<div className={`mx-4 w-full max-w-sm rounded-[28px] border border-white/80 bg-white/90 backdrop-blur p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] transform transition-all duration-200 ${
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
}`}>
<h2 className="text-lg font-bold text-[#1C2B4A] mb-4">{t('autofix.kf4e45236')}</h2>
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500 shadow-sm mb-2">
Languages
</span>
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kf4e45236')}</h2>
</div>
<button
onClick={onClose}
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none"
>
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t('autofix.k92639a9a')}</label>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wide mb-1.5">{t('autofix.k92639a9a')}</label>
<input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder={t('autofix.k03538639')}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('autofix.k926966d0')}</label>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wide mb-1.5">{t('autofix.k926966d0')}</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('autofix.ka019b3c0')}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
/>
</div>
{addError && <p className="text-xs text-red-600">{addError}</p>}
{addError && (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">{addError}</p>
)}
</div>
<div className="mt-5 flex justify-end gap-3">
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => {
onClose();
@ -68,11 +85,14 @@ export default function AddLanguageModal({
setNewCode('');
setNewName('');
}}
className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50"
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition shadow-sm"
>
Cancel
</button>
<button onClick={onAdd} className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90">
<button
onClick={onAdd}
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
>
Add
</button>
</div>

View File

@ -49,47 +49,59 @@ export default function CategoryManagerModal({
if (!isRendered) return null;
return (
<div className={`fixed inset-0 z-[150] flex items-center justify-center bg-black/45 backdrop-blur-sm transition-opacity duration-200 ${
<div className={`fixed inset-0 z-[150] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}>
<div className={`mx-4 w-full max-w-5xl rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden transform transition-all duration-200 ${
<div className={`mx-4 w-full max-w-5xl rounded-[30px] border border-white/80 bg-white/88 backdrop-blur overflow-hidden shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)] transform transition-all duration-200 ${
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
}`}>
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50 flex items-start justify-between gap-4">
{/* Header */}
<div className="px-6 py-5 border-b border-white/60 flex items-start justify-between gap-4 bg-white/40">
<div>
<h2 className="text-lg font-bold text-[#1C2B4A]">{t('autofix.kef9de7f0')}</h2>
<p className="text-xs text-slate-600 mt-1">{t('autofix.kc4671abe')}</p>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500 shadow-sm mb-2">
Admin
</span>
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kef9de7f0')}</h2>
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kc4671abe')}</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 text-xl leading-none">
<button
onClick={onClose}
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none shrink-0"
>
</button>
</div>
{/* Body */}
<div className="px-6 py-5 max-h-[70vh] overflow-y-auto space-y-4">
{/* Create category row */}
<div className="flex items-center gap-2 flex-wrap">
<input
value={newCategoryLabel}
onChange={(e) => setNewCategoryLabel(e.target.value)}
placeholder={t('autofix.ke52ed6e9')}
className="w-56 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
className="w-56 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition"
/>
<button
type="button"
onClick={onCreateCategory}
className="rounded-md bg-[#1C2B4A] text-white px-3 py-1.5 text-sm font-medium hover:bg-[#152344]"
>{t('autofix.k1db86f96')}</button>
className="rounded-2xl bg-slate-900 text-white px-4 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
>
{t('autofix.k1db86f96')}
</button>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-slate-200 bg-white p-3">
<p className="text-xs font-semibold text-slate-700 mb-2">{t('autofix.k505ebdae')}</p>
{/* Uncategorized pool */}
<div className="rounded-[20px] border border-white/80 bg-white/70 backdrop-blur p-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-2.5">{t('autofix.k505ebdae')}</p>
<div className="flex flex-wrap gap-2 min-h-10">
{uncategorizedNamespaces.map((ns) => (
<span
key={ns}
draggable
onDragStart={() => setDragNamespace(ns)}
className="cursor-grab rounded-full border border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-700"
className="cursor-grab rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
title={t('autofix.k66edf1eb')}
>
{ns}
@ -101,6 +113,7 @@ export default function CategoryManagerModal({
</div>
</div>
{/* Category list */}
<div className="space-y-2">
{categoriesWithKnownNamespaces.map((cat) => {
const availableToAssign = namespaces.filter((ns) => !cat.namespaces.includes(ns));
@ -112,9 +125,7 @@ export default function CategoryManagerModal({
key={cat.id}
onDragEnter={() => {
if (!dragNamespace) return;
if (expandedCategoryId !== cat.id) {
setExpandedCategoryId(cat.id);
}
if (expandedCategoryId !== cat.id) setExpandedCategoryId(cat.id);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
@ -123,18 +134,19 @@ export default function CategoryManagerModal({
setDragNamespace(null);
}
}}
className="rounded-xl border border-slate-200 bg-white"
className="rounded-[20px] border border-white/80 bg-white/70 backdrop-blur overflow-hidden shadow-sm"
>
{/* Category header row */}
<div
className="px-3 py-2.5 border-b border-slate-100 flex items-center justify-between gap-2 cursor-pointer"
className="px-4 py-3 flex items-center justify-between gap-2 cursor-pointer hover:bg-white/60 transition"
onClick={() => setExpandedCategoryId((prev) => (prev === cat.id ? null : cat.id))}
>
<div className="flex items-center gap-2 text-left">
<span className="text-sm font-semibold text-[#1C2B4A]">{cat.label}</span>
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-700">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-950">{cat.label}</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-500 shadow-sm">
{cat.namespaces.length}
</span>
<span className="text-xs text-slate-500">{isExpanded ? 'Hide' : 'Manage'}</span>
<span className="text-xs text-slate-400">{isExpanded ? 'Hide' : 'Manage'}</span>
</div>
{cat.isCustom && (
<button
@ -143,7 +155,7 @@ export default function CategoryManagerModal({
e.stopPropagation();
deleteCategory(cat.id);
}}
className="text-xs rounded border border-red-200 bg-red-50 px-2 py-0.5 text-red-600 hover:bg-red-100"
className="rounded-2xl border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-100 transition"
>
Delete
</button>
@ -151,12 +163,12 @@ export default function CategoryManagerModal({
</div>
{isExpanded && (
<div className="p-3 space-y-2">
<div className="border-t border-white/60 p-4 space-y-3 bg-white/40">
<div className="flex flex-col lg:flex-row items-stretch gap-2">
<select
value={selectValue}
onChange={(e) => setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: e.target.value }))}
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-xs"
className="w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition"
>
<option value="">{t('autofix.k0cdc3ee9')}</option>
{availableToAssign.map((ns) => (
@ -170,25 +182,25 @@ export default function CategoryManagerModal({
addNamespaceToCategory(cat.id, selectValue);
setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: '' }));
}}
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-700 hover:bg-slate-50"
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2 min-h-10 rounded-md border border-dashed border-slate-200 p-2">
<div className="flex flex-wrap gap-2 min-h-10 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-3">
{cat.namespaces.map((ns) => (
<span
key={ns}
draggable
onDragStart={() => setDragNamespace(ns)}
className="group cursor-grab rounded-full border border-indigo-200 bg-indigo-50 px-2 py-1 text-xs text-indigo-700"
className="group cursor-grab inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 hover:border-indigo-300 transition"
>
{ns}
<button
type="button"
onClick={() => removeNamespaceFromCategory(cat.id, ns)}
className="ml-1 text-indigo-500 group-hover:text-red-500"
className="ml-1.5 text-indigo-400 group-hover:text-red-500 transition leading-none"
title={t('autofix.ka6791a02')}
>
×
@ -208,8 +220,12 @@ export default function CategoryManagerModal({
</div>
</div>
<div className="px-6 py-4 border-t border-slate-200 bg-white flex justify-end">
<button onClick={onClose} className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90">
{/* Footer */}
<div className="px-6 py-4 border-t border-white/60 bg-white/40 flex justify-end">
<button
onClick={onClose}
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
>
Done
</button>
</div>

View File

@ -27,22 +27,45 @@ export default function DeleteLanguageModal({ deleteTarget, allLanguages, onClos
const languageName = allLanguages.find((l) => l.code === displayTarget)?.name ?? displayTarget;
return (
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}>
<div className={`mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl transform transition-all duration-200 ${
<div className={`mx-4 w-full max-w-sm rounded-[28px] border border-white/80 bg-white/90 backdrop-blur p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] transform transition-all duration-200 ${
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
}`}>
<h2 className="text-lg font-bold text-red-600 mb-3">{t('autofix.kda5f982e')}</h2>
<p className="text-sm text-gray-600 mb-5">
Delete <strong>{languageName}</strong>?
All translations for this language will be removed.
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<span className="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-red-600 shadow-sm mb-2">
Destructive
</span>
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kda5f982e')}</h2>
</div>
<button
onClick={onClose}
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none"
>
</button>
</div>
<div className="rounded-2xl border border-red-100 bg-red-50/60 px-4 py-3 mb-5">
<p className="text-sm text-slate-700">
Delete <strong className="text-slate-950">{languageName}</strong>?{' '}
All translations for this language will be permanently removed.
</p>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50">
</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition shadow-sm"
>
Cancel
</button>
<button onClick={() => onDelete(displayTarget)} className="rounded-md bg-red-600 text-white px-4 py-2 text-sm font-semibold hover:bg-red-700">
<button
onClick={() => onDelete(displayTarget)}
className="rounded-2xl bg-red-600 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(220,38,38,0.7)] hover:bg-red-700 transition"
>
Delete
</button>
</div>

View File

@ -60,11 +60,9 @@ export default function LanguageManagementTopSection({
const byCode = new Map(allLanguages.map((lang) => [lang.code, lang]));
const english = byCode.get('en');
const german = byCode.get('de');
const rest = allLanguages
.filter((lang) => lang.code !== 'en' && lang.code !== 'de')
.sort((a, b) => a.name.localeCompare(b.name));
return [english, german, ...rest].filter((lang): lang is LanguageEntry => Boolean(lang));
}, [allLanguages]);
@ -76,124 +74,168 @@ export default function LanguageManagementTopSection({
<button
key={lang.code}
onClick={() => setActiveLang(lang.code)}
className={`relative rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-2 ${
className={`flex shrink-0 items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition whitespace-nowrap ${
activeLang === lang.code
? 'bg-[#1C2B4A] text-white shadow'
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
? 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
: 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'
}`}
>
{lang.name}
<span className="text-xs opacity-60">({lang.code})</span>
<span className={`text-xs ${activeLang === lang.code ? 'opacity-50' : 'opacity-40'}`}>
{lang.code}
</span>
{!isBuiltin(lang.code) && (
<button
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onDeleteLanguageRequest(lang.code);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onDeleteLanguageRequest(lang.code);
}
}}
title={t('autofix.k5fcc9b0e')}
className={`ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none ${
className={`ml-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none cursor-pointer transition ${
activeLang === lang.code
? 'bg-white/20 hover:bg-white/40 text-white'
: 'bg-gray-200 hover:bg-red-100 text-gray-500 hover:text-red-600'
: 'bg-slate-200 hover:bg-red-100 text-slate-500 hover:text-red-600'
}`}
>
×
</button>
</span>
)}
</button>
);
return (
<>
<div ref={headerRef} className="flex items-center justify-between flex-wrap gap-4">
{/* ── Hero header card ─────────────────────────────────── */}
<div
ref={headerRef}
className="rounded-[30px] border border-white/80 bg-white/85 px-5 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:px-8 md:py-8"
>
<div className="space-y-4">
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
{t('autofix.ka8c928ac')}
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold text-[#1C2B4A]">{t('autofix.k346a2c64')}</h1>
<p className="text-sm text-gray-500 mt-1">
Manage UI translations. All {totalKeys} keys scanned from the English source file.
<h1 className="text-3xl font-black tracking-tight text-slate-950 md:text-4xl">
{t('autofix.k346a2c64')}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{t('autofix.k7227f13d')} {totalKeys} {t('autofix.k511d7fab')}
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap shrink-0">
<button
onClick={onScan}
disabled={isScanning || isAutoFixing}
className="rounded-md border border-[#1C2B4A] text-[#1C2B4A] px-3 py-2 text-sm font-medium hover:bg-[#1C2B4A] hover:text-white transition-colors flex items-center gap-2 disabled:opacity-50"
className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{isScanning ? 'Scanning...' : 'Scan & review fixes'}
{isScanning ? t('autofix.kf191f6df5') : t('autofix.k9863fa5')}
</button>
<button
onClick={onBackToAdmin}
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50"
>{t('autofix.kea7cde7a')}</button>
{isDirty && (
<button
onClick={onSave}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
>{t('autofix.k4be6f631')}</button>
)}
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
>
{t('autofix.kea7cde7a')}
</button>
{saved && !isDirty && (
<span className="rounded-md bg-green-50 border border-green-200 text-green-700 px-3 py-2 text-sm font-medium">
Saved
<span className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">{t('autofix.kac6aab53')}</span>
)}
</div>
</div>
{/* stat pills */}
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">
{allLanguages.length} {allLanguages.length === 1 ? t('autofix.k20eb1f87') : t('autofix.ka6cf3286')}
</span>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">
{totalKeys} {t('autofix.k3931709b')}
</span>
{activeLang !== 'en' && (
<span className={`inline-flex items-center rounded-full border px-3 py-1.5 font-medium shadow-sm ${
allTabStats.missing > 0
? 'border-red-200 bg-red-50 text-red-700'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
}`}>
{allTabStats.missing > 0 ? `${allTabStats.missing} ${t('autofix.k571ffd91')}` : t('autofix.kdcc78d97')}
</span>
)}
</div>
</div>
</div>
{/* ── Save error banner ────────────────────────────────── */}
{saveError && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="rounded-2xl border border-red-200 bg-red-50/80 backdrop-blur px-5 py-3 text-sm text-red-700 shadow-sm">
{saveError}
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{/* ── Language tabs card ───────────────────────────────── */}
<div className="rounded-[28px] border border-white/80 bg-white/85 px-4 py-3 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur md:px-5">
<div className="flex items-center gap-1.5 flex-wrap">
{englishLanguage && renderLanguageButton(englishLanguage)}
{germanLanguage && renderLanguageButton(germanLanguage)}
{otherLanguages.map((lang) => renderLanguageButton(lang))}
<button
onClick={onOpenAddLanguage}
className="rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-[#1C2B4A] hover:text-[#1C2B4A] transition"
className="flex shrink-0 items-center gap-1.5 px-4 py-2.5 rounded-2xl text-sm font-medium text-slate-400 border border-dashed border-slate-300 hover:border-slate-400 hover:text-slate-600 transition whitespace-nowrap"
>
+ Add language
</button>
{otherLanguages.map((lang) => renderLanguageButton(lang))}
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>{t('autofix.k7a515516')}</button>
</div>
</div>
{/* ── Translation progress card ────────────────────────── */}
{activeLang !== 'en' && (
<div className="rounded-xl border border-gray-200 bg-white p-4 flex items-center gap-4">
<div className="flex-1">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<div className="rounded-[28px] border border-white/80 bg-white/85 px-5 py-4 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur flex items-center gap-5">
<div className="flex-1 space-y-1.5">
<div className="flex justify-between text-xs text-slate-500">
<span>{t('autofix.kb8f33873')}</span>
<span>{allTabStats.translated} / {allTabStats.total} keys translated</span>
<span className="font-medium text-slate-700">{allTabStats.translated} / {allTabStats.total} {t('autofix.k33f55455')}</span>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-[#1C2B4A] transition-all"
className="h-full rounded-full bg-slate-900 transition-all duration-500"
style={{ width: `${translationProgressPercent}%` }}
/>
</div>
</div>
<span className="text-lg font-bold text-[#1C2B4A]">
<span className="text-2xl font-black tracking-tight text-slate-950 tabular-nums">
{translationProgressPercent}%
</span>
</div>
)}
{/* ── Wizard nudge card ────────────────────────────────── */}
{activeLang !== 'en' && allTabStats.missing > 0 && wizardMissingKeysCount > 0 && (
<div className="rounded-xl border border-indigo-200 bg-indigo-50 p-4 flex items-start justify-between gap-4">
<div className="rounded-[28px] border border-indigo-200/80 bg-indigo-50/80 backdrop-blur px-5 py-4 shadow-[0_22px_60px_-34px_rgba(99,102,241,0.3)] flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-indigo-900">{t('autofix.k5e5e8744')}</p>
<p className="text-xs text-indigo-800 mt-1">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} still has {wizardMissingKeysCount} missing keys. Start the wizard to fill them step by step.
</p>
<p className="text-sm font-semibold text-indigo-950">{t('autofix.k5e5e8744')}</p>
<p className="text-xs text-indigo-800/80 mt-1">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} still has{' '}
<span className="font-semibold">{wizardMissingKeysCount}</span>{t('autofix.k0a50d234')}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={onOpenTranslationWizard}
className="rounded-md bg-[#1C2B4A] text-white px-3 py-1.5 text-xs font-semibold hover:bg-[#1C2B4A]/90"
>{t('autofix.k725dd1d6')}</button>
</div>
className="shrink-0 rounded-2xl bg-slate-900 text-white px-4 py-2 text-xs font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
>
{t('autofix.k725dd1d6')}
</button>
</div>
)}
</>

View File

@ -22,6 +22,8 @@ type Props = {
setSearch: (value: string) => void;
autoScrollOnPanelOpen: boolean;
setAutoScrollOnPanelOpen: (value: boolean) => void;
autoScrollOnSave: boolean;
setAutoScrollOnSave: (value: boolean) => void;
newGlobalKeySelection: string;
setNewGlobalKeySelection: (value: string) => void;
availableGlobalKeyOptions: string[];
@ -32,6 +34,8 @@ type Props = {
translations: Record<string, Record<string, string>>;
handleChange: (key: string, value: string) => void;
globalKeySet: Set<string>;
englishReferenceKeySet: Set<string>;
setEnglishReferenceForKey: (key: string, enabled: boolean) => void;
filteredNs: string[];
filteredGroups: Record<string, string[]>;
activeNamespacePanel: string | null;
@ -56,6 +60,8 @@ export default function TranslationCoverageEditor({
setSearch,
autoScrollOnPanelOpen,
setAutoScrollOnPanelOpen,
autoScrollOnSave,
setAutoScrollOnSave,
newGlobalKeySelection,
setNewGlobalKeySelection,
availableGlobalKeyOptions,
@ -66,6 +72,8 @@ export default function TranslationCoverageEditor({
translations,
handleChange,
globalKeySet,
englishReferenceKeySet,
setEnglishReferenceForKey,
filteredNs,
filteredGroups,
activeNamespacePanel,
@ -78,145 +86,124 @@ export default function TranslationCoverageEditor({
}: Props) {
const { t } = useTranslation();
// Shared tab button renderer
const renderCategoryTab = (
key: string,
label: string,
stats: { total: number; translated: number; missing: number }
) => {
const isActive = activeCategory === key;
const hasMissing = activeLang !== 'en' && stats.missing > 0;
return (
<button
key={key}
onClick={() => setActiveCategory(key)}
className={`flex shrink-0 items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition whitespace-nowrap ${
isActive
? hasMissing
? 'bg-red-600 text-white shadow-[0_18px_40px_-24px_rgba(220,38,38,0.7)]'
: 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
: hasMissing
? 'border border-red-200 bg-red-50/80 text-red-700 hover:bg-red-100'
: 'bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'
}`}
>
<span>{label}</span>
{hasMissing ? (
<span className={`rounded-full px-2 py-0.5 text-[10px] leading-none font-semibold tabular-nums ${
isActive ? 'bg-white/20 text-white' : 'bg-red-100 text-red-600'
}`}>
{stats.missing}
</span>
) : (
<span className={`rounded-full px-2 py-0.5 text-[10px] leading-none font-medium tabular-nums ${
isActive ? 'bg-white/15 text-white/80' : 'bg-slate-100 text-slate-400'
}`}>
{stats.total}
</span>
)}
</button>
);
};
return (
<>
<div className="rounded-2xl border border-slate-200 bg-slate-50/50 p-4">
{/* ── Category tabs card ───────────────────────────────── */}
<div className="rounded-[28px] border border-white/80 bg-white/85 px-5 py-4 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
<div>
<h2 className="text-base font-semibold text-[#1C2B4A]">{t('autofix.k5f978731')}</h2>
<p className="text-xs text-slate-600 mt-1">{t('autofix.kb7a30760')}</p>
<h2 className="text-sm font-semibold text-slate-950">{t('autofix.k5f978731')}</h2>
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kb7a30760')}</p>
</div>
<button
type="button"
onClick={onOpenCategoryManager}
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100"
>{t('autofix.kd6e42900')}</button>
className="rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
>
{t('autofix.kd6e42900')}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setActiveCategory('all')}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
activeCategory === 'all'
? (activeLang !== 'en' && allTabStats.missing > 0
? 'bg-red-600 text-white shadow'
: 'bg-[#1C2B4A] text-white shadow')
: (activeLang !== 'en' && allTabStats.missing > 0
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
}`}
>
<span className="inline-flex items-center gap-1.5">
<span>All</span>
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
activeCategory === 'all' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
}`}>
{allTabStats.translated}/{allTabStats.total}
</span>
{activeLang !== 'en' && allTabStats.missing > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
activeCategory === 'all' ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
}`}>
{allTabStats.missing} missing
</span>
<div className="flex items-center gap-1.5 flex-wrap">
{renderCategoryTab('all', 'All', allTabStats)}
{renderCategoryTab('global', 'Global', globalTabStats)}
{categoriesWithKnownNamespaces.map((cat) =>
renderCategoryTab(cat.id, cat.label, categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 })
)}
</span>
</button>
<button
onClick={() => setActiveCategory('global')}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
activeCategory === 'global'
? (activeLang !== 'en' && globalTabStats.missing > 0
? 'bg-red-600 text-white shadow'
: 'bg-[#1C2B4A] text-white shadow')
: (activeLang !== 'en' && globalTabStats.missing > 0
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
}`}
>
<span className="inline-flex items-center gap-1.5">
<span>Global</span>
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
activeCategory === 'global' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
}`}>
{globalTabStats.translated}/{globalTabStats.total}
</span>
{activeLang !== 'en' && globalTabStats.missing > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
activeCategory === 'global' ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
}`}>
{globalTabStats.missing} missing
</span>
)}
</span>
</button>
{categoriesWithKnownNamespaces.map((cat) => {
const catStats = categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 };
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
activeCategory === cat.id
? (activeLang !== 'en' && catStats.missing > 0
? 'bg-red-600 text-white shadow'
: 'bg-[#1C2B4A] text-white shadow')
: (activeLang !== 'en' && catStats.missing > 0
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
}`}
>
<span className="inline-flex items-center gap-1.5">
<span>{cat.label}</span>
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
activeCategory === cat.id ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
}`}>
{catStats.translated}/{catStats.total}
</span>
{activeLang !== 'en' && catStats.missing > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
activeCategory === cat.id ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
}`}>
{catStats.missing} missing
</span>
)}
</span>
</button>
);
})}
</div>
</div>
{/* ── Search + options row ─────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[14rem] max-w-sm">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('autofix.kbf49d59b')}
className="w-full max-w-sm rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
className="w-full rounded-2xl border border-slate-200 bg-white/90 pl-9 pr-3 py-2 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
/>
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm text-slate-600 select-none">
<input
type="checkbox"
checked={autoScrollOnPanelOpen}
onChange={(e) => setAutoScrollOnPanelOpen(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
/>{t('autofix.kfd1e0089')}
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>
{t('autofix.kfd1e0089')}
</label>
<label className="inline-flex items-center gap-2 text-sm text-slate-600 select-none">
<input
type="checkbox"
checked={autoScrollOnSave}
onChange={(e) => setAutoScrollOnSave(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>
{t('autofix.k23e95df1')}
</label>
</div>
</div>
{/* ── Content area ────────────────────────────────────── */}
<div className="space-y-3">
{/* Global keys table */}
{activeCategory === 'global' && (
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
<div className="px-5 py-3 bg-gray-50 border-b border-gray-100 flex items-center justify-between gap-3 flex-wrap">
<div className="rounded-[28px] border border-white/80 bg-white/85 overflow-hidden shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between gap-3 flex-wrap">
<div>
<span className="font-semibold text-[#1C2B4A]">{t('autofix.k6cfeedd3')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('autofix.kad7d8c49')}</p>
<span className="font-semibold text-slate-950">{t('autofix.k6cfeedd3')}</span>
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kad7d8c49')}</p>
</div>
<div className="flex items-center gap-2">
<select
value={newGlobalKeySelection}
onChange={(e) => setNewGlobalKeySelection(e.target.value)}
className="w-72 max-w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs"
className="w-72 max-w-full rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20"
>
<option value="">{t('autofix.k47bce570')}</option>
{availableGlobalKeyOptions.map((key) => (
@ -230,7 +217,7 @@ export default function TranslationCoverageEditor({
addGlobalKey(newGlobalKeySelection);
setNewGlobalKeySelection('');
}}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
className="rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
Add
</button>
@ -240,12 +227,12 @@ export default function TranslationCoverageEditor({
{globalFilteredKeys.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50/50">
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
<tr className="border-b border-slate-100 bg-slate-50/60">
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">Key</th>
{activeLang !== 'en' && (
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">English</th>
)}
<th className="px-5 py-2 text-left font-medium text-gray-500">
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
</th>
</tr>
@ -257,34 +244,34 @@ export default function TranslationCoverageEditor({
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
return (
<tr key={key} className="border-b border-gray-50 last:border-0 hover:bg-blue-50/30">
<td className="px-5 py-2 font-mono text-xs text-gray-500 align-top pt-3">
<tr key={key} className="border-b border-slate-50 last:border-0 hover:bg-slate-50/60 transition-colors">
<td className="px-5 py-2.5 font-mono text-xs text-slate-500 align-top pt-3">
<div className="flex items-start justify-between gap-2">
<span>{key}</span>
<button
type="button"
title={t('autofix.kc02b17c3')}
onClick={() => removeGlobalKey(key)}
className="text-[10px] rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
className="shrink-0 rounded-xl border border-red-200 px-1.5 py-0.5 text-[10px] text-red-500 hover:bg-red-50 transition"
>
Remove
</button>
</div>
</td>
{activeLang !== 'en' && (
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">{enVal}</td>
<td className="px-5 py-2.5 text-slate-500 align-top pt-3 text-xs">{enVal}</td>
)}
<td className="px-5 py-2">
<td className="px-5 py-2.5">
<div className="relative">
<textarea
rows={1}
value={activeLang === 'en' ? currentVal : (translations[activeLang]?.[key] ?? '')}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={activeLang === 'en' ? '' : enVal}
className={`w-full rounded border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
className={`w-full rounded-xl border px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition ${
hasOverride && activeLang !== 'en'
? 'border-green-300 bg-green-50'
: 'border-gray-200 bg-white'
? 'border-emerald-300 bg-emerald-50/60'
: 'border-slate-200 bg-white'
}`}
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
onInput={(e) => {
@ -296,9 +283,9 @@ export default function TranslationCoverageEditor({
{hasOverride && activeLang !== 'en' && (
<button
type="button"
title="Clear override (revert to built-in)"
title={t('autofix.k644d9ea8')}
onClick={() => handleChange(key, '')}
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
>
×
</button>
@ -311,11 +298,12 @@ export default function TranslationCoverageEditor({
</tbody>
</table>
) : (
<div className="p-8 text-center text-sm text-gray-500">{t('autofix.k0700b1f2')}</div>
<div className="p-10 text-center text-sm text-slate-400">{t('autofix.k0700b1f2')}</div>
)}
</div>
)}
{/* Namespace grid + open panel */}
{activeCategory !== 'global' && filteredNs.length > 0 && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
@ -331,9 +319,7 @@ export default function TranslationCoverageEditor({
const activeIdx = activeNamespacePanel ? filteredNs.indexOf(activeNamespacePanel) : -1;
const shiftClass = !activeNamespacePanel || isActive
? 'translate-x-0'
: idx < activeIdx
? '-translate-x-1'
: 'translate-x-1';
: idx < activeIdx ? '-translate-x-1' : 'translate-x-1';
return (
<button
@ -343,45 +329,59 @@ export default function TranslationCoverageEditor({
openFromPanelClickRef.current = true;
setActiveNamespacePanel((prev) => (prev === ns ? null : ns));
}}
className={`w-full rounded-xl border px-3 py-2 text-left transition-all duration-300 ${shiftClass} ${
className={`w-full rounded-[20px] border px-4 py-3 text-left transition-all duration-300 backdrop-blur ${shiftClass} ${
isActive
? (hasMissing ? 'border-red-400 bg-red-50 shadow-sm' : 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-sm')
: (hasMissing ? 'border-red-200 bg-red-50/70 hover:bg-red-50' : 'border-gray-200 bg-white hover:border-slate-300 hover:bg-slate-50')
? hasMissing
? 'border-red-300 bg-red-50/90 shadow-[0_12px_30px_-18px_rgba(220,38,38,0.5)]'
: 'border-slate-300 bg-white/95 shadow-[0_12px_30px_-18px_rgba(15,23,42,0.4)]'
: hasMissing
? 'border-red-200 bg-red-50/60 hover:bg-red-50/90'
: 'border-white/80 bg-white/80 hover:bg-white/95 hover:border-slate-200'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-[#1C2B4A] capitalize">{ns}</span>
<span className={`text-sm font-semibold capitalize ${isActive ? 'text-slate-950' : 'text-slate-800'}`}>
{ns}
</span>
<div className="flex items-center gap-1">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
isActive
? (hasMissing ? 'bg-red-500 text-white' : 'bg-[#1C2B4A] text-white')
: (hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700')
? hasMissing ? 'bg-red-500 text-white' : 'bg-slate-900 text-white'
: hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
}`}>
{nsStats.translated}/{nsStats.total}
</span>
{hasMissing && (
<span className="rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
{nsStats.missing} missing
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
isActive ? 'bg-red-200 text-red-800' : 'bg-red-100 text-red-700'
}`}>
{nsStats.missing}
</span>
)}
</div>
</div>
<p className="mt-1 text-[11px] text-slate-500">{isActive ? 'Open' : 'Click to open'}</p>
<p className="mt-1 text-[11px] text-slate-400">
{isActive ? t('autofix.k77d01d6a') : t('autofix.kcfb5fb54')}
</p>
</button>
);
})}
</div>
{activeNamespacePanel && filteredGroups[activeNamespacePanel] && (
<div ref={openedNamespacePanelRef} className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm transition-all duration-300">
<div className="w-full flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-100">
<span className="font-semibold text-[#1C2B4A] capitalize">{activeNamespacePanel}</span>
<div className="flex items-center gap-1">
<span className="text-xs rounded-full bg-slate-100 px-1.5 py-0.5 text-slate-700">
<div
ref={openedNamespacePanelRef}
className="rounded-[28px] border border-white/80 bg-white/90 overflow-hidden shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur transition-all duration-300"
>
{/* Panel header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/60">
<span className="font-bold text-slate-950 capitalize text-base">{activeNamespacePanel}</span>
<div className="flex items-center gap-1.5">
<span className="text-xs rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-600 font-medium">
{(namespaceTranslationStats[activeNamespacePanel]?.translated ?? 0)}/{(namespaceTranslationStats[activeNamespacePanel]?.total ?? 0)}
</span>
{activeLang !== 'en' && (namespaceTranslationStats[activeNamespacePanel]?.missing ?? 0) > 0 && (
<span className="text-xs rounded-full bg-red-100 px-1.5 py-0.5 text-red-700">
<span className="text-xs rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-red-700 font-medium">
{namespaceTranslationStats[activeNamespacePanel]?.missing} missing
</span>
)}
@ -390,12 +390,12 @@ export default function TranslationCoverageEditor({
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50/50">
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
<tr className="border-b border-slate-100 bg-slate-50/40">
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">Key</th>
{activeLang !== 'en' && (
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">English</th>
)}
<th className="px-5 py-2 text-left font-medium text-gray-500">
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
</th>
</tr>
@ -405,25 +405,27 @@ export default function TranslationCoverageEditor({
const enVal = getEnglishValue(key);
const currentVal = getDisplayValue(key);
const isGlobal = globalKeySet.has(key);
const isEnglishReference = englishReferenceKeySet.has(key);
const visibleValue = activeLang === 'en'
? currentVal
: (translations[activeLang]?.[key] ?? '');
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
const isMissingInOpenedPanel =
activeLang !== 'en' &&
!isGlobal && (
!isGlobal &&
!isEnglishReference && (
visibleValue.trim() === '' ||
visibleValue.trim() === enVal.trim()
);
return (
<tr key={key} className={`border-b border-gray-50 last:border-0 ${
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50' : 'hover:bg-blue-50/30'
<tr key={key} className={`border-b border-slate-50 last:border-0 transition-colors ${
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50/80' : 'hover:bg-slate-50/60'
}`}>
<td className={`px-5 py-2 font-mono text-xs align-top pt-3 ${
isMissingInOpenedPanel ? 'text-red-700' : 'text-gray-500'
<td className={`px-5 py-2.5 font-mono text-xs align-top pt-3 ${
isMissingInOpenedPanel ? 'text-red-700' : 'text-slate-500'
}`}>
<div className="space-y-1.5">
<div className="space-y-2.5">
<div className="flex items-center gap-1.5">
<span className="block">{key}</span>
{isGlobal && (
@ -431,38 +433,49 @@ export default function TranslationCoverageEditor({
Global
</span>
)}
{isEnglishReference && activeLang !== 'en' && (
<span className="rounded-full border border-sky-200 bg-sky-50 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700">{t('autofix.k8de6d3df')}</span>
)}
</div>
<label className="inline-flex items-center gap-1.5 text-[11px] font-sans text-slate-600">
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
<input
type="checkbox"
checked={isGlobal}
onChange={(e) => {
if (e.target.checked) {
addGlobalKey(key);
return;
}
if (e.target.checked) { addGlobalKey(key); return; }
removeGlobalKey(key);
}}
className="h-3.5 w-3.5 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
/>{t('autofix.kb1cf599b')}</label>
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>
{t('autofix.kb1cf599b')}
</label>
{activeLang !== 'en' && (
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
<input
type="checkbox"
checked={isEnglishReference}
onChange={(e) => setEnglishReferenceForKey(key, e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
/>{t('autofix.k6d79b1df')}</label>
)}
</div>
</td>
{activeLang !== 'en' && (
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">{enVal}</td>
<td className="px-5 py-2.5 text-slate-400 align-top pt-3 text-xs">{enVal}</td>
)}
<td className="px-5 py-2">
<td className="px-5 py-2.5">
<div className="relative">
<textarea
rows={1}
value={visibleValue}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={activeLang === 'en' ? '' : enVal}
className={`w-full rounded border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
className={`w-full rounded-xl border px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition ${
isMissingInOpenedPanel
? 'border-red-300 bg-red-50'
? 'border-red-300 bg-red-50/60'
: hasOverride && activeLang !== 'en'
? 'border-green-300 bg-green-50'
: 'border-gray-200 bg-white'
? 'border-emerald-300 bg-emerald-50/60'
: 'border-slate-200 bg-white'
}`}
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
onInput={(e) => {
@ -474,9 +487,9 @@ export default function TranslationCoverageEditor({
{hasOverride && activeLang !== 'en' && (
<button
type="button"
title="Clear override (revert to built-in)"
title={t('autofix.k644d9ea8')}
onClick={() => handleChange(key, '')}
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
>
×
</button>
@ -489,11 +502,11 @@ export default function TranslationCoverageEditor({
</tbody>
</table>
<div className="border-t border-gray-100 px-5 py-3 bg-white flex justify-end">
<div className="border-t border-slate-100 px-5 py-3 bg-slate-50/40 flex justify-end">
<button
type="button"
onClick={onBackToPanels}
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
className="rounded-2xl border border-slate-200 bg-white px-4 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
{t('autofix.k6aba2cb0')}
</button>
@ -502,8 +515,11 @@ export default function TranslationCoverageEditor({
)}
</>
)}
{activeCategory !== 'global' && filteredNs.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-500">{t('autofix.k6a892262')}</div>
<div className="rounded-[28px] border border-white/80 bg-white/85 p-10 text-center text-sm text-slate-400 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.18)] backdrop-blur">
{t('autofix.k6a892262')}
</div>
)}
</div>
</>

View File

@ -15,13 +15,16 @@ type Props = {
setWizardInput: (value: string) => void;
wizardMarkGlobal: boolean;
setWizardMarkGlobal: (value: boolean) => void;
wizardUseEnglishReference: boolean;
setWizardUseEnglishReference: (value: boolean) => void;
englishValue: string;
addGlobalKey: (key: string) => void;
removeGlobalKey: (key: string) => void;
onClose: () => void;
onPrevious: () => void;
onSkip: () => void;
onNext: () => void;
onNext: () => void | Promise<void>;
isSavingStep?: boolean;
};
export default function TranslationWizardModal({
@ -35,6 +38,8 @@ export default function TranslationWizardModal({
setWizardInput,
wizardMarkGlobal,
setWizardMarkGlobal,
wizardUseEnglishReference,
setWizardUseEnglishReference,
englishValue,
addGlobalKey,
removeGlobalKey,
@ -42,6 +47,7 @@ export default function TranslationWizardModal({
onPrevious,
onSkip,
onNext,
isSavingStep = false,
}: Props) {
const { t } = useTranslation();
const { isRendered, isVisible } = useModalAnimation(isOpen && Boolean(currentWizardKey));
@ -107,13 +113,31 @@ export default function TranslationWizardModal({
/>
Counts as global key (same value as English)
</label>
{activeLang !== 'en' && (
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={wizardUseEnglishReference}
onChange={(e) => {
const checked = e.target.checked;
setWizardUseEnglishReference(checked);
if (checked) {
setWizardInput(englishValue);
}
}}
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
/>
Use English value (this language only, not global)
</label>
)}
</div>
<div className="px-6 py-4 border-t border-slate-200 bg-white flex items-center justify-between gap-3">
<button
type="button"
onClick={onPrevious}
disabled={wizardIndex === 0}
disabled={wizardIndex === 0 || isSavingStep}
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
Back
@ -122,6 +146,7 @@ export default function TranslationWizardModal({
<button
type="button"
onClick={onSkip}
disabled={isSavingStep}
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
Skip
@ -129,10 +154,12 @@ export default function TranslationWizardModal({
<button
type="button"
onClick={onNext}
disabled={wizardInput.trim() === '' && !wizardMarkGlobal}
disabled={isSavingStep || (wizardInput.trim() === '' && !wizardMarkGlobal && !wizardUseEnglishReference)}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50"
>
{wizardIndex >= wizardMissingCount - 1 ? 'Save and finish' : 'Save and next'}
{isSavingStep
? t('common.saving')
: (wizardIndex >= wizardMissingCount - 1 ? 'Save and finish' : 'Save and next')}
</button>
</div>
</div>

View File

@ -1,4 +1,27 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { authFetch } from '../../../utils/authFetch'
const SCAN_EXCLUDED_FILE_MARKERS = ['src/app/components/toast/toastComponent.tsx'] as const
function normalizeScanPath(path: string): string {
return path.replace(/\\/g, '/').toLowerCase()
}
function isExcludedScanFile(path: string): boolean {
const normalized = normalizeScanPath(path)
return SCAN_EXCLUDED_FILE_MARKERS.some((marker) => normalized.endsWith(marker.toLowerCase()))
}
function filterScanFiles(files: unknown): string[] {
if (!Array.isArray(files)) return []
return files
.filter((file): file is string => typeof file === 'string')
.filter((file) => !isExcludedScanFile(file))
}
type UseI18nScanWorkflowOptions = {
onUnauthorized?: () => void
}
export type WorkspaceScanResult = {
scannedFiles: number
@ -25,19 +48,37 @@ export type WorkspaceScanResult = {
}
function normalizeScanResult(result: any): WorkspaceScanResult {
const missingKeys = Array.isArray(result?.missingKeys)
? result.missingKeys
.filter((entry: any) => entry && typeof entry === 'object' && typeof entry.key === 'string')
.map((entry: any) => ({ key: entry.key, files: filterScanFiles(entry.files) }))
.filter((entry: { key: string; files: string[] }) => entry.files.length > 0)
: []
const untranslatedLiterals = Array.isArray(result?.untranslatedLiterals)
? result.untranslatedLiterals
.filter((entry: any) => entry && typeof entry === 'object' && typeof entry.text === 'string')
.map((entry: any) => ({ text: entry.text, files: filterScanFiles(entry.files) }))
.filter((entry: { text: string; files: string[] }) => entry.files.length > 0)
: []
return {
scannedFiles: Number(result?.scannedFiles ?? 0),
scannedDirectories: Number(result?.scannedDirectories ?? 0),
translationCallCount: Number(result?.translationCallCount ?? 0),
uniqueKeyCount: Number(result?.uniqueKeyCount ?? 0),
missingKeys: Array.isArray(result?.missingKeys) ? result.missingKeys : [],
untranslatedLiterals: Array.isArray(result?.untranslatedLiterals) ? result.untranslatedLiterals : [],
autoFixEligibleFiles: Array.isArray(result?.autoFixEligibleFiles) ? result.autoFixEligibleFiles : undefined,
autoFixForceConvertibleFiles: Array.isArray(result?.autoFixForceConvertibleFiles) ? result.autoFixForceConvertibleFiles : undefined,
missingKeys,
untranslatedLiterals,
autoFixEligibleFiles: Array.isArray(result?.autoFixEligibleFiles) ? filterScanFiles(result.autoFixEligibleFiles) : undefined,
autoFixForceConvertibleFiles: Array.isArray(result?.autoFixForceConvertibleFiles) ? filterScanFiles(result.autoFixForceConvertibleFiles) : undefined,
changedFileCount: Number(result?.changedFileCount ?? 0),
createdKeyCount: Number(result?.createdKeyCount ?? 0),
changedFiles: Array.isArray(result?.changedFiles) ? result.changedFiles : [],
skippedFiles: Array.isArray(result?.skippedFiles) ? result.skippedFiles : [],
changedFiles: Array.isArray(result?.changedFiles)
? result.changedFiles.filter((entry: any) => entry && typeof entry.file === 'string' && !isExcludedScanFile(entry.file))
: [],
skippedFiles: Array.isArray(result?.skippedFiles)
? result.skippedFiles.filter((entry: any) => entry && typeof entry.file === 'string' && !isExcludedScanFile(entry.file))
: [],
autoFixDebug: Array.isArray(result?.autoFixDebug) ? result.autoFixDebug : [],
}
}
@ -64,7 +105,13 @@ function getFixableFiles(scan: WorkspaceScanResult | null, forceConvertToClient:
return Array.from(fileSet).sort((a, b) => a.localeCompare(b))
}
export function useI18nScanWorkflow() {
export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptions = {}) {
const onUnauthorizedRef = useRef(onUnauthorized)
useEffect(() => {
onUnauthorizedRef.current = onUnauthorized
}, [onUnauthorized])
const [showScanModal, setShowScanModal] = useState(false)
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
const [isScanning, setIsScanning] = useState(false)
@ -98,7 +145,11 @@ export function useI18nScanWorkflow() {
setScanError(null)
try {
const response = await fetch('/api/i18n/scan', { method: 'GET' })
const response = await authFetch('/api/i18n/scan', { method: 'GET' })
if (response.status === 401) {
onUnauthorizedRef.current?.()
throw new Error('Session expired. Redirecting to login.')
}
const result = await response.json()
if (!response.ok || !result?.ok) {
@ -114,7 +165,7 @@ export function useI18nScanWorkflow() {
}
const runFixSelected = async () => {
const selectedEligible = selectedFiles.filter((file) => fixableFiles.includes(file))
const selectedEligible = selectedFiles.filter((file) => fixableFiles.includes(file) && !isExcludedScanFile(file))
if (selectedEligible.length === 0) {
setScanError('Select at least one file before running auto-fix.')
@ -126,11 +177,19 @@ export function useI18nScanWorkflow() {
setScanError(null)
try {
const response = await fetch('/api/i18n/scan', {
const response = await authFetch('/api/i18n/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetFiles: selectedEligible, forceConvertToClient }),
body: JSON.stringify({
targetFiles: selectedEligible,
forceConvertToClient,
excludedFiles: [...SCAN_EXCLUDED_FILE_MARKERS],
}),
})
if (response.status === 401) {
onUnauthorizedRef.current?.()
throw new Error('Session expired. Redirecting to login.')
}
const result = await response.json()
if (!response.ok || !result?.ok) {

View File

@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authFetch } from '../../../utils/authFetch';
import useAuthStore from '../../../store/authStore';
export type LanguageEntry = {
code: string;
@ -13,15 +15,51 @@ export type FileBackedI18nData = {
type UseLanguageManagementTranslationsOptions = {
coreLanguages: Set<string>;
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
onUnauthorized?: () => void;
};
export function useLanguageManagementTranslations({ coreLanguages, onAction }: UseLanguageManagementTranslationsOptions) {
const ACTIVE_LANG_STORAGE_KEY = 'language-management-active-lang';
export function useLanguageManagementTranslations({ coreLanguages, onAction, onUnauthorized }: UseLanguageManagementTranslationsOptions) {
const onActionRef = useRef(onAction);
const onUnauthorizedRef = useRef(onUnauthorized);
useEffect(() => {
onUnauthorizedRef.current = onUnauthorized;
}, [onUnauthorized]);
const emitAction = useCallback((notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
try {
onActionRef.current?.(notice);
} catch {
// Keep persistence flow running even if notification UI crashes.
}
}, []);
useEffect(() => {
onActionRef.current = onAction;
}, [onAction]);
const [data, setData] = useState<FileBackedI18nData>({ languages: [], translations: {} });
const dataRef = useRef<FileBackedI18nData>({ languages: [], translations: {} });
const [isLoading, setIsLoading] = useState(true);
const [loadingPhase, setLoadingPhase] = useState('idle');
const [loadingProgress, setLoadingProgress] = useState(0);
const [loadingLogs, setLoadingLogs] = useState<string[]>([]);
const [isDirty, setIsDirty] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState('');
const [activeLang, setActiveLang] = useState('en');
// Base translations from en.ts — used to detect which keys are genuine overrides (delta).
const enBaseRef = useRef<Record<string, string>>({});
// Overrides currently stored in DB per custom language — used to track what needs deletion on revert.
const dbOverridesRef = useRef<Record<string, Record<string, string>>>({});
const [activeLang, setActiveLang] = useState(() => {
if (typeof window === 'undefined') return 'en';
const stored = window.localStorage.getItem(ACTIVE_LANG_STORAGE_KEY);
return stored && stored.trim() !== '' ? stored : 'en';
});
const [showAddModal, setShowAddModal] = useState(false);
const [newCode, setNewCode] = useState('');
@ -29,9 +67,32 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
const [addError, setAddError] = useState('');
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const hasFetchedOnceRef = useRef(false);
const appendLoadingLog = useCallback((message: string) => {
const timestamp = new Date().toLocaleTimeString();
setLoadingLogs((prev) => [...prev, `[${timestamp}] ${message}`].slice(-30));
}, []);
const fetchTranslationFiles = useCallback(async () => {
const response = await fetch('/api/i18n/translations', { cache: 'no-store' });
setLoadingPhase('refresh-auth');
setLoadingProgress(15);
appendLoadingLog('Refreshing auth session before translation bootstrap');
// Preflight refresh avoids immediate 401/refresh/retry loops during initial mount.
await useAuthStore.getState().refreshAuthToken(true).catch(() => null);
setLoadingPhase('fetch-translations');
setLoadingProgress(35);
appendLoadingLog('Fetching translation files from /api/i18n/translations');
const response = await authFetch('/api/i18n/translations', { cache: 'no-store' });
if (response.status === 401) {
onUnauthorizedRef.current?.();
throw new Error('Session expired. Redirecting to login.');
}
const result = await response.json();
if (!response.ok || !result?.ok) {
@ -50,12 +111,85 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
? result.translations as Record<string, Record<string, string>>
: {};
// Store en.ts as the immutable base for delta calculation.
enBaseRef.current = translations['en'] ?? {};
setLoadingProgress(60);
// Fetch DB overrides and merge them on top of .ts file values for custom languages.
// This ensures admin edits from previous sessions are restored on load.
try {
setLoadingPhase('fetch-preferences');
setLoadingProgress(75);
appendLoadingLog('Fetching DB overrides from /api/i18n/preferences');
const prefResponse = await authFetch('/api/i18n/preferences', { cache: 'no-store' });
if (prefResponse.ok) {
const prefResult = await prefResponse.json().catch(() => null);
const payload = prefResult?.preferences && typeof prefResult.preferences === 'object'
? prefResult.preferences
: prefResult;
const overridesArray: unknown[] = Array.isArray(payload?.translations) ? payload.translations : [];
const dbOverrides: Record<string, Record<string, string>> = {};
for (const entry of overridesArray) {
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
if (typeof e.languageCode !== 'string' || typeof e.namespace !== 'string' ||
typeof e.key !== 'string' || typeof e.value !== 'string') continue;
const flatKey = `${e.namespace}.${e.key}`;
if (!dbOverrides[e.languageCode]) dbOverrides[e.languageCode] = {};
dbOverrides[e.languageCode][flatKey] = e.value;
}
dbOverridesRef.current = dbOverrides;
// Deep-merge: DB overrides win on conflict, for custom languages only.
const mergedTranslations = { ...translations };
for (const [langCode, overrides] of Object.entries(dbOverrides)) {
if (coreLanguages.has(langCode)) continue;
mergedTranslations[langCode] = { ...(translations[langCode] ?? {}), ...overrides };
}
setData({ languages, translations: mergedTranslations });
setLoadingProgress(95);
appendLoadingLog('Translations + DB overrides merged successfully');
return;
}
} catch {
// DB overrides unavailable — fall through to file-only data.
appendLoadingLog('DB override fetch failed, continuing with file-only translations');
}
setData({ languages, translations });
}, []);
setLoadingProgress(95);
appendLoadingLog('Loaded file-backed translations without DB overrides');
}, [appendLoadingLog, coreLanguages]);
useEffect(() => {
void fetchTranslationFiles();
}, [fetchTranslationFiles]);
if (hasFetchedOnceRef.current) return;
hasFetchedOnceRef.current = true;
setIsLoading(true);
setLoadingPhase('initializing');
setLoadingProgress(5);
appendLoadingLog('Starting language management bootstrap');
void fetchTranslationFiles()
.catch((error) => {
setLoadingPhase('error');
appendLoadingLog(`Bootstrap error: ${error instanceof Error ? error.message : 'Unknown error'}`);
emitAction({
variant: 'warning',
message: error instanceof Error ? error.message : 'Failed to load translation files.',
});
})
.finally(() => {
setLoadingPhase('ready');
setLoadingProgress(100);
appendLoadingLog('Translation bootstrap complete');
setIsLoading(false);
});
}, [appendLoadingLog, emitAction, fetchTranslationFiles]);
const allLanguages: LanguageEntry[] = useMemo(() => data.languages, [data.languages]);
@ -67,6 +201,15 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
setActiveLang(fallback);
}, [allLanguages, activeLang]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(ACTIVE_LANG_STORAGE_KEY, activeLang);
}, [activeLang]);
useEffect(() => {
dataRef.current = data;
}, [data]);
const getDisplayValue = useCallback(
(key: string): string => data.translations[activeLang]?.[key] ?? '',
[activeLang, data.translations]
@ -76,23 +219,32 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
setData((prev) => {
const langTranslations = { ...(prev.translations[activeLang] ?? {}) };
langTranslations[key] = value;
return {
const next = {
...prev,
translations: { ...prev.translations, [activeLang]: langTranslations },
};
dataRef.current = next;
return next;
});
setIsDirty(true);
setSaved(false);
}, [activeLang]);
const handleSave = useCallback(async () => {
const handleSave = useCallback(async (translationsOverride?: Record<string, Record<string, string>>) => {
try {
setSaveError('');
const response = await fetch('/api/i18n/translations', {
const effectiveTranslations = translationsOverride ?? dataRef.current.translations;
const response = await authFetch('/api/i18n/translations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ translations: data.translations }),
body: JSON.stringify({ translations: effectiveTranslations }),
});
if (response.status === 401) {
onUnauthorizedRef.current?.();
throw new Error('Session expired. Redirecting to login.');
}
const result = await response.json();
if (!response.ok || !result?.ok) {
@ -107,15 +259,69 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
setData({ languages, translations });
setIsDirty(false);
setSaved(true);
onAction?.({ variant: 'success', message: 'Translations saved successfully.' });
emitAction({ variant: 'success', message: 'Translations saved successfully.' });
setTimeout(() => setSaved(false), 2500);
// Best-effort: sync ONLY delta overrides for custom languages to DB.
// Delta = keys whose value differs from the en.ts base. Core languages are .ts-only.
const enBase = enBaseRef.current;
const customLangCodes = Object.keys(translations).filter((c) => !coreLanguages.has(c));
if (customLangCodes.length > 0) {
const overrides: Array<{ languageCode: string; namespace: string; key: string; value: string; isCustom: boolean }> = [];
const nextDbOverrides: Record<string, Record<string, string>> = { ...dbOverridesRef.current };
for (const langCode of customLangCodes) {
const flat = translations[langCode] ?? {};
const prevDbLang = dbOverridesRef.current[langCode] ?? {};
const nextDbLang: Record<string, string> = {};
for (const [flatKey, value] of Object.entries(flat)) {
const baseValue = enBase[flatKey] ?? '';
if (!value || value === baseValue) {
// Key matches base or is empty — not a genuine override; omit from DB.
// If it was previously stored, it will be absent from the next PUT batch
// (backends using full-replace semantics will remove it automatically).
continue;
}
const dotIdx = flatKey.indexOf('.');
const namespace = dotIdx !== -1 ? flatKey.slice(0, dotIdx) : flatKey;
const key = dotIdx !== -1 ? flatKey.slice(dotIdx + 1) : flatKey;
overrides.push({ languageCode: langCode, namespace, key, value, isCustom: true });
nextDbLang[flatKey] = value;
}
// Carry forward any DB overrides for keys not present in flat (shouldn't happen normally).
for (const [flatKey, value] of Object.entries(prevDbLang)) {
if (!(flatKey in nextDbLang)) {
// Key was reverted to base; drop from our local cache.
} else {
nextDbLang[flatKey] = value;
}
}
nextDbOverrides[langCode] = nextDbLang;
}
// Update local DB overrides cache regardless of whether the PUT succeeds.
dbOverridesRef.current = nextDbOverrides;
if (overrides.length > 0) {
authFetch('/api/i18n/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ translations: overrides }),
}).catch(() => {
// Non-fatal: .ts files are already saved successfully.
});
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save translation files.';
setSaveError(message);
onAction?.({ variant: 'error', message });
emitAction({ variant: 'error', message });
setSaved(false);
}
}, [data.translations, onAction]);
}, [emitAction]);
const handleAddLanguage = useCallback(async () => {
const code = newCode.trim().toLowerCase();
@ -132,11 +338,17 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
}
try {
const response = await fetch('/api/i18n/translations', {
const response = await authFetch('/api/i18n/translations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, name }),
});
if (response.status === 401) {
onUnauthorizedRef.current?.();
throw new Error('Session expired. Redirecting to login.');
}
const result = await response.json();
if (!response.ok || !result?.ok) {
@ -156,23 +368,41 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
setActiveLang(code);
setIsDirty(false);
setSaved(false);
onAction?.({ variant: 'success', message: `Language ${name} (${code}) added successfully.` });
emitAction({ variant: 'success', message: `Language ${name} (${code}) added successfully.` });
// Best-effort: register the new language in the DB.
authFetch('/api/i18n/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language: { languageCode: code, label: name, isEnabled: true, isCustom: true },
}),
}).catch(() => {
// Non-fatal: .ts file was created successfully.
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create language file.';
setAddError(message);
onAction?.({ variant: 'error', message });
emitAction({ variant: 'error', message });
}
}, [allLanguages, newCode, newName, onAction]);
}, [allLanguages, emitAction, newCode, newName]);
const handleDeleteLanguage = useCallback(async (code: string) => {
if (coreLanguages.has(code)) return;
try {
const response = await fetch('/api/i18n/translations', {
// 1. Delete the .ts translation file
const response = await authFetch('/api/i18n/translations', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (response.status === 401) {
onUnauthorizedRef.current?.();
throw new Error('Session expired. Redirecting to login.');
}
const result = await response.json();
if (!response.ok || !result?.ok) {
@ -188,19 +418,63 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
setIsDirty(false);
setSaved(false);
setDeleteTarget(null);
onAction?.({ variant: 'success', message: `Language ${code} deleted successfully.` });
if (activeLang === code) setActiveLang('en');
// 2. Best-effort: remove language-specific records from the DB
try {
await authFetch(
`/api/i18n/preferences?languageCode=${encodeURIComponent(code)}`,
{ method: 'DELETE' }
);
} catch {
// DB cleanup failure is non-fatal; file is already deleted.
emitAction({ variant: 'warning', message: `Language "${code}" file deleted, but DB cleanup failed. You may need to remove DB records manually.` });
}
emitAction({ variant: 'success', message: `Language ${code} deleted successfully.` });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete language file.';
setAddError(message);
onAction?.({ variant: 'error', message });
emitAction({ variant: 'error', message });
}
}, [activeLang, coreLanguages, onAction]);
}, [activeLang, coreLanguages, emitAction]);
const isBuiltin = useCallback((code: string) => coreLanguages.has(code), [coreLanguages]);
/**
* Returns true if the given flat key for the currently active language (or a specified language)
* is a genuine DB override i.e. its value differs from the en.ts base.
*/
const isOverridden = useCallback(
(flatKey: string, langCode?: string): boolean => {
const lang = langCode ?? activeLang;
if (coreLanguages.has(lang)) return false;
const currentValue = data.translations[lang]?.[flatKey] ?? '';
const baseValue = enBaseRef.current[flatKey] ?? '';
return currentValue !== '' && currentValue !== baseValue;
},
[activeLang, coreLanguages, data.translations]
);
/**
* Reverts a key to its en.ts base value (removes the override from the working state).
* The override will be excluded from the next DB sync automatically.
*/
const handleRevertKey = useCallback(
(flatKey: string) => {
if (coreLanguages.has(activeLang)) return;
const baseValue = enBaseRef.current[flatKey] ?? '';
handleChange(flatKey, baseValue);
},
[activeLang, coreLanguages, handleChange]
);
return {
data,
isLoading,
loadingPhase,
loadingProgress,
loadingLogs,
allLanguages,
activeLang,
setActiveLang,
@ -223,5 +497,7 @@ export function useLanguageManagementTranslations({ coreLanguages, onAction }: U
setDeleteTarget,
handleDeleteLanguage,
isBuiltin,
isOverridden,
handleRevertKey,
};
}

View File

@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { authFetch } from '../../../utils/authFetch';
import useAuthStore from '../../../store/authStore';
type NamespaceCategorySeed = { label: string; namespaces: string[] };
@ -30,6 +32,13 @@ type UseNamespaceCategoriesOptions = {
allKeys: string[];
defaultCategories: NamespaceCategorySeed[];
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
onUnauthorized?: () => void;
};
type PreferencesSaveSnapshot = {
categories?: NamespaceCategory[];
globalKeys?: string[];
englishReferenceKeysByLanguage?: Record<string, string[]>;
};
function normalizeCategories(value: unknown): NamespaceCategory[] {
@ -52,10 +61,37 @@ function normalizeGlobalKeys(value: unknown): string[] {
return value.filter((k): k is string => typeof k === 'string');
}
export function useNamespaceCategories({ namespaces, allKeys, defaultCategories, onAction }: UseNamespaceCategoriesOptions) {
function normalizeEnglishReferenceKeysByLanguage(value: unknown): Record<string, string[]> {
if (!value || typeof value !== 'object') return {};
const result: Record<string, string[]> = {};
for (const [languageCode, keys] of Object.entries(value as Record<string, unknown>)) {
if (!Array.isArray(keys)) continue;
const normalizedKeys = keys.filter((k): k is string => typeof k === 'string');
if (normalizedKeys.length > 0) {
result[languageCode] = Array.from(new Set(normalizedKeys)).sort((a, b) => a.localeCompare(b));
}
}
return result;
}
export function useNamespaceCategories({ namespaces, allKeys, defaultCategories, onAction, onUnauthorized }: UseNamespaceCategoriesOptions) {
const fallbackCategories = useMemo(() => createDefaultCategories(defaultCategories), [defaultCategories]);
const skipFirstPersistRef = useRef(true);
const onActionRef = useRef(onAction);
const onUnauthorizedRef = useRef(onUnauthorized);
const lastSavedPreferencesRef = useRef('');
const hasLoadedPreferencesOnceRef = useRef(false);
useEffect(() => {
onUnauthorizedRef.current = onUnauthorized;
}, [onUnauthorized]);
const emitAction = (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
try {
onActionRef.current?.(notice);
} catch {
// Never block DB persistence because of notification rendering failures.
}
};
useEffect(() => {
onActionRef.current = onAction;
@ -66,90 +102,226 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
const [categories, setCategories] = useState<NamespaceCategory[]>(fallbackCategories);
const [globalKeys, setGlobalKeys] = useState<string[]>([]);
const [englishReferenceKeysByLanguage, setEnglishReferenceKeysByLanguage] = useState<Record<string, string[]>>({});
const [preferencesHydrated, setPreferencesHydrated] = useState(false);
const [preferencesLoadingPhase, setPreferencesLoadingPhase] = useState('idle');
const [preferencesLoadingProgress, setPreferencesLoadingProgress] = useState(0);
const [preferencesLoadingLogs, setPreferencesLoadingLogs] = useState<string[]>([]);
const [isPreferencesDirty, setIsPreferencesDirty] = useState(false);
const [isSavingPreferences, setIsSavingPreferences] = useState(false);
const [newCategoryLabel, setNewCategoryLabel] = useState('');
const [assignNamespaceByCategory, setAssignNamespaceByCategory] = useState<Record<string, string>>({});
const [expandedCategoryId, setExpandedCategoryId] = useState<string | null>(null);
const [dragNamespace, setDragNamespace] = useState<string | null>(null);
const appendPreferencesLoadingLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
setPreferencesLoadingLogs((prev) => [...prev, `[${timestamp}] ${message}`].slice(-30));
};
const serializePreferences = (
nextCategories: NamespaceCategory[],
nextGlobalKeys: string[],
nextEnglishReferenceKeysByLanguage: Record<string, string[]>
) =>
JSON.stringify({
categories: nextCategories,
globalKeys: nextGlobalKeys,
englishReferenceKeysByLanguage: nextEnglishReferenceKeysByLanguage,
});
useEffect(() => {
let cancelled = false;
if (hasLoadedPreferencesOnceRef.current) return;
hasLoadedPreferencesOnceRef.current = true;
setPreferencesLoadingPhase('initializing');
setPreferencesLoadingProgress(5);
appendPreferencesLoadingLog('Starting preferences bootstrap');
const loadPreferences = async () => {
console.debug('[LanguageManagement][Preferences] load:start');
try {
const response = await fetch('/api/i18n/preferences', { cache: 'no-store' });
setPreferencesLoadingPhase('refresh-auth');
setPreferencesLoadingProgress(15);
appendPreferencesLoadingLog('Refreshing auth session before preferences fetch');
// Preflight refresh avoids immediate 401/refresh/retry loops during initial mount.
await useAuthStore.getState().refreshAuthToken(true).catch(() => null);
setPreferencesLoadingPhase('fetch-preferences');
setPreferencesLoadingProgress(35);
appendPreferencesLoadingLog('Fetching language preferences from /api/i18n/preferences');
const response = await authFetch('/api/i18n/preferences', { cache: 'no-store' });
console.debug('[LanguageManagement][Preferences] load:response', {
status: response.status,
ok: response.ok,
});
if (response.status === 401) {
onUnauthorizedRef.current?.();
throw new Error('Session expired. Redirecting to login.');
}
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
throw new Error(result?.message || 'Failed to load language preferences.');
}
if (cancelled) return;
const payload = result.preferences && typeof result.preferences === 'object'
? result.preferences
: result;
const loadedCategories = normalizeCategories(payload?.categories);
const loadedGlobalKeys = normalizeGlobalKeys(payload?.globalKeys);
const loadedEnglishReferenceKeysByLanguage =
normalizeEnglishReferenceKeysByLanguage(payload?.englishReferenceKeysByLanguage);
setCategories(loadedCategories.length > 0 ? loadedCategories : fallbackCategories);
setGlobalKeys(loadedGlobalKeys);
const effectiveCategories = loadedCategories.length > 0 ? loadedCategories : fallbackCategories;
const effectiveGlobalKeys = loadedGlobalKeys;
const effectiveEnglishReferenceKeysByLanguage = loadedEnglishReferenceKeysByLanguage;
lastSavedPreferencesRef.current = serializePreferences(
effectiveCategories,
effectiveGlobalKeys,
effectiveEnglishReferenceKeysByLanguage
);
console.debug('[LanguageManagement][Preferences] load:applied', {
categoriesCount: effectiveCategories.length,
globalKeysCount: effectiveGlobalKeys.length,
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
});
setCategories(effectiveCategories);
setGlobalKeys(effectiveGlobalKeys);
setEnglishReferenceKeysByLanguage(effectiveEnglishReferenceKeysByLanguage);
setIsPreferencesDirty(false);
setPreferencesLoadingProgress(90);
appendPreferencesLoadingLog('Preferences loaded and applied');
} catch (error) {
if (!cancelled) {
console.debug('[LanguageManagement][Preferences] load:error', {
message: error instanceof Error ? error.message : String(error),
});
setPreferencesLoadingPhase('error');
appendPreferencesLoadingLog(`Preferences bootstrap error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setCategories(fallbackCategories);
setGlobalKeys([]);
onActionRef.current?.({
setEnglishReferenceKeysByLanguage({});
lastSavedPreferencesRef.current = serializePreferences(fallbackCategories, [], {});
setIsPreferencesDirty(false);
emitAction({
variant: 'warning',
message: error instanceof Error
? error.message
: 'Failed to load language preferences from backend.',
});
}
} finally {
if (!cancelled) setPreferencesHydrated(true);
setPreferencesHydrated(true);
setPreferencesLoadingPhase('ready');
setPreferencesLoadingProgress(100);
appendPreferencesLoadingLog('Preferences bootstrap complete');
console.debug('[LanguageManagement][Preferences] load:hydrated');
}
};
void loadPreferences();
return () => {
cancelled = true;
};
}, [fallbackCategories]);
useEffect(() => {
if (!preferencesHydrated) return;
if (skipFirstPersistRef.current) {
skipFirstPersistRef.current = false;
return;
const currentSerialized = serializePreferences(categories, globalKeys, englishReferenceKeysByLanguage);
const nextDirty = currentSerialized !== lastSavedPreferencesRef.current;
setIsPreferencesDirty(nextDirty);
console.debug('[LanguageManagement][Preferences] dirty:recompute', {
preferencesHydrated,
categoriesCount: categories.length,
globalKeysCount: globalKeys.length,
englishReferenceLanguagesCount: Object.keys(englishReferenceKeysByLanguage).length,
isDirty: nextDirty,
});
}, [categories, globalKeys, englishReferenceKeysByLanguage, preferencesHydrated]);
const savePreferences = async (snapshot?: PreferencesSaveSnapshot) => {
const effectiveCategories = snapshot?.categories ?? categories;
const effectiveGlobalKeys = snapshot?.globalKeys ?? globalKeys;
const effectiveEnglishReferenceKeysByLanguage =
snapshot?.englishReferenceKeysByLanguage ?? englishReferenceKeysByLanguage;
if (!preferencesHydrated) {
console.debug('[LanguageManagement][Preferences] save:skipped:notHydrated');
return false;
}
const timeoutId = setTimeout(async () => {
const currentSerialized = serializePreferences(
effectiveCategories,
effectiveGlobalKeys,
effectiveEnglishReferenceKeysByLanguage
);
if (currentSerialized === lastSavedPreferencesRef.current) {
console.debug('[LanguageManagement][Preferences] save:skipped:notDirty', {
categoriesCount: effectiveCategories.length,
globalKeysCount: effectiveGlobalKeys.length,
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
});
return true;
}
console.debug('[LanguageManagement][Preferences] save:start', {
categoriesCount: effectiveCategories.length,
globalKeysCount: effectiveGlobalKeys.length,
globalKeysPreview: effectiveGlobalKeys.slice(0, 8),
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
});
setIsSavingPreferences(true);
try {
const response = await fetch('/api/i18n/preferences', {
const response = await authFetch('/api/i18n/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories, globalKeys }),
body: JSON.stringify({
categories: effectiveCategories,
globalKeys: effectiveGlobalKeys,
englishReferenceKeysByLanguage: effectiveEnglishReferenceKeysByLanguage,
}),
});
console.debug('[LanguageManagement][Preferences] save:response', {
status: response.status,
ok: response.ok,
});
if (response.status === 401) {
onUnauthorizedRef.current?.();
throw new Error('Session expired. Redirecting to login.');
}
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
throw new Error(result?.message || 'Failed to save language preferences.');
}
lastSavedPreferencesRef.current = currentSerialized;
setIsPreferencesDirty(false);
console.debug('[LanguageManagement][Preferences] save:success');
return true;
} catch (error) {
onActionRef.current?.({
console.debug('[LanguageManagement][Preferences] save:error', {
message: error instanceof Error ? error.message : String(error),
});
emitAction({
variant: 'error',
message: error instanceof Error
? error.message
: 'Failed to save language preferences.',
});
return false;
} finally {
setIsSavingPreferences(false);
}
}, 350);
return () => clearTimeout(timeoutId);
}, [categories, globalKeys, preferencesHydrated]);
};
const categoriesWithKnownNamespaces = useMemo(() => {
const namespaceSet = new Set(namespaces);
@ -224,11 +396,68 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
const addGlobalKey = (key: string) => {
if (!key) return;
setGlobalKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
setGlobalKeys((prev) => {
const next = prev.includes(key) ? prev : [...prev, key];
console.debug('[LanguageManagement][Preferences] global:add', {
key,
previousCount: prev.length,
nextCount: next.length,
changed: next !== prev,
});
return next;
});
};
const removeGlobalKey = (key: string) => {
setGlobalKeys((prev) => prev.filter((k) => k !== key));
setGlobalKeys((prev) => {
const next = prev.filter((k) => k !== key);
console.debug('[LanguageManagement][Preferences] global:remove', {
key,
previousCount: prev.length,
nextCount: next.length,
changed: next.length !== prev.length,
});
return next;
});
};
const englishReferenceKeySetByLanguage = useMemo(() => {
const result: Record<string, Set<string>> = {};
for (const [languageCode, keys] of Object.entries(englishReferenceKeysByLanguage)) {
result[languageCode] = new Set(keys);
}
return result;
}, [englishReferenceKeysByLanguage]);
const setEnglishReferenceKey = (languageCode: string, key: string, enabled: boolean) => {
if (!languageCode || languageCode === 'en' || !key) return;
setEnglishReferenceKeysByLanguage((prev) => {
const prevKeys = prev[languageCode] ?? [];
const prevSet = new Set(prevKeys);
if (enabled) {
prevSet.add(key);
} else {
prevSet.delete(key);
}
const nextKeys = Array.from(prevSet).sort((a, b) => a.localeCompare(b));
const next = { ...prev };
if (nextKeys.length === 0) {
delete next[languageCode];
} else {
next[languageCode] = nextKeys;
}
console.debug('[LanguageManagement][Preferences] english-reference:set', {
languageCode,
key,
enabled,
previousCount: prevKeys.length,
nextCount: nextKeys.length,
});
return next;
});
};
return {
@ -236,6 +465,13 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
setActiveCategory,
showCategoryManagerModal,
setShowCategoryManagerModal,
isPreferencesDirty,
preferencesHydrated,
preferencesLoadingPhase,
preferencesLoadingProgress,
preferencesLoadingLogs,
isSavingPreferences,
savePreferences,
categoriesWithKnownNamespaces,
uncategorizedNamespaces,
addNamespaceToCategory,
@ -254,5 +490,8 @@ export function useNamespaceCategories({ namespaces, allKeys, defaultCategories,
globalKeySet,
addGlobalKey,
removeGlobalKey,
englishReferenceKeysByLanguage,
englishReferenceKeySetByLanguage,
setEnglishReferenceKey,
};
}

View File

@ -13,7 +13,9 @@ import LanguageManagementTopSection from './components/LanguageManagementTopSect
import { useI18nScanWorkflow } from './hooks/useI18nScanWorkflow';
import { useLanguageManagementTranslations } from './hooks/useLanguageManagementTranslations';
import { useNamespaceCategories } from './hooks/useNamespaceCategories';
import { useModalAnimation } from './hooks/useModalAnimation';
import { useToast } from '../../components/toast/toastComponent';
import { isPageTransitioning } from '../../components/animation/pageTransitionEffect';
import {
getAllTranslationKeys,
getEnglishValue,
@ -21,6 +23,7 @@ import {
} from '../../i18n/useTranslation';
const CORE_LANGUAGES = new Set(['en', 'de']);
const AUTO_SCROLL_ON_SAVE_STORAGE_KEY = 'language-management-auto-scroll-on-save';
// ── namespace categories
const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
@ -56,6 +59,10 @@ export default function LanguageManagementPage() {
});
}, [showToast]);
const handleUnauthorized = useCallback(() => {
router.push('/login');
}, [router]);
const {
showScanModal,
setShowScanModal,
@ -73,7 +80,7 @@ export default function LanguageManagementPage() {
toggleFileSelection,
selectAllFiles,
clearSelectedFiles,
} = useI18nScanWorkflow();
} = useI18nScanWorkflow({ onUnauthorized: handleUnauthorized });
// ── all flat keys from the English source-of-truth
const allKeys = useMemo(() => getAllTranslationKeys(), []);
@ -82,6 +89,10 @@ export default function LanguageManagementPage() {
const {
data,
isLoading: isTranslationsLoading,
loadingPhase: translationsLoadingPhase,
loadingProgress: translationsLoadingProgress,
loadingLogs: translationsLoadingLogs,
allLanguages,
activeLang,
setActiveLang,
@ -107,12 +118,15 @@ export default function LanguageManagementPage() {
} = useLanguageManagementTranslations({
coreLanguages: CORE_LANGUAGES,
onAction: notifyAction,
onUnauthorized: handleUnauthorized,
});
const [showTranslationWizard, setShowTranslationWizard] = useState(false);
const [wizardIndex, setWizardIndex] = useState(0);
const [wizardInput, setWizardInput] = useState('');
const [wizardMarkGlobal, setWizardMarkGlobal] = useState(false);
const [wizardUseEnglishReference, setWizardUseEnglishReference] = useState(false);
const [isWizardSavingStep, setIsWizardSavingStep] = useState(false);
const [newGlobalKeySelection, setNewGlobalKeySelection] = useState('');
const [reloadAfterScanClose, setReloadAfterScanClose] = useState(false);
@ -123,6 +137,13 @@ export default function LanguageManagementPage() {
setActiveCategory,
showCategoryManagerModal,
setShowCategoryManagerModal,
isPreferencesDirty,
preferencesHydrated,
preferencesLoadingPhase,
preferencesLoadingProgress,
preferencesLoadingLogs,
isSavingPreferences,
savePreferences,
categoriesWithKnownNamespaces,
uncategorizedNamespaces,
addNamespaceToCategory,
@ -141,21 +162,93 @@ export default function LanguageManagementPage() {
globalKeySet,
addGlobalKey,
removeGlobalKey,
englishReferenceKeysByLanguage,
englishReferenceKeySetByLanguage,
setEnglishReferenceKey,
} = useNamespaceCategories({
namespaces,
allKeys,
defaultCategories: NAMESPACE_CATEGORIES,
onAction: notifyAction,
onUnauthorized: handleUnauthorized,
});
const hasUnsavedChanges = isDirty || isPreferencesDirty;
const isInitialDataLoading = isTranslationsLoading || !preferencesHydrated;
const initialLoadingProgress = Math.round((translationsLoadingProgress + preferencesLoadingProgress) / 2);
const [isPageTransitionDone, setIsPageTransitionDone] = useState(() => !isPageTransitioning);
const [showDelayedFetchingScreen, setShowDelayedFetchingScreen] = useState(false);
const [showDelayedFetchLogs, setShowDelayedFetchLogs] = useState(false);
const combinedLoadingLogs = useMemo(() => {
const preferenceLines = preferencesLoadingLogs.map((line) => `[PREF] ${line}`);
const translationLines = translationsLoadingLogs.map((line) => `[I18N] ${line}`);
return [...preferenceLines, ...translationLines].slice(-18);
}, [preferencesLoadingLogs, translationsLoadingLogs]);
const { isRendered: isSaveBarRendered, isVisible: isSaveBarVisible } = useModalAnimation(hasUnsavedChanges);
useEffect(() => {
if (isPageTransitionDone) return;
let rafId: number | null = null;
const pollTransition = () => {
if (!isPageTransitioning) {
setIsPageTransitionDone(true);
return;
}
rafId = window.requestAnimationFrame(pollTransition);
};
rafId = window.requestAnimationFrame(pollTransition);
return () => {
if (rafId) window.cancelAnimationFrame(rafId);
};
}, [isPageTransitionDone]);
useEffect(() => {
if (!isInitialDataLoading || !isPageTransitionDone) {
setShowDelayedFetchingScreen(false);
return;
}
const timeoutId = window.setTimeout(() => setShowDelayedFetchingScreen(true), 350);
return () => window.clearTimeout(timeoutId);
}, [isInitialDataLoading, isPageTransitionDone]);
useEffect(() => {
if (!showDelayedFetchingScreen) {
setShowDelayedFetchLogs(false);
return;
}
const timeoutId = window.setTimeout(() => setShowDelayedFetchLogs(true), 1400);
return () => window.clearTimeout(timeoutId);
}, [showDelayedFetchingScreen]);
const showFetchingScreen = isInitialDataLoading && isPageTransitionDone && showDelayedFetchingScreen;
const showWarmGap = isInitialDataLoading && isPageTransitionDone && !showDelayedFetchingScreen;
// ── search / filter
const [search, setSearch] = useState('');
const [autoScrollOnPanelOpen, setAutoScrollOnPanelOpen] = useState(true);
const [autoScrollOnSave, setAutoScrollOnSave] = useState(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(AUTO_SCROLL_ON_SAVE_STORAGE_KEY) === '1';
});
const [activeNamespacePanel, setActiveNamespacePanel] = useState<string | null>(null);
const languageManagementHeaderRef = useRef<HTMLDivElement | null>(null);
const openedNamespacePanelRef = useRef<HTMLDivElement | null>(null);
const openFromPanelClickRef = useRef(false);
const saveBarRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
const scrollHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { isRendered: isHintRendered, isVisible: isHintVisible } = useModalAnimation(showScrollHint);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(AUTO_SCROLL_ON_SAVE_STORAGE_KEY, autoScrollOnSave ? '1' : '0');
}, [autoScrollOnSave]);
const filteredGroups = useMemo(() => {
const q = search.toLowerCase();
@ -222,6 +315,7 @@ export default function LanguageManagementPage() {
const isMissingForLanguage = (key: string, languageCode: string) => {
if (languageCode === 'en') return false;
if (globalKeySet.has(key)) return false;
if (englishReferenceKeySetByLanguage[languageCode]?.has(key)) return false;
const value = (data.translations[languageCode]?.[key] ?? '').trim();
const englishValue = getEnglishValue(key).trim();
@ -322,25 +416,87 @@ export default function LanguageManagementPage() {
const key = wizardMissingKeys[wizardIndex];
setWizardInput(data.translations[activeLang]?.[key] ?? '');
setWizardMarkGlobal(globalKnownKeys.includes(key));
}, [showTranslationWizard, wizardIndex, wizardMissingKeys, data.translations, activeLang, globalKnownKeys]);
setWizardUseEnglishReference(Boolean(englishReferenceKeySetByLanguage[activeLang]?.has(key)));
}, [showTranslationWizard, wizardIndex, wizardMissingKeys, data.translations, activeLang, globalKnownKeys, englishReferenceKeySetByLanguage]);
const saveCurrentWizardValue = () => {
if (!currentWizardKey) return;
const trimmedWizardInput = wizardInput.trim();
const englishValue = getEnglishValue(currentWizardKey).trim();
const nextTranslations: Record<string, Record<string, string>> = {
...data.translations,
[activeLang]: {
...(data.translations[activeLang] ?? {}),
},
};
const nextGlobalKeysSet = new Set(globalKnownKeys);
const nextEnglishReferenceKeysByLanguage = {
...englishReferenceKeysByLanguage,
[activeLang]: [...(englishReferenceKeysByLanguage[activeLang] ?? [])],
};
const nextEnglishReferenceSet = new Set(nextEnglishReferenceKeysByLanguage[activeLang] ?? []);
if (wizardUseEnglishReference) {
nextTranslations[activeLang][currentWizardKey] = englishValue;
handleChange(currentWizardKey, englishValue);
setEnglishReferenceKey(activeLang, currentWizardKey, true);
nextEnglishReferenceSet.add(currentWizardKey);
if (wizardMarkGlobal) {
addGlobalKey(currentWizardKey);
nextGlobalKeysSet.add(currentWizardKey);
} else {
removeGlobalKey(currentWizardKey);
nextGlobalKeysSet.delete(currentWizardKey);
}
nextEnglishReferenceKeysByLanguage[activeLang] = [...nextEnglishReferenceSet].sort((a, b) => a.localeCompare(b));
return {
nextTranslations,
preferencesSnapshot: {
globalKeys: [...nextGlobalKeysSet].sort((a, b) => a.localeCompare(b)),
englishReferenceKeysByLanguage: nextEnglishReferenceKeysByLanguage,
},
};
}
// Global keys intentionally reuse the source term and don't require a local override.
const nextValue = wizardMarkGlobal && trimmedWizardInput === englishValue
? ''
: trimmedWizardInput;
nextTranslations[activeLang][currentWizardKey] = nextValue;
handleChange(currentWizardKey, nextValue);
setEnglishReferenceKey(activeLang, currentWizardKey, false);
nextEnglishReferenceSet.delete(currentWizardKey);
if (wizardMarkGlobal) {
addGlobalKey(currentWizardKey);
nextGlobalKeysSet.add(currentWizardKey);
} else {
removeGlobalKey(currentWizardKey);
nextGlobalKeysSet.delete(currentWizardKey);
}
const normalizedEnglishReferenceKeysByLanguage = { ...nextEnglishReferenceKeysByLanguage };
const normalizedKeys = [...nextEnglishReferenceSet].sort((a, b) => a.localeCompare(b));
if (normalizedKeys.length === 0) {
delete normalizedEnglishReferenceKeysByLanguage[activeLang];
} else {
normalizedEnglishReferenceKeysByLanguage[activeLang] = normalizedKeys;
}
return {
nextTranslations,
preferencesSnapshot: {
globalKeys: [...nextGlobalKeysSet].sort((a, b) => a.localeCompare(b)),
englishReferenceKeysByLanguage: normalizedEnglishReferenceKeysByLanguage,
},
};
};
const openTranslationWizard = () => {
@ -349,13 +505,25 @@ export default function LanguageManagementPage() {
setShowTranslationWizard(true);
};
const goToNextWizardStep = () => {
saveCurrentWizardValue();
const goToNextWizardStep = async () => {
if (isWizardSavingStep) return;
setIsWizardSavingStep(true);
const saveSnapshot = saveCurrentWizardValue();
try {
if (saveSnapshot) {
// Persist both translation content and preferences on every wizard step.
await handleSave(saveSnapshot.nextTranslations);
await savePreferences(saveSnapshot.preferencesSnapshot);
}
if (wizardIndex >= wizardMissingKeys.length - 1) {
setShowTranslationWizard(false);
return;
}
setWizardIndex((prev) => prev + 1);
} finally {
setIsWizardSavingStep(false);
}
};
const skipWizardStep = () => {
@ -388,6 +556,29 @@ export default function LanguageManagementPage() {
languageManagementHeaderRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Show scroll-to-save hint whenever a namespace panel is opened and there are unsaved changes
// It stays visible until explicitly dismissed (button click) or save bar disappears
useEffect(() => {
if (!activeNamespacePanel || !isSaveBarRendered) {
setShowScrollHint(false);
return;
}
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current);
setShowScrollHint(true);
}, [activeNamespacePanel, isSaveBarRendered]);
// Hide scroll hint when the save bar scrolls into view
useEffect(() => {
const el = saveBarRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setShowScrollHint(false); },
{ threshold: 0.5 }
);
observer.observe(el);
return () => observer.disconnect();
}, [isSaveBarRendered]);
useEffect(() => {
if (!pendingAutoFixResult || !workspaceScan) return;
@ -429,9 +620,82 @@ export default function LanguageManagementPage() {
setReloadAfterScanClose(false);
};
const handleSaveAll = async () => {
const hadChangesToSave = isDirty || isPreferencesDirty;
console.debug('[LanguageManagement][SaveAll] start', {
isDirty,
isPreferencesDirty,
hasUnsavedChanges,
autoScrollOnSave,
});
if (isDirty) {
console.debug('[LanguageManagement][SaveAll] saving:translations');
await handleSave();
console.debug('[LanguageManagement][SaveAll] saved:translations');
}
if (isPreferencesDirty) {
console.debug('[LanguageManagement][SaveAll] saving:preferences');
const savedPreferences = await savePreferences();
console.debug('[LanguageManagement][SaveAll] saved:preferences', { savedPreferences });
}
if (autoScrollOnSave && hadChangesToSave) {
scrollToLanguageManagementHeader();
}
console.debug('[LanguageManagement][SaveAll] done');
};
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="mx-auto max-w-7xl px-4 py-8 space-y-6">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
<div className="mx-auto max-w-7xl px-4 py-8 space-y-5 sm:px-6 lg:px-8">
{showFetchingScreen ? (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="flex items-center gap-4">
<div className="relative h-10 w-10">
<span className="absolute inset-0 rounded-full border-2 border-slate-200" />
<span className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-slate-700" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{t('autofix.k78e1bf35')}</p>
<p className="text-xs text-slate-500">
I18N: {translationsLoadingPhase} | PREF: {preferencesLoadingPhase}
</p>
</div>
<div className="ml-auto text-sm font-semibold text-slate-700">{initialLoadingProgress}%</div>
</div>
<div className="mt-5 h-2 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-slate-600 transition-[width] duration-300"
style={{ width: `${Math.max(6, initialLoadingProgress)}%` }}
/>
</div>
{showDelayedFetchLogs && (
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-950/95 p-3 text-slate-100 shadow-inner">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold tracking-wide text-slate-200">{t('autofix.k1c7ec4f2')}</p>
<p className="text-[11px] text-slate-400">{t('autofix.k057b3dbd')}</p>
</div>
<div className="mt-2 h-40 overflow-y-auto rounded-lg bg-black/30 p-2 font-mono text-[11px] leading-5">
{combinedLoadingLogs.length === 0 ? (
<p className="text-slate-400">{t('autofix.k835d3cbf')}</p>
) : (
combinedLoadingLogs.map((line, idx) => (
<p key={`${line}-${idx}`} className="text-slate-200">{line}</p>
))
)}
</div>
</div>
)}
</div>
) : showWarmGap ? (
<div className="h-24" />
) : (
<>
<LanguageManagementTopSection
headerRef={languageManagementHeaderRef}
totalKeys={totalKeys}
@ -439,8 +703,8 @@ export default function LanguageManagementPage() {
isScanning={isScanning}
isAutoFixing={isAutoFixing}
onBackToAdmin={() => router.push('/admin')}
isDirty={isDirty}
onSave={handleSave}
isDirty={hasUnsavedChanges}
onSave={handleSaveAll}
saved={saved}
saveError={saveError}
allLanguages={allLanguages}
@ -468,6 +732,8 @@ export default function LanguageManagementPage() {
setSearch={setSearch}
autoScrollOnPanelOpen={autoScrollOnPanelOpen}
setAutoScrollOnPanelOpen={setAutoScrollOnPanelOpen}
autoScrollOnSave={autoScrollOnSave}
setAutoScrollOnSave={setAutoScrollOnSave}
newGlobalKeySelection={newGlobalKeySelection}
setNewGlobalKeySelection={setNewGlobalKeySelection}
availableGlobalKeyOptions={availableGlobalKeyOptions}
@ -478,6 +744,13 @@ export default function LanguageManagementPage() {
translations={data.translations}
handleChange={handleChange}
globalKeySet={globalKeySet}
englishReferenceKeySet={englishReferenceKeySetByLanguage[activeLang] ?? new Set<string>()}
setEnglishReferenceForKey={(key, enabled) => {
setEnglishReferenceKey(activeLang, key, enabled);
if (enabled) {
handleChange(key, getEnglishValue(key));
}
}}
filteredNs={filteredNs}
filteredGroups={filteredGroups}
activeNamespacePanel={activeNamespacePanel}
@ -488,22 +761,115 @@ export default function LanguageManagementPage() {
onBackToPanels={scrollToLanguageManagementHeader}
onOpenCategoryManager={() => setShowCategoryManagerModal(true)}
/>
</>
)}
{/* Sticky save bar */}
{isDirty && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<div className="flex items-center gap-3 rounded-xl bg-[#1C2B4A] text-white px-6 py-3 shadow-2xl">
<span className="text-sm">{t('autofix.kd63c8219')}</span>
</div>
</div>
{/* Scroll-to-top floating button */}
<button
onClick={handleSave}
className="rounded-md bg-white text-[#1C2B4A] px-4 py-1.5 text-sm font-semibold hover:bg-gray-100"
type="button"
aria-label={t('autofix.kb494ddd8')}
onClick={scrollToLanguageManagementHeader}
className="group fixed bottom-36 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/80 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.28)] backdrop-blur-md transition-all duration-300 ease-out hover:-translate-y-0.5 hover:scale-105 hover:bg-white hover:shadow-[0_12px_32px_-10px_rgba(15,23,42,0.36)]"
>
Save
<svg className="h-4 w-4 text-slate-700 transition-transform duration-300 ease-out group-hover:-translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
</svg>
</button>
{/* Scroll-to-save floating button */}
<button
type="button"
aria-label={t('autofix.k889cc3e3')}
onClick={() => {
if (saveBarRef.current) {
saveBarRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
} else {
const roots = [
document.scrollingElement,
document.documentElement,
document.body,
].filter(Boolean) as HTMLElement[];
const maxScrollTop = Math.max(
0,
...roots.map((root) => root.scrollHeight - window.innerHeight)
);
// Trigger scroll on all possible roots so it works regardless of which element owns scrolling.
roots.forEach((root) => {
root.scrollTo({ top: maxScrollTop, behavior: 'smooth' });
});
window.scrollTo({ top: maxScrollTop, behavior: 'smooth' });
}
setShowScrollHint(false);
}}
className="group fixed bottom-24 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/80 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.28)] backdrop-blur-md transition-all duration-300 ease-out hover:translate-y-0.5 hover:scale-105 hover:bg-white hover:shadow-[0_12px_32px_-10px_rgba(15,23,42,0.36)]"
>
<svg className="h-4 w-4 text-slate-700 transition-transform duration-300 ease-out group-hover:translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Scroll-to-save hint tooltip */}
{isHintRendered && (
<button
type="button"
aria-label={t('autofix.k0b27fdf8')}
onClick={() => {
saveBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
setShowScrollHint(false);
}}
className={`fixed bottom-[5.25rem] right-[4rem] z-50 transition-all duration-500 ${
isHintVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-3 pointer-events-none'
}`}
>
{/* outer pulsing ring */}
<span className="absolute inset-0 rounded-2xl animate-pulse bg-gradient-to-br from-sky-400/20 via-blue-300/15 to-indigo-400/20" />
<div className="relative flex items-center gap-2.5 rounded-2xl border border-blue-300/80 bg-gradient-to-br from-sky-50 via-blue-50 to-indigo-50 px-3.5 py-2.5 shadow-[0_8px_28px_-8px_rgba(37,99,235,0.35)] backdrop-blur-md">
{/* logo */}
<img
src="/images/logos/PP_Logo_BW_round.png"
alt={t('autofix.k788633d1')}
className="h-7 w-7 rounded-full border border-blue-200/70 shadow-sm shrink-0 object-cover"
/>
<div className="flex flex-col leading-tight">
<span className="text-[11px] font-bold text-blue-950 whitespace-nowrap">{t('autofix.k5188f06f')}</span>
<span className="text-[10px] font-medium text-blue-700 whitespace-nowrap">Click to scroll &amp; save </span>
</div>
{/* pulsing dot */}
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
{/* caret pointing right toward the chevron button */}
<span className="absolute -right-[5px] top-1/2 -translate-y-1/2 h-2.5 w-2.5 rotate-45 border-r border-t border-blue-300/80 bg-indigo-50" />
</div>
</button>
)}
{/* Glassy save bar */}
{isSaveBarRendered && (
<div
ref={saveBarRef}
className={`w-full border-t border-white/60 bg-white/70 backdrop-blur-md px-4 py-4 transition-opacity duration-300 ${
isSaveBarVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="mx-auto max-w-7xl flex items-center justify-between gap-4">
<span className="text-sm font-medium text-slate-700">{t('autofix.kd63c8219')}</span>
<button
onClick={handleSaveAll}
disabled={isSavingPreferences}
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition disabled:opacity-50"
>
{isSavingPreferences ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)}
</div>
<TranslationWizardModal
isOpen={showTranslationWizard}
@ -516,6 +882,8 @@ export default function LanguageManagementPage() {
setWizardInput={setWizardInput}
wizardMarkGlobal={wizardMarkGlobal}
setWizardMarkGlobal={setWizardMarkGlobal}
wizardUseEnglishReference={wizardUseEnglishReference}
setWizardUseEnglishReference={setWizardUseEnglishReference}
englishValue={currentWizardKey ? getEnglishValue(currentWizardKey) : ''}
addGlobalKey={addGlobalKey}
removeGlobalKey={removeGlobalKey}
@ -523,6 +891,7 @@ export default function LanguageManagementPage() {
onPrevious={goToPreviousWizardStep}
onSkip={skipWizardStep}
onNext={goToNextWizardStep}
isSavingStep={isWizardSavingStep}
/>
<AddLanguageModal
@ -546,7 +915,10 @@ export default function LanguageManagementPage() {
<CategoryManagerModal
isOpen={showCategoryManagerModal}
onClose={() => setShowCategoryManagerModal(false)}
onClose={() => {
setShowCategoryManagerModal(false);
if (isPreferencesDirty) void savePreferences();
}}
newCategoryLabel={newCategoryLabel}
setNewCategoryLabel={setNewCategoryLabel}
onCreateCategory={handleCreateCategory}

View File

@ -49,8 +49,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
} catch {}
}
const currentUser = useAuthStore.getState().user
const ok = isUserAdmin(currentUser)
let currentUser = useAuthStore.getState().user
let ok = isUserAdmin(currentUser)
if (currentUser && !ok) {
try {
console.log('🔐 AdminLayout: user present but not admin, revalidating via refresh')
await refreshAuthToken?.()
} catch {}
currentUser = useAuthStore.getState().user
ok = isUserAdmin(currentUser)
}
console.log('🔐 AdminLayout guard:resolved', {
hasUser: !!currentUser,

View File

@ -15,11 +15,21 @@ function resolveBackendBaseUrl(): string | null {
}
async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST' | 'PUT' | 'DELETE') {
console.info('[API][i18n/preferences] start', { method });
const access = await requireAdminSession(request);
if (!access.ok) return access.response;
if (!access.ok) {
console.warn('[API][i18n/preferences] denied:admin-session', { method });
return access.response;
}
const tokenResult = await fetchBackendAccessToken(request);
if (!tokenResult.ok || !tokenResult.accessToken) {
console.warn('[API][i18n/preferences] denied:access-token', {
method,
status: tokenResult.status,
message: tokenResult.message,
});
const denied = NextResponse.json(
{ ok: false, message: tokenResult.message ?? 'Unable to obtain backend access token.' },
{ status: tokenResult.status === 401 ? 401 : 403 }
@ -34,6 +44,7 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
const apiBase = resolveBackendBaseUrl();
if (!apiBase) {
console.error('[API][i18n/preferences] missing NEXT_PUBLIC_API_BASE_URL');
return NextResponse.json({ ok: false, message: 'Missing NEXT_PUBLIC_API_BASE_URL.' }, { status: 500 });
}
@ -46,13 +57,36 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
};
let body: string | undefined;
// Build the backend URL; for DELETE we forward the languageCode query param if present.
const incomingUrl = new URL(request.url);
const languageCode = incomingUrl.searchParams.get('languageCode');
const backendQuery = languageCode ? `?languageCode=${encodeURIComponent(languageCode)}` : '';
const backendUrl = `${apiBase}${backendPath}${backendQuery}`;
if (method !== 'GET' && method !== 'DELETE') {
const payload = await request.json().catch(() => ({}));
body = JSON.stringify(payload ?? {});
headers['Content-Type'] = 'application/json';
const categoriesCount = Array.isArray((payload as { categories?: unknown[] })?.categories)
? (payload as { categories?: unknown[] }).categories!.length
: 0;
const globalKeysCount = Array.isArray((payload as { globalKeys?: unknown[] })?.globalKeys)
? (payload as { globalKeys?: unknown[] }).globalKeys!.length
: 0;
console.info('[API][i18n/preferences] outgoing-payload', {
method,
categoriesCount,
globalKeysCount,
});
}
const backendResponse = await fetch(`${apiBase}${backendPath}`, {
if (method === 'DELETE' && languageCode) {
console.info('[API][i18n/preferences] delete-language', { languageCode });
}
const backendResponse = await fetch(backendUrl, {
method,
headers,
body,
@ -60,6 +94,10 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
}).catch(() => null);
if (!backendResponse) {
console.error('[API][i18n/preferences] backend unreachable', {
method,
backendPath,
});
const failed = NextResponse.json({ ok: false, message: 'Preferences backend is unreachable.' }, { status: 502 });
for (const setCookie of tokenResult.setCookies) {
failed.headers.append('set-cookie', setCookie);
@ -67,6 +105,12 @@ async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST'
return failed;
}
console.info('[API][i18n/preferences] backend-response', {
method,
status: backendResponse.status,
ok: backendResponse.ok,
});
const payload = await backendResponse.json().catch(() => null);
const out = NextResponse.json(payload ?? { ok: backendResponse.ok }, { status: backendResponse.status });

View File

@ -210,6 +210,9 @@ function isAutoFixCandidatePath(relPath: string): boolean {
if (!relPath.startsWith('src/app/')) return false;
if (relPath.startsWith('src/app/api/')) return false;
if (relPath.startsWith('src/app/i18n/')) return false;
// Portal component renders outside any React context provider — hooks like
// useTranslation must not be injected here by the autofix.
if (relPath.endsWith('components/toast/toastComponent.tsx')) return false;
return true;
}
@ -321,6 +324,130 @@ function ensureUseTranslation(
return { content: next, addedImport, addedHook };
}
function ensureUseTranslationHooksInComponents(content: string): { content: string; addedHooks: number } {
if (!/\bt\(\s*['"`][^'"`]+['"`]/.test(content)) {
return { content, addedHooks: 0 };
}
const componentPatterns = [
/export\s+default\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm,
/export\s+function\s+[A-Z]\w*\s*\([\s\S]*?\)\s*\{/gm,
/function\s+[A-Z]\w*\s*\([\s\S]*?\)\s*\{/gm,
/const\s+[A-Z]\w*\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
/const\s+[A-Z]\w*\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
];
const hookPrefixRegex = /^\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\);/;
const insertPositions = new Set<number>();
const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => {
let depth = 1;
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
const prev = index > 0 ? source[index - 1] : '';
const next = index + 1 < source.length ? source[index + 1] : '';
if (inLineComment) {
if (char === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (prev === '*' && char === '/') inBlockComment = false;
continue;
}
if (inSingleQuote) {
if (char === '\'' && prev !== '\\') inSingleQuote = false;
continue;
}
if (inDoubleQuote) {
if (char === '"' && prev !== '\\') inDoubleQuote = false;
continue;
}
if (inTemplate) {
if (char === '`' && prev !== '\\') inTemplate = false;
continue;
}
if (char === '/' && next === '/') {
inLineComment = true;
index += 1;
continue;
}
if (char === '/' && next === '*') {
inBlockComment = true;
index += 1;
continue;
}
if (char === '\'') {
inSingleQuote = true;
continue;
}
if (char === '"') {
inDoubleQuote = true;
continue;
}
if (char === '`') {
inTemplate = true;
continue;
}
if (char === '{') {
depth += 1;
continue;
}
if (char === '}') {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
return null;
};
for (const pattern of componentPatterns) {
for (const match of content.matchAll(pattern)) {
if (typeof match.index !== 'number') continue;
const bodyStart = match.index + match[0].length;
const bodyEnd = findFunctionBodyEnd(content, bodyStart);
if (bodyEnd === null) continue;
const bodyContent = content.slice(bodyStart, bodyEnd);
if (!/\bt\(\s*['"`][^'"`]+['"`]/.test(bodyContent)) continue;
const bodyPrefix = content.slice(bodyStart, bodyStart + 200);
if (hookPrefixRegex.test(bodyPrefix)) continue;
insertPositions.add(bodyStart);
}
}
if (insertPositions.size === 0) {
return { content, addedHooks: 0 };
}
let next = content;
const positions = Array.from(insertPositions).sort((a, b) => b - a);
for (const position of positions) {
next = `${next.slice(0, position)}\n const { t } = useTranslation();${next.slice(position)}`;
}
return { content: next, addedHooks: positions.length };
}
function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
const keyValueMap = new Map<string, string>();
let replacements = 0;
@ -509,8 +636,9 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
const textReplaced = replaceJsxTextNodes(ensured.content);
const attrReplaced = replaceJsxAttributeLiterals(textReplaced.content);
const ensuredHooks = ensureUseTranslationHooksInComponents(attrReplaced.content);
const totalReplacements = textReplaced.replacements + attrReplaced.replacements;
const afterLiteralCount = extractPotentialUiLiterals(attrReplaced.content).filter((text) => !shouldIgnoreLiteral(text)).length;
const afterLiteralCount = extractPotentialUiLiterals(ensuredHooks.content).filter((text) => !shouldIgnoreLiteral(text)).length;
if (totalReplacements === 0) {
const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).';
skippedFiles.push({ file: relPath, reason });
@ -533,13 +661,13 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
createdEntries.set(k, v);
}
if (attrReplaced.content !== raw) {
await fs.writeFile(absPath, attrReplaced.content, 'utf8');
if (ensuredHooks.content !== raw) {
await fs.writeFile(absPath, ensuredHooks.content, 'utf8');
changedFiles.push({
file: relPath,
replacements: totalReplacements,
addedImport: ensured.addedImport,
addedHook: ensured.addedHook,
addedHook: ensured.addedHook || ensuredHooks.addedHooks > 0,
});
debugEntries.push({
file: relPath,

View File

@ -4,12 +4,7 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { useTranslation } from '../i18n/useTranslation';
interface LangEntry { code: string; name: string; flag: string }
const FALLBACK_LANG_INFO: Record<string, { name: string; flag: string }> = {
en: { name: 'English', flag: '🇬🇧' },
de: { name: 'Deutsch', flag: '🇩🇪' },
};
interface LangEntry { code: string; name: string }
interface LanguageSwitcherProps {
variant?: 'light' | 'dark';
@ -21,40 +16,63 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
const allLangs: LangEntry[] = languages.map((lang) => ({
code: lang.code,
name: lang.name,
flag: FALLBACK_LANG_INFO[lang.code]?.flag ?? '🏳️',
}));
const activeLang: LangEntry =
allLangs.find((l) => l.code === language) ?? { code: language, name: language, flag: '🏳️' };
allLangs.find((l) => l.code === language) ?? { code: language, name: language };
const buttonCls =
variant === 'dark'
? 'inline-flex items-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors'
: 'inline-flex items-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200 transition-colors';
? 'group inline-flex min-w-[168px] items-center justify-between gap-3 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm font-semibold text-white shadow-sm backdrop-blur-sm transition hover:bg-white/15 data-[open]:bg-white/15 data-[open]:border-white/25'
: 'group inline-flex min-w-[168px] items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-900 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 data-[open]:bg-slate-50 data-[open]:border-slate-300';
const menuCls =
variant === 'dark'
? 'absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl bg-gray-800/95 backdrop-blur-sm border border-white/10 shadow-2xl py-1 transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in'
: 'absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl bg-white border border-gray-200 shadow-xl py-1 transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
? 'absolute right-0 z-50 mt-2.5 w-60 origin-top-right rounded-2xl border border-white/15 bg-slate-900/95 p-1.5 shadow-2xl backdrop-blur-md transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in'
: 'absolute right-0 z-50 mt-2.5 w-60 origin-top-right rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
const itemCls = (isActive: boolean) =>
variant === 'dark'
? `flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${isActive ? 'bg-[#8D6B1D] text-white' : 'text-gray-200 hover:bg-white/10 hover:text-white'}`
: `flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${isActive ? 'bg-[#8D6B1D] text-white' : 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}`;
? `flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition ${isActive ? 'bg-[#8D6B1D]/25 text-white ring-1 ring-[#8D6B1D]/50' : 'text-slate-200 hover:bg-white/10 hover:text-white'}`
: `flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition ${isActive ? 'bg-[#8D6B1D]/10 text-[#7A5E1A] ring-1 ring-[#8D6B1D]/30' : 'text-slate-700 hover:bg-slate-50 hover:text-slate-900'}`;
return (
<Menu as="div" className="relative inline-block">
<MenuButton className={buttonCls}>
<span>{activeLang.name}</span>
<ChevronDownIcon aria-hidden="true" className="size-4 opacity-60" />
<span className="inline-flex items-center gap-2 min-w-0">
<span className="truncate">{activeLang.name}</span>
<span
className={
variant === 'dark'
? 'rounded-md bg-white/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white/80'
: 'rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600'
}
>
{activeLang.code}
</span>
</span>
<ChevronDownIcon aria-hidden="true" className="size-4 shrink-0 opacity-70 transition group-data-[open]:rotate-180" />
</MenuButton>
<MenuItems transition className={menuCls}>
{allLangs.map((lang) => (
<MenuItem key={lang.code}>
<button onClick={() => setLanguage(lang.code)} className={itemCls(language === lang.code)}>
<span className="flex-1 text-left">{lang.name}</span>
{language === lang.code && <span className="text-xs font-bold"></span>}
<span className="flex-1 text-left truncate">{lang.name}</span>
<span
className={
variant === 'dark'
? 'rounded-md bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/75'
: 'rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500'
}
>
{lang.code}
</span>
{language === lang.code && (
<span className={variant === 'dark' ? 'text-[11px] font-bold text-amber-300' : 'text-[11px] font-bold text-[#8D6B1D]'}>
</span>
)}
</button>
</MenuItem>
))}

View File

@ -9,6 +9,22 @@ import Image from 'next/image';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// ── Global transition gate ─────────────────────────────────────────────────
export let isPageTransitioning = false
const transitionEndCallbacks = new Set<() => void>()
export function onPageTransitionEnd(cb: () => void) {
transitionEndCallbacks.add(cb)
}
export function startPageTransition() {
isPageTransitioning = true
}
function flushTransitionCallbacks() {
isPageTransitioning = false
for (const cb of transitionEndCallbacks) cb()
transitionEndCallbacks.clear()
}
// ─────────────────────────────────────────────────────────────────────────────
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation();
const pathname = usePathname();
@ -24,10 +40,14 @@ const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
// Exit overlay shortly after route change (200ms)
useEffect(() => {
isPageTransitioning = true
setShowOverlay(true);
setOverlayExit(false);
if (delayT.current) clearTimeout(delayT.current);
delayT.current = window.setTimeout(() => setOverlayExit(true), DELAY_MS);
delayT.current = window.setTimeout(() => {
setOverlayExit(true)
flushTransitionCallbacks()
}, DELAY_MS);
return () => {
if (delayT.current) clearTimeout(delayT.current);
};

View File

@ -23,6 +23,8 @@ import useAuthStore from '../../store/authStore'
import { Avatar } from '../avatar'
import LanguageSwitcher from '../LanguageSwitcher'
import { useTranslation } from '../../i18n/useTranslation'
import { useToast } from '../toast/toastComponent'
import { startPageTransition } from '../animation/pageTransitionEffect'
// ENV-BASED FEATURE FLAGS (string envs: treat "false" as off, everything else as on)
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
@ -58,6 +60,7 @@ interface HeaderProps {
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
const { t } = useTranslation()
const { showToast } = useToast()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [mounted, setMounted] = useState(false)
const [animateIn, setAnimateIn] = useState(false)
@ -103,10 +106,13 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
setGlobalLoggingOut?.(true)
await logout()
setMobileMenuOpen(false)
startPageTransition() // mark transition active BEFORE toast so it defers
showToast({ variant: 'success', title: t('nav.logout'), message: 'You have been logged out successfully.' })
router.push('/login')
} catch (err) {
console.error('Logout failed:', err)
setGlobalLoggingOut?.(false)
showToast({ variant: 'error', message: 'Logout failed. Please try again.' })
}
}

View File

@ -2,6 +2,8 @@
import React, {
createContext,
useCallback,
@ -11,6 +13,7 @@ import React, {
type ReactNode
} from 'react'
import { createRoot } from 'react-dom/client'
import { isPageTransitioning, onPageTransitionEnd } from '../animation/pageTransitionEffect'
type ToastVariant = 'success' | 'error' | 'info' | 'warning'
@ -84,6 +87,12 @@ function removeToastInternal(id: string) {
}
function addToast(options: ToastOptions) {
// Defer toast until page transition overlay has fully exited
if (typeof window !== 'undefined' && isPageTransitioning) {
onPageTransitionEnd(() => addToast(options))
return
}
const id = options.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
const toast: ToastInternal = {
id,
@ -215,8 +224,64 @@ interface ToastItemProps {
onClose: () => void
}
const TOAST_ICONS: Record<ToastVariant, React.ReactElement> = {
success: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
),
error: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
</svg>
),
info: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
),
warning: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
),
}
const TOAST_VARIANT_STYLES: Record<ToastVariant, { card: string; iconWrap: string; title: string; message: string; close: string }> = {
success: {
card: 'border border-emerald-200 border-l-4 border-l-emerald-400 bg-emerald-50/90 shadow-[0_8px_32px_-8px_rgba(5,150,105,0.18)]',
iconWrap: 'bg-emerald-100 text-emerald-600',
title: 'text-emerald-800',
message: 'text-emerald-900/80',
close: 'text-emerald-400 hover:bg-emerald-100 hover:text-emerald-700',
},
error: {
card: 'border border-red-200 border-l-4 border-l-red-400 bg-red-50/90 shadow-[0_8px_32px_-8px_rgba(220,38,38,0.18)]',
iconWrap: 'bg-red-100 text-red-600',
title: 'text-red-800',
message: 'text-red-900/80',
close: 'text-red-300 hover:bg-red-100 hover:text-red-700',
},
info: {
card: 'border border-sky-200 border-l-4 border-l-sky-400 bg-sky-50/90 shadow-[0_8px_32px_-8px_rgba(14,165,233,0.18)]',
iconWrap: 'bg-sky-100 text-sky-600',
title: 'text-sky-800',
message: 'text-sky-900/80',
close: 'text-sky-300 hover:bg-sky-100 hover:text-sky-700',
},
warning: {
card: 'border border-amber-200 border-l-4 border-l-amber-400 bg-amber-50/90 shadow-[0_8px_32px_-8px_rgba(217,119,6,0.18)]',
iconWrap: 'bg-amber-100 text-amber-600',
title: 'text-amber-800',
message: 'text-amber-900/80',
close: 'text-amber-300 hover:bg-amber-100 hover:text-amber-700',
},
}
function ToastItem({ toast, onClose }: ToastItemProps) {
const { title, message, variant } = toast
const v = variant ?? 'info'
const styles = TOAST_VARIANT_STYLES[v]
// local visible state for entry animation
const [visible, setVisible] = useState(false)
@ -226,53 +291,37 @@ function ToastItem({ toast, onClose }: ToastItemProps) {
return () => cancelAnimationFrame(frame)
}, [])
const variantClasses: Record<ToastVariant, string> = {
success: 'border-emerald-400/80 bg-emerald-900/90',
error: 'border-red-400/80 bg-red-900/90',
info: 'border-sky-400/80 bg-slate-900/90',
warning: 'border-amber-400/80 bg-amber-900/90'
}
const iconBg: Record<ToastVariant, string> = {
success: 'bg-emerald-500/10 text-emerald-300',
error: 'bg-red-500/10 text-red-300',
info: 'bg-sky-500/10 text-sky-300',
warning: 'bg-amber-500/10 text-amber-300'
}
const isClosing = !!toast.closing
const motionClasses = isClosing
? 'opacity-0 translate-y-2'
? 'opacity-0 translate-y-2 scale-95'
: visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2'
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-2 scale-95'
return (
<div
className={`
pointer-events-auto flex w-full items-start gap-3 rounded-xl border-l-4
px-4 py-3 text-sm text-slate-50 shadow-xl shadow-black/40
backdrop-blur-md transform transition-all duration-400
pointer-events-auto flex w-full items-start gap-3 rounded-2xl
px-4 py-3.5 backdrop-blur-md transform transition-all duration-400
${motionClasses}
${variantClasses[variant ?? 'info']}
${styles.card}
`}
>
<div className={`mt-0.5 flex h-8 w-8 items-center justify-center rounded-full ${iconBg[variant ?? 'info']}`}>
{/* Simple dot indicator */}
<span className="h-2 w-2 rounded-full bg-current" />
<div className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${styles.iconWrap}`}>
{TOAST_ICONS[v]}
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
{title && (
<div className="mb-0.5 text-xs font-semibold uppercase tracking-wide text-slate-200">
<div className={`mb-0.5 text-[11px] font-bold uppercase tracking-widest ${styles.title}`}>
{title}
</div>
)}
<div className="text-[13px] leading-snug text-slate-50">{message}</div>
<div className={`text-[13px] leading-snug ${styles.message}`}>{message}</div>
</div>
<button
type="button"
onClick={onClose}
className="ml-1 mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-slate-300 hover:bg-slate-800/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
className={`ml-1 mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-slate-300 ${styles.close}`}
aria-label="Close notification"
>
×

File diff suppressed because it is too large Load Diff

View File

@ -1109,6 +1109,8 @@ export const en: Translations = {
"k096f4013": "Manage your company stamps. One active at a time.",
"k0af6c6be": "Create & Activate",
"k0affa826": "Shown to users in the shop and checkout.",
"k0a50d234": " missing keys.",
"k0b27fdf8": "Scroll to save changes",
"k0b03e660": "2. Choose coffees & quantities",
"k0b2445d5": "Generating PDF preview…",
"k0bbc633d": "Loading contract preview…",
@ -1135,7 +1137,8 @@ export const en: Translations = {
"k41afd863": "Editing:",
"k4aeb8688": "2. Your selection",
"k4be6f631": "Save changes",
"k516705dd": "Ort ist erforderlich.",
"k5188f06f": "Unsaved changes!",
"k516705dd": "City is required.",
"k528eede9": "Same as shipping address",
"k56717603": "no image",
"k56a52520": "Skipped files",
@ -1148,7 +1151,8 @@ export const en: Translations = {
"k6a892262": "No keys match your search.",
"k6ee0a1b6": "Click or drag and drop an image here",
"k73d1d7d7": "Edit Crop",
"k74491338": "Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.",
"k7a515516": "Add language",
"k74491338": "Reverse Charge active: valid UID and foreign invoice country detected.",
"k7775eddb": "Your Company Stamps",
"k788633d1": "Profit Planet",
"k7a3a6ea3": "to render invoice line items.",
@ -1165,6 +1169,7 @@ export const en: Translations = {
"k91052e3f": "Translation calls",
"k92639a9a": "Language code",
"k926966d0": "Language name",
"k889cc3e3": "Scroll to save changes",
"k96839795": "Back to selection",
"k99bffb65": "Fill all fields to proceed.",
"k9b173204": "Files auto-fixed",
@ -1175,6 +1180,7 @@ export const en: Translations = {
"ka802064d": "Applying i18n auto-fixes to client components and updating translation files...",
"kaa30f0cd": "Create Coffee",
"kaa8bbc8e": "Company Information",
"kac6aab53": "Saved",
"kac6cedc7": "Saving…",
"kae63e46a": "Missing translation keys detected in workspace",
"kb06fa395": "Edit Coffee",
@ -1184,6 +1190,7 @@ export const en: Translations = {
"kb791958e": "Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.",
"kb8f33873": "Translation progress",
"kb9e483c4": "Update details of the coffee.",
"k644d9ea8": "Revert override",
"kba6bd6f3": "or click to browse",
"kcc4adbcc": "Navigation shortcuts",
"kce094582": "Invoice address",
@ -1204,7 +1211,7 @@ export const en: Translations = {
"ke7f0a9e3": "FREE SHIPPING",
"kea7cde7a": "Back to Admin",
"kec078e54": "No coffees selected yet.",
"kefe5f0dd": "Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.",
"kefe5f0dd": "Without a valid UID, the invoice will be created with standard VAT.",
"kf1a9384b": "Auto-applied to documents where applicable.",
"kf4e45236": "Add Language",
"kf72d41db": "Add a new coffee.",
@ -1212,7 +1219,7 @@ export const en: Translations = {
"kfeac3f7e": "Choose file",
"k0c51fa85": "Activate template now?",
"k134e3932": "Active stamp",
"k1f0b2c48": "z.B. Wien",
"k1f0b2c48": "e.g. Vienna",
"k2fac9ff2": "Template name",
"k3477c83a": "Describe the product",
"k35ac864e": "Search templates…",
@ -1220,7 +1227,7 @@ export const en: Translations = {
"kaa5e5363": "ABO Contract PDF Preview",
"kcb65c692": "e.g., Company Seal 2025",
"kd9e4bcbd": "Contract Preview",
"kf1512f8f": "z.B. SI12345678",
"kf1512f8f": "e.g. SI12345678",
"k00016501": "🧪 Token Refresh Test",
"k002455d8": "Total Gross / Brutto",
"k00394342": "Welcome back! Log in to continue.",
@ -1244,7 +1251,7 @@ export const en: Translations = {
"k0c838ec3": "Min €",
"k0c87d75d": "Max €",
"k0c95a1b4": "Back:",
"k0cc2a3ba": "Versuche andere Suchbegriffe oder Filter",
"k0cc2a3ba": "Try different search terms or filters",
"k0cdde8f8": "Name:",
"k0d6626e3": "👤 User Info",
"k0d8cb427": "Type:",
@ -1256,7 +1263,7 @@ export const en: Translations = {
"k0efd830c": "Verification Readiness",
"k0f0395ca": "Multi-statement SQL and dump files are supported. Use with caution.",
"k0f1fc266": "All Statuses",
"k0fbaa1a9": "Jetzt registrieren",
"k0fbaa1a9": "Register now",
"k0fe28e0b": "Affiliate Management",
"k10ccb626": "All Users",
"k10e2568f": "All Types",
@ -1273,7 +1280,7 @@ export const en: Translations = {
"k16b60f69": "View All",
"k17ba59ff": "Community Hands - Profit Planet",
"k17f65c37": "Example: /shop or https://example.com",
"k1882bd75": "Max Mustermann",
"k1882bd75": "John Doe",
"k199db5f1": "your.email@example.com",
"k19f2c5dc": "No affiliates found",
"k1a1ca621": "e.g. DE89 3704 0044 0532 0130 00",
@ -1291,7 +1298,7 @@ export const en: Translations = {
"k209ba561": "Create New Pool",
"k20ab2fc7": "We'll send a verification code to your email address.",
"k21440f8a": "Pool Management",
"k21db276a": "Auf Lager",
"k21db276a": "In Stock",
"k228929e2": "Profile Information",
"k23c9f0ff": "No results yet. Import a SQL dump to see output.",
"k258c3515": "892 members",
@ -1300,7 +1307,7 @@ export const en: Translations = {
"k2786bc5f": "Signing in...",
"k27e93fd7": "Stay informed with our latest announcements and insights",
"k27f56959": "State change will affect add/remove operations.",
"k290e3aab": "tt.mm jjjj",
"k290e3aab": "dd.mm.yyyy",
"k2a2fe15a": "Phone Number",
"k2a37c394": "Brief description of the affiliate partner...",
"k2af2916f": "Your account is fully submitted. Our team will verify your account shortly.",
@ -1343,14 +1350,14 @@ export const en: Translations = {
"k483aa95a": "• Share authentic experiences",
"k48852b8d": "Customer Email",
"k49568342": "Manage your affiliate partners and tracking links",
"k4968eb2a": "Abonement:",
"k4968eb2a": "Subscription:",
"k49f254bd": "Current URL:",
"k4a055849": "ID Front",
"k4a9e1ebe": "Loading user details...",
"k4b6c7681": "Open subscriptions",
"k4c5e8e87": "Export CSV",
"k4c5ecd73": "Export PDF",
"k4cb62cff": "Keine Produkte gefunden",
"k4cb62cff": "No products found",
"k4db68c96": "SQL Import",
"k4e0c889b": "Not Ready",
"k4e168c01": "Coffee Abonnements",
@ -1364,9 +1371,9 @@ export const en: Translations = {
"k533db977": "Your new password",
"k54c06343": "Refresh Token",
"k54f49724": "No users match your search.",
"k55aba973": "Produkte gefunden",
"k55aba973": "Products found",
"k5614c806": "Review and verify all users who need admin approval. Users must complete all steps before verification.",
"k56435c9b": "Verfügbarkeit",
"k56435c9b": "Availability",
"k5738c039": "Matrix created successfully.",
"k577a012c": "User Type",
"k578dcc0b": "PNG, JPG, WebP, SVG up to 5MB",
@ -1379,19 +1386,19 @@ export const en: Translations = {
"k5c598bc0": "Trending Groups",
"k5d4d494e": "Loading members...",
"k5d85b354": "Driver's License",
"k5e580e3f": "Filter zurücksetzen",
"k5e580e3f": "Reset filters",
"k5ef19112": "Join our team",
"k5f74c123": "Last 30 days",
"k5fb70267": "Shop wird geladen...",
"k5fb70267": "Loading shop...",
"k5fbf1824": "Masked names for deeper descendants.",
"k61c2a732": "Angemeldet bleiben",
"k61c2a732": "Stay logged in",
"k61f6cd4e": "Token Preview:",
"k6285753a": "Back to Pool Management",
"k62bc3c59": "e.g. Berlin",
"k62d12fab": "Error loading data",
"k63115bb4": "ID Documents",
"k633438a0": "Discover our trusted partners and earn commissions through affiliate links.",
"k63458f03": "Produkte durchsuchen...",
"k63458f03": "Browse products...",
"k65b67dc3": "Back to matrices",
"k65e33378": "Total users fetched",
"k661c032b": "You need admin privileges to access this page.",
@ -1407,7 +1414,7 @@ export const en: Translations = {
"k6aa2d843": "Read full guidelines",
"k6af9037b": "Open navigation",
"k6b0f4f70": "ID documents or a signed contract are missing for this user. The users verification status should be checked.",
"k6b76bd0e": "Willkommen bei Profit Planet",
"k6b76bd0e": "Welcome to Profit Planet",
"k6c6e5c0f": "Use with caution",
"k6ca85cda": "Trending right now",
"k6d85810b": "Your password",
@ -1451,7 +1458,7 @@ export const en: Translations = {
"k81a1b900": "Loading settings…",
"k81b056f2": "See our job postings",
"k81c0b74b": "Status:",
"k81c7c2f2": "Musterstraße 1",
"k81c7c2f2": "Sample Street 1",
"k8323a7d9": "Loading:",
"k832a032b": "Search affiliates...",
"k8358f1d1": "Loading folder issues...",
@ -1463,7 +1470,7 @@ export const en: Translations = {
"k86aa4f9c": "Current Month",
"k87e4b9a2": "Core Pool",
"k883ea8c5": "Loading ghost directories...",
"k88d8bb9d": "Passwort vergessen?",
"k88d8bb9d": "Forgot password?",
"k890ff52f": "e.g., Coffee Equipment Co.",
"k8a35cc53": "SQL dumps run immediately and can modify production data.",
"k8a59b156": "Import SQL",
@ -1486,7 +1493,7 @@ export const en: Translations = {
"k91eb415a": "ProfitPlanet Logo",
"k91f24187": "Complete Profile",
"k9213db6e": "📋 Testing Instructions",
"k93165aea": "12345 Berlin",
"k93165aea": "12345 London",
"k93b6dc1b": "Uploaded Documents",
"k93f03bca": "Signed Contract Document",
"k941fd092": "Last Folder Structure Action",
@ -1506,7 +1513,7 @@ export const en: Translations = {
"k9c3db145": "Start Discussion",
"k9d0c063d": "Password saved. Redirecting to login...",
"k9e609523": "No missing folders found. Run Refresh to scan again.",
"k9f29dbfb": "Entdecke nachhaltige Produkte und verdiene dabei. Deine Plattform für bewussten Konsum und finanzielle Vorteile.",
"k9f29dbfb": "Discover sustainable products and earn rewards. Your platform for conscious consumption and financial benefits.",
"k9f56d4ac": "e.g. +43 676 1234567",
"ka00fc5db": "Manage your account information and preferences",
"ka15f5ec5": "Auth Store State",
@ -1561,7 +1568,7 @@ export const en: Translations = {
"kbce9fbea": "No platforms configured.",
"kbd8b3364": "Sign Contract",
"kbd979e13": "We are a community",
"kbdb02e32": "Keine Rechnungen gefunden.",
"kbdb02e32": "No invoices found.",
"kbe9355f8": "Business License",
"kbf4b7789": "You are already logged in. Redirecting...",
"kbf7bde57": "Select any subscription to view details and included items.",
@ -1588,17 +1595,17 @@ export const en: Translations = {
"kccc13f16": "← Go back",
"kccde6d86": "User Verification Center",
"kccf7593a": "• Be respectful and kind",
"kcd7a1625": "deine@email.com",
"kcd7a1625": "your@email.com",
"kcd9890e5": "PNG, JPG, WebP up to 5MB",
"kcdfef775": "Loading subscriptions…",
"kce0ab46c": "Dein Passwort",
"kce0ab46c": "Your password",
"kcf4ba87d": "Crop Affiliate Logo",
"kcf61fc9e": "Last Loose Files Action",
"kd00443f2": "Go to Dashboard",
"kd04a7c59": "Matrix Name",
"kd058bb7b": "Missing:",
"kd09be3cd": "Matrix Management",
"kd1c17b3f": "Alle Marken",
"kd1c17b3f": "All Brands",
"kd1f35ccf": "Search & Filter Users",
"kd2e35b08": "Rows per page",
"kd2e5e813": "• Already booked:",
@ -1614,7 +1621,7 @@ export const en: Translations = {
"kd5cca6e9": "? This action cannot be undone.",
"kd6024811": "PDF File",
"kd642e230": "Search by name or email. Minimum 3 characters. Existing matrix members are hidden.",
"kd68da70d": "Nachhaltige Produkte für deinen Erfolg",
"kd68da70d": "Sustainable products for your success",
"kd89474fa": "Back to News",
"kda96f5b3": "Matrix Depth",
"kdb27a82d": " Previous",
@ -1633,7 +1640,7 @@ export const en: Translations = {
"ke4c4a858": "Min. 3 characters",
"ke697b8cb": "Set Active",
"ke8b9f33c": "Total in Pool",
"ke9e71971": "Oder weiter mit",
"ke9e71971": "Or continue with",
"kebf33594": "Filter by category:",
"kec5a5357": "Upload Invoice",
"keccee79f": "Email address",
@ -1655,7 +1662,7 @@ export const en: Translations = {
"kf3b81ba3": "Used in the URL. Auto-generated from title unless edited.",
"kf4868273": "Click to upload",
"kf4f44e2f": "e.g. ATU12345678",
"kf530c357": "Anmeldung läuft...",
"kf530c357": "Signing in...",
"kf663ef67": "Shop with an infinite variety of products",
"kf69154f8": "• Stay on topic",
"kf70b9896": "e.g. 12345",
@ -1682,23 +1689,30 @@ export const en: Translations = {
"k4bfb4f28": "Feature comparison",
"k4c6eb72c": "Select all",
"k4f209a66": "You currently dont have an active subscription.",
"k511d7fab": "keys scanned from the English source file.",
"k5b7042c7": "Previous page",
"k60b1e339": "No media or documents found.",
"k6569783c": "Use this to include server-style files. Files with server-only Next.js APIs are skipped for safety.",
"k68c88f41": "Force convert selected files to client components before auto-fix",
"k74914369": "Delete Item",
"k7227f13d": "Manage UI translations. All",
"k772cc77b": "Complete your profile to unlock all features",
"k7fa55432": "My Subscription",
"k86b03343": "Billed annually",
"k8953de89": "Finance & Invoices",
"k947d8777": "Invoice #",
"k9863fa5": "Scan & review fixes",
"ka5603827": "Loading invoices…",
"ka6cf3286": "languages",
"ka8c928ac": "Admin",
"ka86bdc9b": "Payment frequency",
"kb3243742": "No file",
"kc48b877b": "No subscription selected. Invoices will appear once you have an active subscription.",
"kd08b698a": "Profile Completion",
"kdcc78d97": "Fully translated",
"ke3480838": "No fixable hardcoded UI text detected in eligible components.",
"ked7d533b": "Media & Documents",
"kf191f6df5": "Scanning…",
"kf5ac16fb": "Pricing that grows with you",
"kf9f94d5e": "Buy this plan",
"kfd632d02": "Export all invoices",
@ -1706,9 +1720,13 @@ export const en: Translations = {
"k9dafde30": "Contact Person",
"kada9d61c": "Account Holder",
"kde6d477f": "Email Address",
"k20eb1f87": "language",
"k33f55455": "keys",
"k3931709b": "translation keys",
"kfc6b6a29": "Editing disabled",
"k03538639": "e.g. fr, es, zh-TW",
"k5fcc9b0e": "Delete language",
"k571ffd91": "missing",
"k9bd0812b": "Shows why a file was changed, skipped, or left untouched after a fix attempt.",
"ka019b3c0": "e.g. Français",
"kbe30c353": "Coverage by namespace",
@ -1738,6 +1756,7 @@ export const en: Translations = {
"kc518ff5c": "English reference",
"kcd190bdd": "Translation wizard",
"kfd1e0089": "Auto-scroll on panel open",
"k23e95df1": "Auto-scroll on save",
"k429d94bf": "Organized by template family",
"k61d66984": "Grouped library",
"k66b39536": "Template overview",
@ -1747,7 +1766,16 @@ export const en: Translations = {
"k8351e02f": "Admin workspace",
"kccff045c": "Faster edit flow",
"kf962066f": "Contract type",
"kb1cf599b": "Counts as global key"
"kb1cf599b": "Counts as global key",
"k1c7ec4f2": "Admin Fetch Log",
"k057b3dbd": "visible for slow fetches",
"k6d79b1df": "Use English value",
"k78e1bf35": "Fetching language data...",
"k77d01d6a": "Open ▾",
"k835d3cbf": "Waiting for first bootstrap event...",
"k8de6d3df": "EN ref",
"kb494ddd8": "Scroll to top",
"kcfb5fb54": "Click to open"
},
"toasts": {
"loginSuccess": "Login successful",

View File

@ -21,10 +21,10 @@ import { editProfileBasic } from './hooks/editProfile'
import { authFetch } from '../utils/authFetch'
// Helper to display missing fields in subtle gray italic (no yellow highlight)
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
function HighlightIfMissing({ value, children, missingLabel }: { value: any, children: React.ReactNode, missingLabel?: React.ReactNode }) {
if (value === null || value === undefined || value === '') {
return (
<span className="italic text-gray-400">{t('autofix.kf2147f07')}</span>
<span className="italic text-gray-400">{missingLabel ?? 'Not provided'}</span>
);
}
return <>{children}</>;
@ -98,6 +98,11 @@ export default function ProfilePage() {
const [editModalError, setEditModalError] = React.useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = React.useState(false)
const [downloadError, setDownloadError] = React.useState<string | null>(null)
const HighlightIfMissingWithText: React.FC<{ value: any, children: React.ReactNode }> = ({ value, children }) => (
<HighlightIfMissing value={value} missingLabel={t('autofix.kf2147f07')}>
{children}
</HighlightIfMissing>
)
useEffect(() => { setHasHydrated(true) }, [])
@ -347,7 +352,7 @@ export default function ProfilePage() {
<div className="lg:col-span-2 space-y-6">
<BasicInformation
profileData={profileData}
HighlightIfMissing={HighlightIfMissing}
HighlightIfMissing={HighlightIfMissingWithText}
// Add edit button handler
onEdit={() => openEditModal('basic', {
firstName: profileData.firstName,

View File

@ -62,7 +62,7 @@ const init: CompanyProfileData = {
function ModernSelect({
label,
placeholder={t('autofix.ka5bf342b')},
placeholder,
value,
onChange,
options,
@ -73,10 +73,12 @@ function ModernSelect({
onChange: (next: string) => void
options: { value: string; label: string }[]
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const btnRef = useRef<HTMLButtonElement | null>(null)
const [pos, setPos] = useState({ left: 16, top: 0, width: 320 })
const resolvedPlaceholder = placeholder ?? t('autofix.ka5bf342b')
const selected = useMemo(() => options.find(o => o.value === value) || null, [options, value])
const filtered = useMemo(() => {
@ -129,7 +131,7 @@ function ModernSelect({
aria-expanded={open}
>
<span className={selected ? 'text-gray-900' : 'text-gray-500'}>
{selected ? selected.label : placeholder}
{selected ? selected.label : resolvedPlaceholder}
</span>
<ChevronDownIcon className={`h-5 w-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>

View File

@ -80,7 +80,7 @@ type SelectOption = { value: string; label: string }
function ModernSelect({
label,
placeholder={t('autofix.ka5bf342b')},
placeholder,
searchPlaceholder = 'Search…',
noResults = 'No results',
value,
@ -95,10 +95,12 @@ function ModernSelect({
onChange: (next: string) => void
options: SelectOption[]
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const btnRef = useRef<HTMLButtonElement | null>(null)
const [pos, setPos] = useState({ left: 16, top: 0, width: 320 })
const resolvedPlaceholder = placeholder ?? t('autofix.ka5bf342b')
const selected = useMemo(
() => options.find(o => o.value === value) || null,
@ -162,7 +164,7 @@ function ModernSelect({
aria-expanded={open}
>
<span className={selected ? 'text-gray-900' : 'text-gray-500'}>
{selected ? selected.label : placeholder}
{selected ? selected.label : resolvedPlaceholder}
</span>
<ChevronDownIcon className={`h-5 w-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>

View File

@ -66,7 +66,7 @@ interface AuthStore {
setUser: (userData: User | null) => void;
clearAuth: () => void;
logout: () => Promise<void>;
refreshAuthToken: () => Promise<boolean | null>;
refreshAuthToken: (forceRefresh?: boolean) => Promise<boolean | null>;
getAuthState: () => AuthStore;
}
@ -165,16 +165,16 @@ const useAuthStore = create<AuthStore>((set, get) => ({
}
},
refreshAuthToken: async () => {
refreshAuthToken: async (forceRefresh = false) => {
// If there's already a refresh in flight, return that promise
if (get().refreshPromise) {
log("🔁 Zustand: refreshAuthToken - returning existing refresh promise");
return get().refreshPromise;
}
// SHORT-CIRCUIT: if we already have a valid accessToken that's not about to expire, skip refresh
// SHORT-CIRCUIT: skip refresh when token is still valid unless caller explicitly forces refresh.
const currentToken = get().accessToken;
if (currentToken) {
if (!forceRefresh && currentToken) {
const expiry = getTokenExpiry(currentToken);
if (expiry && expiry.getTime() - Date.now() > 60 * 1000) { // more than 60s left
log("⏸️ Zustand: accessToken present and valid, skipping refresh");
@ -203,7 +203,14 @@ const useAuthStore = create<AuthStore>((set, get) => ({
if (res.ok && body && body.accessToken) {
log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
get().setAccessToken(body.accessToken);
if (body.user) get().setUser(body.user);
if (body.user) {
const existingUser = get().user;
const mergedUser =
existingUser && typeof existingUser === 'object' && body.user && typeof body.user === 'object'
? { ...existingUser, ...body.user }
: body.user;
get().setUser(mergedUser);
}
// Log token expiry for debugging
const newExpiry = getTokenExpiry(body.accessToken);

View File

@ -44,6 +44,38 @@ interface CustomRequestInit extends RequestInit {
headers?: Record<string, string>;
}
let logoutInFlight: Promise<void> | null = null;
let lastLogoutAt = 0;
const LOGOUT_DEBOUNCE_MS = 1500;
async function runLogoutOnce(): Promise<void> {
if (logoutInFlight) {
return logoutInFlight;
}
const now = Date.now();
if (now - lastLogoutAt < LOGOUT_DEBOUNCE_MS) {
log("⏭️ authFetch: Skipping duplicate logout attempt in debounce window");
return Promise.resolve();
}
if (!logoutInFlight) {
lastLogoutAt = now;
logoutInFlight = useAuthStore
.getState()
.logout()
.catch((e) => {
log("❌ authFetch: logout error:", e);
useAuthStore.getState().clearAuth();
})
.finally(() => {
logoutInFlight = null;
});
}
return logoutInFlight;
}
// Helper: safe stringify body for logging
function safeBodyPreview(body: any, max = 500): string | null {
if (body == null) return null;
@ -97,10 +129,10 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
// If unauthorized, try to refresh token and retry once
if (res.status === 401) {
log("🔄 authFetch: 401 Unauthorized received. Attempting store.refreshAuthToken()...");
log("🔄 authFetch: 401 Unauthorized received. Attempting forced store.refreshAuthToken()...");
try {
// call centralized, deduped refresh in store
const refreshOk = await useAuthStore.getState().refreshAuthToken();
// call centralized, deduped refresh in store (forced on 401)
const refreshOk = await useAuthStore.getState().refreshAuthToken(true);
log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk);
if (refreshOk) {
@ -117,18 +149,12 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
log("📡 authFetch: Retry response status:", res.status);
} else {
log("❌ authFetch: Refresh failed. Calling logout to revoke server cookie and clear client state");
await useAuthStore.getState().logout().catch((e) => {
log("❌ authFetch: logout error:", e);
useAuthStore.getState().clearAuth();
});
log("❌ authFetch: Refresh failed. Running deduplicated logout flow");
await runLogoutOnce();
}
} catch (err) {
log("❌ authFetch: Error while refreshing token:", err);
await useAuthStore.getState().logout().catch((e) => {
log("❌ authFetch: logout error after refresh exception:", e);
useAuthStore.getState().clearAuth();
});
await runLogoutOnce();
throw err;
}
}

View File

@ -0,0 +1,275 @@
/**
* Smoke tests: token refresh logic in authFetch + authStore
*
* Scenarios covered:
* 1. Valid token present request goes through, no refresh attempted
* 2. No token, first request gets 401, refresh succeeds retry with new token succeeds
* 3. No token, first request gets 401, refresh fails logout called, 401 returned
* 4. Concurrent 401s trigger only ONE refresh call (deduplication)
* 5. Short-circuit: valid non-expired token in store refreshAuthToken returns true without hitting /api/refresh
*/
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
// ── Silence logger so test output stays clean ─────────────────────────────────
vi.mock('@/app/utils/logger', () => ({ log: vi.fn() }));
// ── Build a deterministic JWT with a given exp (seconds from now) ─────────────
function makeJwt(secondsFromNow: number): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = btoa(
JSON.stringify({ sub: 'test-user', exp: Math.floor(Date.now() / 1000) + secondsFromNow })
);
return `${header}.${payload}.sig`;
}
const FRESH_TOKEN = makeJwt(3600); // 1 hour from now
const NEW_TOKEN = makeJwt(3600); // token returned after refresh
const STALE_TOKEN = makeJwt(-60); // already expired
// ── Minimal sessionStorage shim (jsdom already provides one but let's be explicit) ──
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
sessionStorage.clear();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
// We re-import authStore fresh per test so Zustand state is reset.
async function freshStore() {
// Vitest module cache survives between tests in the same file.
// Reset by clearing sessionStorage (store reads it on init) and then
// re-importing the module with a cache-bust isn't straightforward,
// so we directly manipulate state after import.
const mod = await import('@/app/store/authStore');
const store = mod.default;
store.getState().clearAuth();
return store;
}
// ─────────────────────────────────────────────────────────────────────────────
describe('authStore.refreshAuthToken()', () => {
it('short-circuits when a valid non-expired token is already in state', async () => {
const store = await freshStore();
store.getState().setAccessToken(FRESH_TOKEN);
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const result = await store.getState().refreshAuthToken();
expect(result).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('calls /api/refresh and sets new token on success', async () => {
const store = await freshStore();
// No token in store → refresh should proceed
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
makeResponse(200, { accessToken: NEW_TOKEN, user: { email: 'test@example.com' } })
);
const result = await store.getState().refreshAuthToken();
expect(result).toBe(true);
expect(store.getState().accessToken).toBe(NEW_TOKEN);
expect(store.getState().user?.email).toBe('test@example.com');
});
it('returns false and clears auth when /api/refresh returns non-ok', async () => {
const store = await freshStore();
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
makeResponse(401, { message: 'Refresh token expired' })
);
const result = await store.getState().refreshAuthToken();
expect(result).toBe(false);
expect(store.getState().accessToken).toBeNull();
expect(store.getState().user).toBeNull();
});
it('deduplicates concurrent refresh calls — fetch called exactly once', async () => {
const store = await freshStore();
let resolveRefresh!: (v: Response) => void;
const refreshPending = new Promise<Response>((res) => { resolveRefresh = res; });
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockReturnValueOnce(refreshPending);
// Fire three concurrent refreshes
const p1 = store.getState().refreshAuthToken();
const p2 = store.getState().refreshAuthToken();
const p3 = store.getState().refreshAuthToken();
// Resolve the single in-flight fetch
resolveRefresh(makeResponse(200, { accessToken: NEW_TOKEN }));
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
expect(r1).toBe(true);
expect(r2).toBe(true);
expect(r3).toBe(true);
// fetch should only have been called once for /api/refresh
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(store.getState().accessToken).toBe(NEW_TOKEN);
});
it('clears isRefreshing and refreshPromise after completion', async () => {
const store = await freshStore();
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
makeResponse(200, { accessToken: NEW_TOKEN })
);
await store.getState().refreshAuthToken();
expect(store.getState().isRefreshing).toBe(false);
expect(store.getState().refreshPromise).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
describe('authFetch() token refresh flow', () => {
it('attaches Bearer token and succeeds on first try when token is fresh', async () => {
const store = await freshStore();
store.getState().setAccessToken(FRESH_TOKEN);
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
makeResponse(200, { ok: true, data: 'hello' })
);
const { authFetch } = await import('@/app/utils/authFetch');
const res = await authFetch('/api/some-endpoint');
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const callHeaders = (fetchSpy.mock.calls[0][1] as RequestInit)
.headers as Record<string, string>;
expect(callHeaders['Authorization']).toBe(`Bearer ${FRESH_TOKEN}`);
});
it('retries with new token after 401 + successful refresh', async () => {
const store = await freshStore();
// No access token → first request will get 401
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
// 1st call: the protected endpoint → 401
.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' }))
// 2nd call: /api/refresh → success
.mockResolvedValueOnce(makeResponse(200, { accessToken: NEW_TOKEN }))
// 3rd call: retry of protected endpoint with new token → 200
.mockResolvedValueOnce(makeResponse(200, { ok: true }));
const { authFetch } = await import('@/app/utils/authFetch');
const res = await authFetch('/api/protected');
expect(res.status).toBe(200);
// 3 total fetch calls: original + refresh + retry
expect(fetchSpy).toHaveBeenCalledTimes(3);
const retryHeaders = (fetchSpy.mock.calls[2][1] as RequestInit)
.headers as Record<string, string>;
expect(retryHeaders['Authorization']).toBe(`Bearer ${NEW_TOKEN}`);
});
it('returns the 401 response and calls logout when refresh fails', async () => {
const store = await freshStore();
const logoutSpy = vi.spyOn(store.getState(), 'logout').mockResolvedValueOnce(undefined);
vi.spyOn(globalThis, 'fetch')
// Original request → 401
.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' }))
// /api/refresh → fails
.mockResolvedValueOnce(makeResponse(401, { message: 'Refresh token expired' }));
const { authFetch } = await import('@/app/utils/authFetch');
const res = await authFetch('/api/protected');
expect(res.status).toBe(401);
expect(logoutSpy).toHaveBeenCalledTimes(1);
});
it('deduplicates logout when multiple concurrent requests fail refresh', async () => {
const store = await freshStore();
const logoutSpy = vi.spyOn(store.getState(), 'logout').mockResolvedValue(undefined);
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === '/api/refresh') {
return makeResponse(401, { message: 'Refresh token expired' });
}
return makeResponse(401, { message: 'Unauthorized' });
});
const { authFetch } = await import('@/app/utils/authFetch');
const [res1, res2] = await Promise.all([
authFetch('/api/protected-a'),
authFetch('/api/protected-b'),
]);
expect(res1.status).toBe(401);
expect(res2.status).toBe(401);
expect(logoutSpy).toHaveBeenCalledTimes(1);
const refreshCalls = fetchSpy.mock.calls.filter(([input]) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
return url === '/api/refresh';
});
expect(refreshCalls).toHaveLength(1);
});
it('does NOT call /api/refresh when token is fresh (no 401 response)', async () => {
const store = await freshStore();
store.getState().setAccessToken(FRESH_TOKEN);
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
makeResponse(200, { ok: true })
);
const { authFetch } = await import('@/app/utils/authFetch');
await authFetch('/api/something');
// Only 1 call — to the endpoint, not to /api/refresh
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe('/api/something');
});
it('forces /api/refresh on 401 even when access token is not expired', async () => {
const store = await freshStore();
store.getState().setAccessToken(FRESH_TOKEN);
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
// 1st call: protected endpoint with stale/invalid server-side token -> 401
.mockResolvedValueOnce(makeResponse(401, { message: 'Unauthorized' }))
// 2nd call: forced refresh endpoint
.mockResolvedValueOnce(makeResponse(200, { accessToken: NEW_TOKEN }))
// 3rd call: retry succeeds
.mockResolvedValueOnce(makeResponse(200, { ok: true }));
const { authFetch } = await import('@/app/utils/authFetch');
const res = await authFetch('/api/protected');
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(fetchSpy.mock.calls[1][0]).toBe('/api/refresh');
});
});

1
src/tests/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

15
vitest.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/tests/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});