tod mich in den mail

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
DeathKaioken 2026-05-04 16:44:45 +02:00
parent 646c293bc1
commit a88c7cc133
16 changed files with 747 additions and 228 deletions

View File

@ -1,223 +1,556 @@
'use client'; 'use client';
import { useState } from 'react'; import { useTranslation } from '../../../i18n/useTranslation';
import { useState, useEffect, useCallback } from 'react';
import { authFetch } from '../../../utils/authFetch';
import { useToast } from '../../../components/toast/toastComponent';
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
const MAIL_TEMPLATES_BASE = `${API_BASE}/api/admin/mail-templates`;
/** Convert kebab-case to camelCase for use as i18n key segment */
function toCamelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Derive the i18n translation key for a mail template's subject */
function subjectI18nKey(templateType: string): string {
return `mailTemplates.${toCamelCase(templateType)}.subject`;
}
/** Derive the i18n translation key for a mail template's HTML content */
function htmlI18nKey(templateType: string): string {
return `mailTemplates.${toCamelCase(templateType)}.htmlContent`;
}
/**
* Sync mail template content to the i18n translation files so it appears
* in Language Management and can be translated to other languages.
*/
async function syncToI18n(templateType: string, subject: string, htmlContent: string): Promise<void> {
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
const data = await res.json();
if (!res.ok || !data?.ok) throw new Error(data?.message || 'Failed to load translations');
const translations: Record<string, Record<string, string>> = data.translations ?? {};
const sKey = subjectI18nKey(templateType);
const hKey = htmlI18nKey(templateType);
const updated = {
...translations,
en: { ...(translations.en ?? {}), [sKey]: subject, [hKey]: htmlContent },
};
const putRes = await fetch('/api/i18n/translations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ translations: updated }),
});
const putData = await putRes.json();
if (!putRes.ok || !putData?.ok) throw new Error(putData?.message || 'Failed to sync to Language Management');
}
type MailTemplate = { type MailTemplate = {
id: string; id: number;
template_type: string;
name: string; name: string;
subject: string; subject: string | null;
html: string; html_content: string;
updatedAt: string; is_active: boolean;
is_archived: boolean;
archived_at: string | null;
created_at: string;
updated_at: string;
}; };
const MOCK_MAIL_TEMPLATES: MailTemplate[] = [ type EditorState = {
{ template_type: string;
id: 'welcome', name: string;
name: 'Welcome Mail', subject: string;
subject: 'Welcome to Profit Planet', html_content: string;
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>Welcome {{firstName}}</h2><p>Thanks for joining Profit Planet. We are happy to have you onboard.</p><p>Best regards,<br/>Profit Planet Team</p></div>', };
updatedAt: '2026-05-04T09:00:00.000Z',
}, const BLANK_EDITOR: EditorState = {
{ template_type: '',
id: 'invoice-reminder', name: '',
name: 'Invoice Reminder', subject: '',
subject: 'Friendly reminder: invoice {{invoiceNumber}}', html_content: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>New Template</h2><p>Edit this HTML content.</p></div>',
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>Invoice Reminder</h2><p>Hello {{companyName}},</p><p>your invoice <strong>{{invoiceNumber}}</strong> is due on <strong>{{dueDate}}</strong>.</p><p>Thank you!</p></div>', };
updatedAt: '2026-05-03T14:30:00.000Z',
},
];
export default function MailTemplatesManager() { export default function MailTemplatesManager() {
const [mailTemplates, setMailTemplates] = useState<MailTemplate[]>(MOCK_MAIL_TEMPLATES); const { t } = useTranslation();
const [selectedMailTemplateId, setSelectedMailTemplateId] = useState<string>(MOCK_MAIL_TEMPLATES[0]?.id ?? ''); const { showToast } = useToast();
const [isCreatingMailTemplate, setIsCreatingMailTemplate] = useState(false);
const [mailTemplateSavedMessage, setMailTemplateSavedMessage] = useState<string | null>(null);
const [mailEditor, setMailEditor] = useState<{ name: string; subject: string; html: string }>({
name: MOCK_MAIL_TEMPLATES[0]?.name ?? '',
subject: MOCK_MAIL_TEMPLATES[0]?.subject ?? '',
html: MOCK_MAIL_TEMPLATES[0]?.html ?? '',
});
const selectedMailTemplate = mailTemplates.find((template) => template.id === selectedMailTemplateId) ?? null; const [tab, setTab] = useState<'active' | 'archived'>('active');
const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const startCreateMailTemplate = () => { const [selectedId, setSelectedId] = useState<number | null>(null);
setIsCreatingMailTemplate(true); const [isCreating, setIsCreating] = useState(false);
setSelectedMailTemplateId(''); const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle');
setMailEditor({ const [editor, setEditor] = useState<EditorState>(BLANK_EDITOR);
name: 'New Mail Template', const [isSaving, setIsSaving] = useState(false);
subject: '', const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>New Template</h2><p>Edit this HTML content.</p></div>',
});
};
const selectExistingMailTemplate = (id: string) => { const fetchTemplates = useCallback(async (includeArchived: boolean) => {
const template = mailTemplates.find((item) => item.id === id); setIsLoading(true);
if (!template) return; setFetchError(null);
setIsCreatingMailTemplate(false); try {
setSelectedMailTemplateId(id); const url = `${MAIL_TEMPLATES_BASE}${includeArchived ? '?includeArchived=true' : ''}`;
setMailEditor({ const res = await authFetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
const raw = await res.json();
// API may return a bare array or a wrapper object { data: [...] } / { templates: [...] }
const data: MailTemplate[] = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: Array.isArray(raw?.templates)
? raw.templates
: [];
setTemplates(data);
} catch (e: any) {
setFetchError(e?.message || 'Failed to load templates');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates(tab === 'archived');
setSelectedId(null);
setIsCreating(false);
setEditor(BLANK_EDITOR);
}, [tab, fetchTemplates]);
const visibleTemplates = tab === 'archived'
? templates.filter((tmpl) => tmpl.is_archived)
: templates.filter((tmpl) => !tmpl.is_archived);
const selectedTemplate = templates.find((tmpl) => tmpl.id === selectedId) ?? null;
const openEditor = (template: MailTemplate) => {
setIsCreating(false);
setSelectedId(template.id);
setI18nSyncStatus('idle');
setEditor({
template_type: template.template_type,
name: template.name, name: template.name,
subject: template.subject, subject: template.subject ?? '',
html: template.html, html_content: template.html_content,
}); });
}; };
const saveMailTemplate = () => { const startCreate = () => {
const nowIso = new Date().toISOString(); setIsCreating(true);
setSelectedId(null);
if (isCreatingMailTemplate) { setI18nSyncStatus('idle');
const generatedId = `mail-${Date.now()}`; setEditor(BLANK_EDITOR);
const nextTemplate: MailTemplate = {
id: generatedId,
name: mailEditor.name.trim() || 'Untitled Template',
subject: mailEditor.subject,
html: mailEditor.html,
updatedAt: nowIso,
}; };
setMailTemplates((current) => [nextTemplate, ...current]);
setSelectedMailTemplateId(generatedId); const handleSave = async () => {
setIsCreatingMailTemplate(false); if (isSaving) return;
setMailTemplateSavedMessage('Mail template created (mock).'); setIsSaving(true);
return; setI18nSyncStatus('idle');
let savedTemplateType = editor.template_type;
let savedSubject = editor.subject;
let savedHtmlContent = editor.html_content;
try {
if (isCreating) {
const res = await authFetch(MAIL_TEMPLATES_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_type: editor.template_type,
name: editor.name,
subject: editor.subject || undefined,
html_content: editor.html_content,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
const created: MailTemplate = await res.json();
savedTemplateType = created.template_type;
savedSubject = created.subject ?? editor.subject;
savedHtmlContent = created.html_content;
showToast({ type: 'success', message: 'Mail template created.' });
await fetchTemplates(false);
setTab('active');
setIsCreating(false);
setSelectedId(created.id);
setEditor({
template_type: created.template_type,
name: created.name,
subject: created.subject ?? '',
html_content: created.html_content,
});
} else if (selectedId !== null) {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${selectedId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_type: editor.template_type,
name: editor.name,
subject: editor.subject || undefined,
html_content: editor.html_content,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ type: 'success', message: 'Mail template updated.' });
await fetchTemplates(tab === 'archived');
} }
if (!selectedMailTemplateId) return; // Sync content to i18n system so it's translatable in Language Management
if (savedTemplateType?.trim()) {
setMailTemplates((current) => setI18nSyncStatus('syncing');
current.map((template) => syncToI18n(savedTemplateType, savedSubject, savedHtmlContent)
template.id === selectedMailTemplateId .then(() => setI18nSyncStatus('synced'))
? { .catch(() => setI18nSyncStatus('error'));
...template, }
name: mailEditor.name.trim() || template.name, } catch (e: any) {
subject: mailEditor.subject, showToast({ type: 'error', message: e?.message || 'Failed to save template.' });
html: mailEditor.html, } finally {
updatedAt: nowIso, setIsSaving(false);
} }
: template
)
);
setMailTemplateSavedMessage('Mail template updated (mock).');
}; };
const deleteSelectedMailTemplate = () => { const handleActivate = async (id: number) => {
if (!selectedMailTemplateId) return; setActionLoadingId(id);
try {
setMailTemplates((current) => { const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/activate`, { method: 'PATCH' });
const next = current.filter((template) => template.id !== selectedMailTemplateId); if (!res.ok) {
const nextSelected = next[0] ?? null; const err = await res.json().catch(() => ({}));
setSelectedMailTemplateId(nextSelected?.id ?? ''); throw new Error((err as any)?.message || `Error ${res.status}`);
setIsCreatingMailTemplate(false); }
setMailEditor({ showToast({ type: 'success', message: 'Template activated.' });
name: nextSelected?.name ?? '', await fetchTemplates(tab === 'archived');
subject: nextSelected?.subject ?? '', } catch (e: any) {
html: nextSelected?.html ?? '', showToast({ type: 'error', message: e?.message || 'Failed to activate template.' });
}); } finally {
return next; setActionLoadingId(null);
}); }
setMailTemplateSavedMessage('Mail template removed (mock).');
}; };
const handleArchive = async (id: number) => {
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/archive`, { method: 'PATCH' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ type: 'success', message: 'Template archived.' });
if (selectedId === id) {
setSelectedId(null);
setEditor(BLANK_EDITOR);
}
await fetchTemplates(tab === 'archived');
} catch (e: any) {
showToast({ type: 'error', message: e?.message || 'Failed to archive template.' });
} finally {
setActionLoadingId(null);
}
};
const handleDelete = async (id: number) => {
if (!window.confirm('Delete this mail template? This cannot be undone.')) return;
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`);
}
showToast({ type: 'success', message: 'Template deleted.' });
if (selectedId === id) {
setSelectedId(null);
setEditor(BLANK_EDITOR);
}
await fetchTemplates(tab === 'archived');
} catch (e: any) {
showToast({ type: 'error', message: e?.message || 'Failed to delete template.' });
} finally {
setActionLoadingId(null);
}
};
const editorOpen = isCreating || selectedId !== null;
return ( return (
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7"> <section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
{/* Header */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-xl font-semibold text-slate-900"> <h2 className="flex items-center gap-2 text-xl font-semibold text-slate-900">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Mail Templates <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{t('autofix.kd93a60af')}
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tab === 'active' && (
<button <button
type="button" type="button"
onClick={startCreateMailTemplate} onClick={startCreate}
className="inline-flex items-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition" className="inline-flex items-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
> >
+ New Mail Template + New Mail Template
</button> </button>
)}
{editorOpen && (
<button <button
type="button" type="button"
onClick={saveMailTemplate} onClick={handleSave}
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50 transition" disabled={isSaving}
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50 transition disabled:opacity-50"
> >
Save {isSaving ? 'Saving…' : (isCreating ? 'Create' : 'Save')}
</button>
)}
<button
type="button"
onClick={() => fetchTemplates(tab === 'archived')}
disabled={isLoading}
title="Refresh"
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 transition disabled:opacity-50"
>
<svg className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582M20 20v-5h-.581M5.636 15.364A9 9 0 1020 12" />
</svg>
</button>
</div>
</div>
{/* Tabs */}
<div className="mb-5 flex gap-1 rounded-2xl border border-slate-200 bg-slate-100/60 p-1 w-fit">
<button
type="button"
onClick={() => setTab('active')}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
tab === 'active' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>
Active Templates
</button> </button>
<button <button
type="button" type="button"
onClick={deleteSelectedMailTemplate} onClick={() => setTab('archived')}
disabled={isCreatingMailTemplate || !selectedMailTemplateId} className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
className="inline-flex items-center rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 transition disabled:opacity-50 disabled:cursor-not-allowed" tab === 'archived' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>
Archived
</button>
</div>
{fetchError && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{fetchError}
</div>
)}
<div className={`grid gap-4 ${editorOpen ? 'lg:grid-cols-[290px_minmax(0,1fr)]' : ''}`}>
{/* Template List */}
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3">
<h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">
{tab === 'archived' ? 'Archived Templates' : t('autofix.k2fbe0857')}
</h3>
{isLoading && (
<div className="px-2 py-4 text-sm text-slate-400 text-center">Loading</div>
)}
{!isLoading && visibleTemplates.length === 0 && !isCreating && (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
{tab === 'archived' ? 'No archived templates.' : t('autofix.k247b74e1')}
</div>
)}
{isCreating && (
<div className="mb-2 rounded-xl border border-slate-900 bg-slate-900 px-3 py-2 text-white">
<div className="text-sm font-semibold truncate">New Template</div>
<div className="mt-0.5 text-[11px] text-slate-300">Draft</div>
</div>
)}
<div className="space-y-2">
{visibleTemplates.map((template) => {
const isSelected = !isCreating && selectedId === template.id;
const isBusy = actionLoadingId === template.id;
return (
<div
key={template.id}
className={`rounded-xl border transition ${
isSelected ? 'border-slate-900 bg-slate-900' : 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<button
type="button"
onClick={() => openEditor(template)}
className="w-full px-3 pt-2 pb-1 text-left"
>
<div className={`text-sm font-semibold truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
{template.name}
</div>
<div className={`mt-0.5 text-[11px] flex items-center gap-1.5 ${isSelected ? 'text-slate-300' : 'text-slate-500'}`}>
<span className="truncate">{template.template_type}</span>
{template.is_active && !template.is_archived && (
<span className={`shrink-0 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-emerald-500 text-white' : 'bg-emerald-100 text-emerald-700'}`}>
Active
</span>
)}
</div>
</button>
<div className="flex flex-wrap items-center gap-1 px-3 pb-2">
<button
type="button"
title="Edit"
onClick={() => openEditor(template)}
className={`rounded-lg px-2 py-1 text-[11px] font-semibold transition ${
isSelected ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Edit
</button>
{!template.is_archived && !template.is_active && (
<button
type="button"
title="Activate"
disabled={isBusy}
onClick={() => handleActivate(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 transition disabled:opacity-50"
>
Activate
</button>
)}
{!template.is_archived && template.is_active && (
<span className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-100 text-emerald-700 cursor-default select-none">
Active
</span>
)}
{!template.is_archived && (
<button
type="button"
title="Archive"
disabled={isBusy}
onClick={() => handleArchive(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-amber-50 text-amber-700 hover:bg-amber-100 transition disabled:opacity-50"
>
Archive
</button>
)}
<button
type="button"
title="Delete"
disabled={isBusy}
onClick={() => handleDelete(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-rose-50 text-rose-700 hover:bg-rose-100 transition disabled:opacity-50"
> >
Delete Delete
</button> </button>
</div>
</div>
<p className="mb-5 text-sm text-slate-600"> {isBusy && (
Frontend-only mock editor. Data is stored in local component state and resets on page reload. <svg className="ml-1 w-3.5 h-3.5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
</p> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
{mailTemplateSavedMessage && ( </svg>
<div className="mb-5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{mailTemplateSavedMessage}
</div>
)} )}
<div className="grid gap-4 lg:grid-cols-[290px_minmax(0,1fr)]">
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3">
<h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">Template List</h3>
<div className="space-y-2">
{mailTemplates.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
No templates yet.
</div> </div>
)}
{mailTemplates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => selectExistingMailTemplate(template.id)}
className={`w-full rounded-xl border px-3 py-2 text-left transition ${
!isCreatingMailTemplate && selectedMailTemplateId === template.id
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-800 hover:bg-slate-50'
}`}
>
<div className="text-sm font-semibold truncate">{template.name}</div>
<div className={`mt-0.5 text-[11px] ${!isCreatingMailTemplate && selectedMailTemplateId === template.id ? 'text-slate-300' : 'text-slate-500'}`}>
{template.subject || 'No subject'}
</div> </div>
</button> );
))} })}
</div> </div>
</aside> </aside>
{/* Editor + Preview */}
{editorOpen && (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4"> <div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<label className="block md:col-span-2"> <label className="block">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Template Name</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Template Type</div>
<input <input
value={mailEditor.name} value={editor.template_type}
onChange={(event) => setMailEditor((current) => ({ ...current, name: event.target.value }))} onChange={(e) => { setEditor((s) => ({ ...s, template_type: e.target.value })); setI18nSyncStatus('idle'); }}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="e.g. welcome, invoice-reminder"
/>
</label>
<label className="block">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fc164d2')}</div>
<input
value={editor.name}
onChange={(e) => setEditor((s) => ({ ...s, name: e.target.value }))}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent" className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="Welcome Mail" placeholder="Welcome Mail"
/> />
</label> </label>
{/* i18n key info + sync status */}
{editor.template_type?.trim() && (
<div className="md:col-span-2 rounded-xl border border-sky-200 bg-sky-50/60 px-4 py-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-sky-700">
<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="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
Translation Keys edit in Language Management
</div>
{i18nSyncStatus === 'syncing' && (
<span className="text-[11px] text-sky-500 flex items-center gap-1">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
Syncing
</span>
)}
{i18nSyncStatus === 'synced' && (
<span className="text-[11px] text-emerald-600 font-semibold"> Synced to Language Management</span>
)}
{i18nSyncStatus === 'error' && (
<span className="text-[11px] text-rose-600 font-semibold"> Sync failed (content saved to DB)</span>
)}
</div>
<div className="flex flex-col gap-1 text-[11px] font-mono text-sky-800">
<span className="flex items-center gap-1.5">
<span className="text-sky-400 select-none">subject:</span>
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{subjectI18nKey(editor.template_type)}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-sky-400 select-none">html:</span>
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{htmlI18nKey(editor.template_type)}</span>
</span>
</div>
</div>
)}
<label className="block md:col-span-2"> <label className="block md:col-span-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
<input <input
value={mailEditor.subject} value={editor.subject}
onChange={(event) => setMailEditor((current) => ({ ...current, subject: event.target.value }))} onChange={(e) => setEditor((s) => ({ ...s, subject: e.target.value }))}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent" className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="Subject with placeholders like {{firstName}}" placeholder="Subject with placeholders like {{firstName}}"
/> />
</label> </label>
<label className="block md:col-span-2"> <label className="block md:col-span-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">HTML Body</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kb56e3ea8')}</div>
<textarea <textarea
value={mailEditor.html} value={editor.html_content}
onChange={(event) => setMailEditor((current) => ({ ...current, html: event.target.value }))} onChange={(e) => setEditor((s) => ({ ...s, html_content: e.target.value }))}
className="min-h-[220px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent" className="min-h-[220px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="<div>Your HTML here</div>" placeholder="<div>Your HTML here</div>"
/> />
@ -226,21 +559,23 @@ export default function MailTemplatesManager() {
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4"> <div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
<div className="mb-2 text-sm font-semibold text-slate-900">Live Preview</div> <div className="mb-2 text-sm font-semibold text-slate-900">{t('autofix.kefc3a3f9')}</div>
<div className="rounded-lg border border-slate-200 bg-white p-4"> <div className="rounded-lg border border-slate-200 bg-white p-4">
<div className="mb-3 text-xs text-slate-500"> <div className="mb-3 text-xs text-slate-500">
Subject: <span className="font-medium text-slate-800">{mailEditor.subject || 'No subject'}</span> {t('autofix.k64efb463')}
{selectedMailTemplate && !isCreatingMailTemplate && ( <span className="font-medium text-slate-800">{editor.subject || 'No subject'}</span>
<span className="ml-3">Last update: {new Date(selectedMailTemplate.updatedAt).toLocaleString()}</span> {selectedTemplate && !isCreating && (
<span className="ml-3">Last update: {new Date(selectedTemplate.updated_at).toLocaleString()}</span>
)} )}
</div> </div>
<div <div
className="prose prose-sm max-w-none text-slate-800" className="prose prose-sm max-w-none text-slate-800"
dangerouslySetInnerHTML={{ __html: mailEditor.html || '<p class="text-slate-500">No HTML content</p>' }} dangerouslySetInnerHTML={{ __html: editor.html_content || '<p class="text-slate-500">No HTML content</p>' }}
/> />
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
</section> </section>
); );

View File

@ -140,6 +140,7 @@ function CreateNewsModal({
creating: boolean creating: boolean
error: string | null error: string | null
}) { }) {
const { t } = useTranslation();
const [title, setTitle] = React.useState('') const [title, setTitle] = React.useState('')
const [summary, setSummary] = React.useState('') const [summary, setSummary] = React.useState('')
const [content, setContent] = React.useState('') const [content, setContent] = React.useState('')
@ -266,6 +267,7 @@ function CreateNewsModal({
} }
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) { function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
const { t } = useTranslation();
const [title, setTitle] = React.useState(item.title) const [title, setTitle] = React.useState(item.title)
const [summary, setSummary] = React.useState(item.summary || '') const [summary, setSummary] = React.useState(item.summary || '')
const [content, setContent] = React.useState(item.content || '') const [content, setContent] = React.useState(item.content || '')

View File

@ -188,8 +188,8 @@ export function usePoolManageState() {
email: String(apiUser.email || '').trim(), email: String(apiUser.email || '').trim(),
} }
}) })
.filter((candidate) => !existingIds.has(candidate.id)) .filter((candidate: UserCandidate) => !existingIds.has(candidate.id))
.filter((candidate) => `${candidate.name} ${candidate.email}`.toLowerCase().includes(normalizedQuery)) .filter((candidate: UserCandidate) => `${candidate.name} ${candidate.email}`.toLowerCase().includes(normalizedQuery))
setCandidates(mapped) setCandidates(mapped)
} catch (requestError: any) { } catch (requestError: any) {

View File

@ -464,6 +464,87 @@ function ensureUseTranslationHooksInComponents(content: string): { content: stri
return { content: next, addedHooks: positions.length }; return { content: next, addedHooks: positions.length };
} }
function isIndexInsideStringOrComment(source: string, index: number): boolean {
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplateLiteral = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < source.length; i += 1) {
if (i >= index) {
return inSingleQuote || inDoubleQuote || inTemplateLiteral || inLineComment || inBlockComment;
}
const char = source[i];
const prev = i > 0 ? source[i - 1] : '';
const next = i + 1 < source.length ? source[i + 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 (inTemplateLiteral) {
if (char === '`' && prev !== '\\') {
inTemplateLiteral = false;
}
continue;
}
if (char === '/' && next === '/') {
inLineComment = true;
i += 1;
continue;
}
if (char === '/' && next === '*') {
inBlockComment = true;
i += 1;
continue;
}
if (char === '\'') {
inSingleQuote = true;
continue;
}
if (char === '"') {
inDoubleQuote = true;
continue;
}
if (char === '`') {
inTemplateLiteral = true;
}
}
return inSingleQuote || inDoubleQuote || inTemplateLiteral || inLineComment || inBlockComment;
}
function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } { function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
const keyValueMap = new Map<string, string>(); const keyValueMap = new Map<string, string>();
let replacements = 0; let replacements = 0;
@ -472,8 +553,12 @@ function replaceJsxAttributeLiterals(content: string): { content: string; replac
const attrRegex = new RegExp(`\\b(${attrsPattern})\\s*=\\s*([\"'])([^\"'{}<>\\n][^\"'<>\\n]*)\\2`, 'g'); const attrRegex = new RegExp(`\\b(${attrsPattern})\\s*=\\s*([\"'])([^\"'{}<>\\n][^\"'<>\\n]*)\\2`, 'g');
// Only rewrite inside JSX tag bodies, never in TS/JS function params or object literals. // Only rewrite inside JSX tag bodies, never in TS/JS function params or object literals.
const next = content.replace(/<[^>]+>/g, (tag) => const next = content.replace(/<[^>]+>/g, (tag, tagOffset: number) => {
tag.replace(attrRegex, (full, attrName: string, quote: string, captured: string) => { if (isIndexInsideStringOrComment(content, tagOffset)) {
return tag;
}
return tag.replace(attrRegex, (full, attrName: string, quote: string, captured: string) => {
const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
if (shouldIgnoreLiteral(text)) return full; if (shouldIgnoreLiteral(text)) return full;
@ -481,8 +566,8 @@ function replaceJsxAttributeLiterals(content: string): { content: string; replac
keyValueMap.set(key, text); keyValueMap.set(key, text);
replacements += 1; replacements += 1;
return `${attrName}={t('${key}')}`; return `${attrName}={t('${key}')}`;
}) });
); });
return { content: next, replacements, keyValueMap }; return { content: next, replacements, keyValueMap };
} }
@ -492,7 +577,11 @@ function replaceJsxTextNodes(content: string): { content: string; replacements:
let replacements = 0; let replacements = 0;
const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s*</g; const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s*</g;
const next = content.replace(jsxTextRegex, (full, captured: string) => { const next = content.replace(jsxTextRegex, (full, captured: string, offset: number) => {
if (isIndexInsideStringOrComment(content, offset)) {
return full;
}
const text = String(captured ?? '').replace(/\s+/g, ' ').trim(); const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
if (shouldIgnoreLiteral(text)) return full; if (shouldIgnoreLiteral(text)) return full;
@ -515,6 +604,8 @@ function upsertAutofixNamespace(content: string, entries: Map<string, string>):
const indent = match[1] ?? ' '; const indent = match[1] ?? ' ';
const blockHeader = match[2] ?? 'autofix: {'; const blockHeader = match[2] ?? 'autofix: {';
const blockBody = match[3] ?? ''; const blockBody = match[3] ?? '';
const trimmedBlockBody = blockBody.replace(/\s*$/, '');
const trailingWhitespace = blockBody.slice(trimmedBlockBody.length);
const existing = new Set<string>(); const existing = new Set<string>();
for (const m of blockBody.matchAll(/\n\s{4}(?:"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))\s*:\s*['"]/g)) { for (const m of blockBody.matchAll(/\n\s{4}(?:"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))\s*:\s*['"]/g)) {
const keyName = m[1] || m[2]; const keyName = m[1] || m[2];
@ -543,7 +634,12 @@ function upsertAutofixNamespace(content: string, entries: Map<string, string>):
if (missing.length === 0) return content; if (missing.length === 0) return content;
const replacement = `\n${indent}${blockHeader}${blockBody}\n${missing.join('\n')}\n${indent}},`; const normalizedBlockBody =
trimmedBlockBody.length === 0 || trimmedBlockBody.endsWith(',')
? blockBody
: `${trimmedBlockBody},${trailingWhitespace}`;
const replacement = `\n${indent}${blockHeader}${normalizedBlockBody}\n${missing.join('\n')}\n${indent}},`;
return content.replace(autofixBlockRegex, replacement); return content.replace(autofixBlockRegex, replacement);
} }
@ -767,8 +863,8 @@ async function addMissingAutofixKeysToTranslations(missingKeys: string[]): Promi
return { createdKeys: [] }; return { createdKeys: [] };
} }
const enFlat = flattenObject(en as Record<string, unknown>); const enFlat = flattenObject(en as unknown as Record<string, unknown>);
const deFlat = flattenObject(de as Record<string, unknown>); const deFlat = flattenObject(de as unknown as Record<string, unknown>);
const entriesForEn = new Map<string, string>(); const entriesForEn = new Map<string, string>();
const entriesForDe = new Map<string, string>(); const entriesForDe = new Map<string, string>();
@ -823,7 +919,7 @@ async function runWorkspaceScan(): Promise<ScanResult> {
await walk(workspaceRoot, files, counters); await walk(workspaceRoot, files, counters);
const englishKeys = new Set(Object.keys(flattenObject(en as Record<string, unknown>))); const englishKeys = new Set(Object.keys(flattenObject(en as unknown as Record<string, unknown>)));
const uniqueUsedKeys = new Set<string>(); const uniqueUsedKeys = new Set<string>();
const missingKeyFiles: MissingKeyMap = new Map(); const missingKeyFiles: MissingKeyMap = new Map();
const untranslatedLiteralFiles: Map<string, Set<string>> = new Map(); const untranslatedLiteralFiles: Map<string, Set<string>> = new Map();

View File

@ -24,17 +24,17 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
const buttonCls = const buttonCls =
variant === 'dark' variant === 'dark'
? '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-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'; : 'group inline-flex min-w-[176px] items-center justify-between gap-3 rounded-2xl border border-white/80 bg-white/75 px-3.5 py-2.5 text-sm font-semibold text-slate-900 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.32)] backdrop-blur-md transition hover:border-white hover:bg-white/90 hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.36)] data-[open]:border-white data-[open]:bg-white/92 data-[open]:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.36)]';
const menuCls = const menuCls =
variant === 'dark' variant === 'dark'
? '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-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'; : 'absolute right-0 z-50 mt-3 w-64 origin-top-right rounded-[24px] border border-white/80 bg-white/88 p-2 shadow-[0_28px_80px_-38px_rgba(15,23,42,0.4)] backdrop-blur-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) => const itemCls = (isActive: boolean) =>
variant === 'dark' variant === 'dark'
? `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]/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'}`; : `flex w-full items-center gap-3 rounded-2xl px-3.5 py-3 text-sm transition ${isActive ? 'bg-gradient-to-r from-[#8D6B1D]/12 via-amber-100/70 to-white/80 text-[#6f5416] ring-1 ring-[#8D6B1D]/20 shadow-[inset_0_1px_0_rgba(255,255,255,0.85)]' : 'text-slate-700 hover:bg-white/75 hover:text-slate-900'}`;
return ( return (
<Menu as="div" className="relative inline-block"> <Menu as="div" className="relative inline-block">
@ -45,7 +45,7 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
className={ className={
variant === 'dark' 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-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' : 'rounded-full border border-white/80 bg-white/70 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-600 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]'
} }
> >
{activeLang.code} {activeLang.code}
@ -63,7 +63,7 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
className={ className={
variant === 'dark' 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-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' : 'rounded-full border border-white/75 bg-white/65 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
} }
> >
{lang.code} {lang.code}

View File

@ -328,6 +328,7 @@ export default function QuickActions() {
// Email Verification Modal Component // Email Verification Modal Component
function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) { function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) {
const { t } = useTranslation();
const [verificationCode, setVerificationCode] = useState('') const [verificationCode, setVerificationCode] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@ -461,6 +462,7 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
onClose: () => void, onClose: () => void,
onSuccess: () => void onSuccess: () => void
}) { }) {
const { t } = useTranslation();
const [frontFile, setFrontFile] = useState<File | null>(null) const [frontFile, setFrontFile] = useState<File | null>(null)
const [backFile, setBackFile] = useState<File | null>(null) const [backFile, setBackFile] = useState<File | null>(null)
const [idType, setIdType] = useState('') const [idType, setIdType] = useState('')
@ -634,6 +636,7 @@ function ProfileCompletionModal({ userType, onClose, onSuccess }: {
onClose: () => void, onClose: () => void,
onSuccess: () => void onSuccess: () => void
}) { }) {
const { t } = useTranslation();
const [formData, setFormData] = useState<any>({}) const [formData, setFormData] = useState<any>({})
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -758,6 +761,7 @@ function ContractSigningModal({ userType, onClose, onSuccess }: {
onClose: () => void, onClose: () => void,
onSuccess: () => void onSuccess: () => void
}) { }) {
const { t } = useTranslation();
const [contractFile, setContractFile] = useState<File | null>(null) const [contractFile, setContractFile] = useState<File | null>(null)
const [agreed, setAgreed] = useState(false) const [agreed, setAgreed] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)

View File

@ -833,12 +833,8 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
</div> </div>
</div> </div>
{/* Sticky bottom: language switcher + logout */} {/* Sticky bottom: logout */}
<div className="border-t border-gray-200/60 dark:border-white/10 px-4 py-3 space-y-2"> <div className="border-t border-gray-200/60 dark:border-white/10 px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('common.language')}</span>
<LanguageSwitcher variant="dark" />
</div>
{user && ( {user && (
<button <button
onClick={() => { handleLogout(); setMobileMenuOpen(false); }} onClick={() => { handleLogout(); setMobileMenuOpen(false); }}

View File

@ -21,6 +21,7 @@ export function PaginationPrevious({
className, className,
children = 'Previous', children = 'Previous',
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) { }: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
const { t } = useTranslation();
return ( return (
<span className={clsx(className, 'grow basis-0')}> <span className={clsx(className, 'grow basis-0')}>
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k5b7042c7')}> <Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k5b7042c7')}>
@ -43,6 +44,7 @@ export function PaginationNext({
className, className,
children = 'Next', children = 'Next',
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) { }: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
const { t } = useTranslation();
return ( return (
<span className={clsx(className, 'flex grow basis-0 justify-end')}> <span className={clsx(className, 'flex grow basis-0 justify-end')}>
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k17581b31')}> <Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k17581b31')}>

View File

@ -25,6 +25,7 @@ function CloseMenuIcon() {
} }
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) { function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
const { t } = useTranslation();
return ( return (
<Headless.Dialog open={open} onClose={close} className="lg:hidden"> <Headless.Dialog open={open} onClose={close} className="lg:hidden">
<Headless.DialogBackdrop <Headless.DialogBackdrop
@ -53,6 +54,7 @@ export function SidebarLayout({
sidebar, sidebar,
children, children,
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) { }: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
const { t } = useTranslation();
let [showSidebar, setShowSidebar] = useState(false) let [showSidebar, setShowSidebar] = useState(false)
return ( return (

View File

@ -25,6 +25,7 @@ function CloseMenuIcon() {
} }
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) { function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
const { t } = useTranslation();
return ( return (
<Headless.Dialog open={open} onClose={close} className="lg:hidden"> <Headless.Dialog open={open} onClose={close} className="lg:hidden">
<Headless.DialogBackdrop <Headless.DialogBackdrop
@ -53,6 +54,7 @@ export function StackedLayout({
sidebar, sidebar,
children, children,
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) { }: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
const { t } = useTranslation();
let [showSidebar, setShowSidebar] = useState(false) let [showSidebar, setShowSidebar] = useState(false)
return ( return (

View File

@ -1949,7 +1949,26 @@ export const de: Translations = {
"k4e2a8d19": "Seite", "k4e2a8d19": "Seite",
"k5a9d3c27": "ausstehende Benutzer", "k5a9d3c27": "ausstehende Benutzer",
"k1f8c4a52": "Zeige {current} von {total} Benutzern", "k1f8c4a52": "Zeige {current} von {total} Benutzern",
"k9b5d2e70": "Seite {page} von {totalPages} ({total} ausstehende Benutzer)" "k9b5d2e70": "Seite {page} von {totalPages} ({total} ausstehende Benutzer)",
"k2437072a": "is due on",
"k247b74e1": "No templates yet.",
"k2f343849": "New Template",
"k2fbe0857": "Template List",
"k2fc164d2": "Template Name",
"k5270d585": "your invoice",
"k64efb463": "Subject:",
"k8e6829d0": "No HTML content",
"k9876e80c": "Profit Planet Team",
"ka1d10c8e": "Thanks for joining Profit Planet. We are happy to have you onboard.",
"kb56e3ea8": "HTML Body",
"kb8191dff": "Thank you!",
"kbbe1d6ba": "Invoice Reminder",
"kbe86fadd": "Best regards,",
"kd62cc394": "Frontend-only mock editor. Data is stored in local component state and resets on page reload.",
"kd93a60af": "Mail Templates",
"kefc3a3f9": "Live Preview",
"kf2426f05": "Edit this HTML content.",
"kf34ed3b3": "Your HTML here",
}, },
"toasts": { "toasts": {
"loginSuccess": "Anmeldung erfolgreich", "loginSuccess": "Anmeldung erfolgreich",
@ -1973,5 +1992,6 @@ export const de: Translations = {
"deleteSuccess": "Erfolgreich gelöscht.", "deleteSuccess": "Erfolgreich gelöscht.",
"deleteFailed": "Löschen fehlgeschlagen. Bitte versuchen Sie es erneut.", "deleteFailed": "Löschen fehlgeschlagen. Bitte versuchen Sie es erneut.",
"genericError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut." "genericError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
} },
"mailTemplates": {}
}; };

View File

@ -1949,7 +1949,26 @@ export const en: Translations = {
"k4e2a8d19": "Page", "k4e2a8d19": "Page",
"k5a9d3c27": "pending users", "k5a9d3c27": "pending users",
"k1f8c4a52": "Showing {current} of {total} users", "k1f8c4a52": "Showing {current} of {total} users",
"k9b5d2e70": "Page {page} of {totalPages} ({total} pending users)" "k9b5d2e70": "Page {page} of {totalPages} ({total} pending users)",
"k2437072a": "is due on",
"k247b74e1": "No templates yet.",
"k2f343849": "New Template",
"k2fbe0857": "Template List",
"k2fc164d2": "Template Name",
"k5270d585": "your invoice",
"k64efb463": "Subject:",
"k8e6829d0": "No HTML content",
"k9876e80c": "Profit Planet Team",
"ka1d10c8e": "Thanks for joining Profit Planet. We are happy to have you onboard.",
"kb56e3ea8": "HTML Body",
"kb8191dff": "Thank you!",
"kbbe1d6ba": "Invoice Reminder",
"kbe86fadd": "Best regards,",
"kd62cc394": "Frontend-only mock editor. Data is stored in local component state and resets on page reload.",
"kd93a60af": "Mail Templates",
"kefc3a3f9": "Live Preview",
"kf2426f05": "Edit this HTML content.",
"kf34ed3b3": "Your HTML here",
}, },
"toasts": { "toasts": {
"loginSuccess": "Login successful", "loginSuccess": "Login successful",
@ -1973,5 +1992,6 @@ export const en: Translations = {
"deleteSuccess": "Deleted successfully.", "deleteSuccess": "Deleted successfully.",
"deleteFailed": "Could not delete. Please try again.", "deleteFailed": "Could not delete. Please try again.",
"genericError": "Something went wrong. Please try again." "genericError": "Something went wrong. Please try again."
} },
"mailTemplates": {}
}; };

View File

@ -1019,6 +1019,7 @@ export interface Translations {
}; };
autofix: Record<string, string>; autofix: Record<string, string>;
mailTemplates: Record<string, string>;
// ─── Notifications / Toasts ──────────────────────────── // ─── Notifications / Toasts ────────────────────────────
toasts: { toasts: {

View File

@ -53,11 +53,8 @@ interface I18nProviderProps {
} }
export function I18nProvider({ children }: I18nProviderProps) { export function I18nProvider({ children }: I18nProviderProps) {
const [language, setLanguage] = useState<string>(() => { const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
if (typeof window === 'undefined') return DEFAULT_LANGUAGE; const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false);
const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY);
return stored && stored.trim() ? stored : DEFAULT_LANGUAGE;
});
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({ const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
languages: [ languages: [
{ code: 'en', name: 'English' }, { code: 'en', name: 'English' },
@ -100,6 +97,17 @@ export function I18nProvider({ children }: I18nProviderProps) {
void reloadTranslations(); void reloadTranslations();
}, [reloadTranslations]); }, [reloadTranslations]);
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY);
if (stored && stored.trim() && stored !== language) {
setLanguage(stored);
}
setHasSyncedStoredLanguage(true);
}, []);
useEffect(() => { useEffect(() => {
if (translationFiles.languages.length === 0) return; if (translationFiles.languages.length === 0) return;
if (translationFiles.languages.some((entry) => entry.code === language)) return; if (translationFiles.languages.some((entry) => entry.code === language)) return;
@ -111,9 +119,10 @@ export function I18nProvider({ children }: I18nProviderProps) {
}, [translationFiles.languages, language]); }, [translationFiles.languages, language]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined' || !hasSyncedStoredLanguage) return;
window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language); window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language);
}, [language]); document.documentElement.lang = language;
}, [hasSyncedStoredLanguage, language]);
const t = useCallback((key: string): string => { const t = useCallback((key: string): string => {
// 1. Check translation loaded from translation files API. // 1. Check translation loaded from translation files API.

View File

@ -10,6 +10,7 @@ import Waves from '../components/background/waves'
import { ToastProvider, useToast } from '../components/toast/toastComponent' import { ToastProvider, useToast } from '../components/toast/toastComponent'
function PasswordResetPageInner() { function PasswordResetPageInner() {
const { t } = useTranslation();
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const token = searchParams.get('token') const token = searchParams.get('token')
@ -339,7 +340,6 @@ function PasswordResetPageInner() {
} }
export default function PasswordResetPage() { export default function PasswordResetPage() {
const { t } = useTranslation();
return ( return (
<ToastProvider> <ToastProvider>
<Suspense <Suspense

View File

@ -3,6 +3,7 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline' import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
import { createReferralLink } from '../hooks/generateReferralLink' import { createReferralLink } from '../hooks/generateReferralLink'
import { useToast } from '../../components/toast/toastComponent'
import { useTranslation } from '../../i18n/useTranslation' import { useTranslation } from '../../i18n/useTranslation'
interface Props { interface Props {
@ -11,6 +12,7 @@ interface Props {
export default function GenerateReferralLinkWidget({ onCreated }: Props) { export default function GenerateReferralLinkWidget({ onCreated }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { showToast } = useToast()
// Defaults: Unlimited + Never expires // Defaults: Unlimited + Never expires
const [maxUses, setMaxUses] = useState<string>('-1') const [maxUses, setMaxUses] = useState<string>('-1')
const [expiresInDays, setExpiresInDays] = useState<string>('-1') const [expiresInDays, setExpiresInDays] = useState<string>('-1')
@ -95,9 +97,27 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
if (url) setGeneratedLink(url) if (url) setGeneratedLink(url)
if (res.ok) {
showToast({
variant: 'success',
title: t('referralManagement.createLink'),
message: t('referralManagement.createSuccess'),
})
} else {
showToast({
variant: 'error',
title: t('referralManagement.createLink'),
message: body?.message || body?.error || t('referralManagement.createError'),
})
}
if (res.ok && onCreated) await onCreated() if (res.ok && onCreated) await onCreated()
} catch { } catch {
// optional error handling showToast({
variant: 'error',
title: t('referralManagement.createLink'),
message: t('referralManagement.createError'),
})
} finally { } finally {
setIsGenerating(false) setIsGenerating(false)
} }
@ -108,8 +128,18 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
try { try {
setIsCopying(true) setIsCopying(true)
await navigator.clipboard.writeText(generatedLink) await navigator.clipboard.writeText(generatedLink)
showToast({
variant: 'success',
title: t('referralManagement.copyLink'),
message: t('referralManagement.copiedMessage'),
})
setTimeout(() => setIsCopying(false), 800) setTimeout(() => setIsCopying(false), 800)
} catch { } catch {
showToast({
variant: 'error',
title: t('referralManagement.copyFailed'),
message: t('referralManagement.copyFailedMessage'),
})
setIsCopying(false) setIsCopying(false)
} }
} }