dev #21
@ -1,246 +1,581 @@
|
||||
'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 = {
|
||||
id: string;
|
||||
id: number;
|
||||
template_type: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
updatedAt: string;
|
||||
subject: string | null;
|
||||
html_content: string;
|
||||
is_active: boolean;
|
||||
is_archived: boolean;
|
||||
archived_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const MOCK_MAIL_TEMPLATES: MailTemplate[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
name: 'Welcome Mail',
|
||||
subject: 'Welcome to Profit Planet',
|
||||
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',
|
||||
},
|
||||
{
|
||||
id: 'invoice-reminder',
|
||||
name: 'Invoice Reminder',
|
||||
subject: 'Friendly reminder: invoice {{invoiceNumber}}',
|
||||
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',
|
||||
},
|
||||
];
|
||||
type EditorState = {
|
||||
template_type: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
html_content: string;
|
||||
};
|
||||
|
||||
const BLANK_EDITOR: EditorState = {
|
||||
template_type: '',
|
||||
name: '',
|
||||
subject: '',
|
||||
html_content: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>New Template</h2><p>Edit this HTML content.</p></div>',
|
||||
};
|
||||
|
||||
export default function MailTemplatesManager() {
|
||||
const [mailTemplates, setMailTemplates] = useState<MailTemplate[]>(MOCK_MAIL_TEMPLATES);
|
||||
const [selectedMailTemplateId, setSelectedMailTemplateId] = useState<string>(MOCK_MAIL_TEMPLATES[0]?.id ?? '');
|
||||
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 { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
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 = () => {
|
||||
setIsCreatingMailTemplate(true);
|
||||
setSelectedMailTemplateId('');
|
||||
setMailEditor({
|
||||
name: 'New Mail Template',
|
||||
subject: '',
|
||||
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>New Template</h2><p>Edit this HTML content.</p></div>',
|
||||
});
|
||||
};
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle');
|
||||
const [editor, setEditor] = useState<EditorState>(BLANK_EDITOR);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
||||
|
||||
const selectExistingMailTemplate = (id: string) => {
|
||||
const template = mailTemplates.find((item) => item.id === id);
|
||||
if (!template) return;
|
||||
setIsCreatingMailTemplate(false);
|
||||
setSelectedMailTemplateId(id);
|
||||
setMailEditor({
|
||||
name: template.name,
|
||||
subject: template.subject,
|
||||
html: template.html,
|
||||
});
|
||||
};
|
||||
|
||||
const saveMailTemplate = () => {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
if (isCreatingMailTemplate) {
|
||||
const generatedId = `mail-${Date.now()}`;
|
||||
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);
|
||||
setIsCreatingMailTemplate(false);
|
||||
setMailTemplateSavedMessage('Mail template created (mock).');
|
||||
return;
|
||||
const fetchTemplates = useCallback(async (includeArchived: boolean) => {
|
||||
setIsLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const url = `${MAIL_TEMPLATES_BASE}${includeArchived ? '?includeArchived=true' : ''}`;
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!selectedMailTemplateId) return;
|
||||
useEffect(() => {
|
||||
fetchTemplates(tab === 'archived');
|
||||
setSelectedId(null);
|
||||
setIsCreating(false);
|
||||
setEditor(BLANK_EDITOR);
|
||||
}, [tab, fetchTemplates]);
|
||||
|
||||
setMailTemplates((current) =>
|
||||
current.map((template) =>
|
||||
template.id === selectedMailTemplateId
|
||||
? {
|
||||
...template,
|
||||
name: mailEditor.name.trim() || template.name,
|
||||
subject: mailEditor.subject,
|
||||
html: mailEditor.html,
|
||||
updatedAt: nowIso,
|
||||
}
|
||||
: template
|
||||
)
|
||||
);
|
||||
setMailTemplateSavedMessage('Mail template updated (mock).');
|
||||
};
|
||||
const visibleTemplates = tab === 'archived'
|
||||
? templates.filter((tmpl) => tmpl.is_archived)
|
||||
: templates.filter((tmpl) => !tmpl.is_archived);
|
||||
|
||||
const deleteSelectedMailTemplate = () => {
|
||||
if (!selectedMailTemplateId) return;
|
||||
const selectedTemplate = templates.find((tmpl) => tmpl.id === selectedId) ?? null;
|
||||
|
||||
setMailTemplates((current) => {
|
||||
const next = current.filter((template) => template.id !== selectedMailTemplateId);
|
||||
const nextSelected = next[0] ?? null;
|
||||
setSelectedMailTemplateId(nextSelected?.id ?? '');
|
||||
setIsCreatingMailTemplate(false);
|
||||
setMailEditor({
|
||||
name: nextSelected?.name ?? '',
|
||||
subject: nextSelected?.subject ?? '',
|
||||
html: nextSelected?.html ?? '',
|
||||
});
|
||||
return next;
|
||||
const openEditor = (template: MailTemplate) => {
|
||||
setIsCreating(false);
|
||||
setSelectedId(template.id);
|
||||
setI18nSyncStatus('idle');
|
||||
setEditor({
|
||||
template_type: template.template_type,
|
||||
name: template.name,
|
||||
subject: template.subject ?? '',
|
||||
html_content: template.html_content,
|
||||
});
|
||||
|
||||
setMailTemplateSavedMessage('Mail template removed (mock).');
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedId(null);
|
||||
setI18nSyncStatus('idle');
|
||||
setEditor(BLANK_EDITOR);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
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');
|
||||
}
|
||||
|
||||
// Sync content to i18n system so it's translatable in Language Management
|
||||
if (savedTemplateType?.trim()) {
|
||||
setI18nSyncStatus('syncing');
|
||||
syncToI18n(savedTemplateType, savedSubject, savedHtmlContent)
|
||||
.then(() => setI18nSyncStatus('synced'))
|
||||
.catch(() => setI18nSyncStatus('error'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast({ type: 'error', message: e?.message || 'Failed to save template.' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: number) => {
|
||||
setActionLoadingId(id);
|
||||
try {
|
||||
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/activate`, { 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 activated.' });
|
||||
await fetchTemplates(tab === 'archived');
|
||||
} catch (e: any) {
|
||||
showToast({ type: 'error', message: e?.message || 'Failed to activate template.' });
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
Mail Templates
|
||||
<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>
|
||||
{t('autofix.kd93a60af')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tab === 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
+ New Mail Template
|
||||
</button>
|
||||
)}
|
||||
{editorOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
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"
|
||||
>
|
||||
{isSaving ? 'Saving…' : (isCreating ? 'Create' : 'Save')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCreateMailTemplate}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
+ New Mail Template
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveMailTemplate}
|
||||
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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteSelectedMailTemplate}
|
||||
disabled={isCreatingMailTemplate || !selectedMailTemplateId}
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
<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>
|
||||
|
||||
<p className="mb-5 text-sm text-slate-600">
|
||||
Frontend-only mock editor. Data is stored in local component state and resets on page reload.
|
||||
</p>
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={() => setTab('archived')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||
tab === 'archived' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Archived
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mailTemplateSavedMessage && (
|
||||
<div className="mb-5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
{mailTemplateSavedMessage}
|
||||
{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 lg:grid-cols-[290px_minmax(0,1fr)]">
|
||||
<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">Template List</h3>
|
||||
<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">
|
||||
{mailTemplates.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
||||
No templates yet.
|
||||
</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'}
|
||||
{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
|
||||
</button>
|
||||
|
||||
{isBusy && (
|
||||
<svg className="ml-1 w-3.5 h-3.5 animate-spin text-slate-400" 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="block md:col-span-2">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Template Name</div>
|
||||
<input
|
||||
value={mailEditor.name}
|
||||
onChange={(event) => setMailEditor((current) => ({ ...current, name: event.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"
|
||||
placeholder="Welcome Mail"
|
||||
/>
|
||||
</label>
|
||||
{/* Editor + Preview */}
|
||||
{editorOpen && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Template Type</div>
|
||||
<input
|
||||
value={editor.template_type}
|
||||
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 md:col-span-2">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
|
||||
<input
|
||||
value={mailEditor.subject}
|
||||
onChange={(event) => setMailEditor((current) => ({ ...current, subject: event.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"
|
||||
placeholder="Subject with placeholders like {{firstName}}"
|
||||
/>
|
||||
</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"
|
||||
placeholder="Welcome Mail"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:col-span-2">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">HTML Body</div>
|
||||
<textarea
|
||||
value={mailEditor.html}
|
||||
onChange={(event) => setMailEditor((current) => ({ ...current, html: event.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"
|
||||
placeholder="<div>Your HTML here</div>"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="mb-3 text-xs text-slate-500">
|
||||
Subject: <span className="font-medium text-slate-800">{mailEditor.subject || 'No subject'}</span>
|
||||
{selectedMailTemplate && !isCreatingMailTemplate && (
|
||||
<span className="ml-3">Last update: {new Date(selectedMailTemplate.updatedAt).toLocaleString()}</span>
|
||||
{/* 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">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
|
||||
<input
|
||||
value={editor.subject}
|
||||
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"
|
||||
placeholder="Subject with placeholders like {{firstName}}"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:col-span-2">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kb56e3ea8')}</div>
|
||||
<textarea
|
||||
value={editor.html_content}
|
||||
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"
|
||||
placeholder="<div>Your HTML here</div>"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||
<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="mb-3 text-xs text-slate-500">
|
||||
{t('autofix.k64efb463')}
|
||||
<span className="font-medium text-slate-800">{editor.subject || 'No subject'}</span>
|
||||
{selectedTemplate && !isCreating && (
|
||||
<span className="ml-3">Last update: {new Date(selectedTemplate.updated_at).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-slate-800"
|
||||
dangerouslySetInnerHTML={{ __html: editor.html_content || '<p class="text-slate-500">No HTML content</p>' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-slate-800"
|
||||
dangerouslySetInnerHTML={{ __html: mailEditor.html || '<p class="text-slate-500">No HTML content</p>' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -140,6 +140,7 @@ function CreateNewsModal({
|
||||
creating: boolean
|
||||
error: string | null
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = React.useState('')
|
||||
const [summary, setSummary] = 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 }) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = React.useState(item.title)
|
||||
const [summary, setSummary] = React.useState(item.summary || '')
|
||||
const [content, setContent] = React.useState(item.content || '')
|
||||
|
||||
@ -188,8 +188,8 @@ export function usePoolManageState() {
|
||||
email: String(apiUser.email || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((candidate) => !existingIds.has(candidate.id))
|
||||
.filter((candidate) => `${candidate.name} ${candidate.email}`.toLowerCase().includes(normalizedQuery))
|
||||
.filter((candidate: UserCandidate) => !existingIds.has(candidate.id))
|
||||
.filter((candidate: UserCandidate) => `${candidate.name} ${candidate.email}`.toLowerCase().includes(normalizedQuery))
|
||||
|
||||
setCandidates(mapped)
|
||||
} catch (requestError: any) {
|
||||
|
||||
@ -464,6 +464,87 @@ function ensureUseTranslationHooksInComponents(content: string): { content: stri
|
||||
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> } {
|
||||
const keyValueMap = new Map<string, string>();
|
||||
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');
|
||||
|
||||
// Only rewrite inside JSX tag bodies, never in TS/JS function params or object literals.
|
||||
const next = content.replace(/<[^>]+>/g, (tag) =>
|
||||
tag.replace(attrRegex, (full, attrName: string, quote: string, captured: string) => {
|
||||
const next = content.replace(/<[^>]+>/g, (tag, tagOffset: number) => {
|
||||
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();
|
||||
if (shouldIgnoreLiteral(text)) return full;
|
||||
|
||||
@ -481,8 +566,8 @@ function replaceJsxAttributeLiterals(content: string): { content: string; replac
|
||||
keyValueMap.set(key, text);
|
||||
replacements += 1;
|
||||
return `${attrName}={t('${key}')}`;
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return { content: next, replacements, keyValueMap };
|
||||
}
|
||||
@ -492,7 +577,11 @@ function replaceJsxTextNodes(content: string): { content: string; replacements:
|
||||
let replacements = 0;
|
||||
|
||||
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();
|
||||
if (shouldIgnoreLiteral(text)) return full;
|
||||
|
||||
@ -515,6 +604,8 @@ function upsertAutofixNamespace(content: string, entries: Map<string, string>):
|
||||
const indent = match[1] ?? ' ';
|
||||
const blockHeader = match[2] ?? 'autofix: {';
|
||||
const blockBody = match[3] ?? '';
|
||||
const trimmedBlockBody = blockBody.replace(/\s*$/, '');
|
||||
const trailingWhitespace = blockBody.slice(trimmedBlockBody.length);
|
||||
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)) {
|
||||
const keyName = m[1] || m[2];
|
||||
@ -543,7 +634,12 @@ function upsertAutofixNamespace(content: string, entries: Map<string, string>):
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -767,8 +863,8 @@ async function addMissingAutofixKeysToTranslations(missingKeys: string[]): Promi
|
||||
return { createdKeys: [] };
|
||||
}
|
||||
|
||||
const enFlat = flattenObject(en as Record<string, unknown>);
|
||||
const deFlat = flattenObject(de as Record<string, unknown>);
|
||||
const enFlat = flattenObject(en as unknown as Record<string, unknown>);
|
||||
const deFlat = flattenObject(de as unknown as Record<string, unknown>);
|
||||
const entriesForEn = new Map<string, string>();
|
||||
const entriesForDe = new Map<string, string>();
|
||||
|
||||
@ -823,7 +919,7 @@ async function runWorkspaceScan(): Promise<ScanResult> {
|
||||
|
||||
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 missingKeyFiles: MissingKeyMap = new Map();
|
||||
const untranslatedLiteralFiles: Map<string, Set<string>> = new Map();
|
||||
|
||||
@ -24,17 +24,17 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
|
||||
const buttonCls =
|
||||
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-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 =
|
||||
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-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) =>
|
||||
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]/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 (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
@ -45,7 +45,7 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
|
||||
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'
|
||||
: '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}
|
||||
@ -63,7 +63,7 @@ export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcher
|
||||
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'
|
||||
: '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}
|
||||
|
||||
@ -328,6 +328,7 @@ export default function QuickActions() {
|
||||
|
||||
// Email Verification Modal Component
|
||||
function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
@ -461,6 +462,7 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
|
||||
onClose: () => void,
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [frontFile, setFrontFile] = useState<File | null>(null)
|
||||
const [backFile, setBackFile] = useState<File | null>(null)
|
||||
const [idType, setIdType] = useState('')
|
||||
@ -634,6 +636,7 @@ function ProfileCompletionModal({ userType, onClose, onSuccess }: {
|
||||
onClose: () => void,
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<any>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@ -758,6 +761,7 @@ function ContractSigningModal({ userType, onClose, onSuccess }: {
|
||||
onClose: () => void,
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [contractFile, setContractFile] = useState<File | null>(null)
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@ -833,12 +833,8 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
</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="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 && (
|
||||
<button
|
||||
onClick={() => { handleLogout(); setMobileMenuOpen(false); }}
|
||||
|
||||
@ -21,6 +21,7 @@ export function PaginationPrevious({
|
||||
className,
|
||||
children = 'Previous',
|
||||
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className={clsx(className, 'grow basis-0')}>
|
||||
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k5b7042c7')}>
|
||||
@ -43,6 +44,7 @@ export function PaginationNext({
|
||||
className,
|
||||
children = 'Next',
|
||||
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
|
||||
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k17581b31')}>
|
||||
|
||||
@ -25,6 +25,7 @@ function CloseMenuIcon() {
|
||||
}
|
||||
|
||||
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||
<Headless.DialogBackdrop
|
||||
@ -53,6 +54,7 @@ export function SidebarLayout({
|
||||
sidebar,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||
const { t } = useTranslation();
|
||||
let [showSidebar, setShowSidebar] = useState(false)
|
||||
|
||||
return (
|
||||
|
||||
@ -25,6 +25,7 @@ function CloseMenuIcon() {
|
||||
}
|
||||
|
||||
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||
<Headless.DialogBackdrop
|
||||
@ -53,6 +54,7 @@ export function StackedLayout({
|
||||
sidebar,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||
const { t } = useTranslation();
|
||||
let [showSidebar, setShowSidebar] = useState(false)
|
||||
|
||||
return (
|
||||
|
||||
@ -1949,7 +1949,26 @@ export const de: Translations = {
|
||||
"k4e2a8d19": "Seite",
|
||||
"k5a9d3c27": "ausstehende Benutzer",
|
||||
"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": {
|
||||
"loginSuccess": "Anmeldung erfolgreich",
|
||||
@ -1973,5 +1992,6 @@ export const de: Translations = {
|
||||
"deleteSuccess": "Erfolgreich gelöscht.",
|
||||
"deleteFailed": "Löschen fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"genericError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"mailTemplates": {}
|
||||
};
|
||||
|
||||
@ -1949,7 +1949,26 @@ export const en: Translations = {
|
||||
"k4e2a8d19": "Page",
|
||||
"k5a9d3c27": "pending 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": {
|
||||
"loginSuccess": "Login successful",
|
||||
@ -1973,5 +1992,6 @@ export const en: Translations = {
|
||||
"deleteSuccess": "Deleted successfully.",
|
||||
"deleteFailed": "Could not delete. Please try again.",
|
||||
"genericError": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"mailTemplates": {}
|
||||
};
|
||||
|
||||
@ -1019,6 +1019,7 @@ export interface Translations {
|
||||
};
|
||||
|
||||
autofix: Record<string, string>;
|
||||
mailTemplates: Record<string, string>;
|
||||
|
||||
// ─── Notifications / Toasts ────────────────────────────
|
||||
toasts: {
|
||||
|
||||
@ -53,11 +53,8 @@ interface I18nProviderProps {
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: I18nProviderProps) {
|
||||
const [language, setLanguage] = useState<string>(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_LANGUAGE;
|
||||
const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY);
|
||||
return stored && stored.trim() ? stored : DEFAULT_LANGUAGE;
|
||||
});
|
||||
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
|
||||
const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false);
|
||||
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
|
||||
languages: [
|
||||
{ code: 'en', name: 'English' },
|
||||
@ -100,6 +97,17 @@ export function I18nProvider({ children }: I18nProviderProps) {
|
||||
void 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(() => {
|
||||
if (translationFiles.languages.length === 0) return;
|
||||
if (translationFiles.languages.some((entry) => entry.code === language)) return;
|
||||
@ -111,9 +119,10 @@ export function I18nProvider({ children }: I18nProviderProps) {
|
||||
}, [translationFiles.languages, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === 'undefined' || !hasSyncedStoredLanguage) return;
|
||||
window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language);
|
||||
}, [language]);
|
||||
document.documentElement.lang = language;
|
||||
}, [hasSyncedStoredLanguage, language]);
|
||||
|
||||
const t = useCallback((key: string): string => {
|
||||
// 1. Check translation loaded from translation files API.
|
||||
|
||||
@ -10,6 +10,7 @@ import Waves from '../components/background/waves'
|
||||
import { ToastProvider, useToast } from '../components/toast/toastComponent'
|
||||
|
||||
function PasswordResetPageInner() {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
@ -339,7 +340,6 @@ function PasswordResetPageInner() {
|
||||
}
|
||||
|
||||
export default function PasswordResetPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Suspense
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
|
||||
import { createReferralLink } from '../hooks/generateReferralLink'
|
||||
import { useToast } from '../../components/toast/toastComponent'
|
||||
import { useTranslation } from '../../i18n/useTranslation'
|
||||
|
||||
interface Props {
|
||||
@ -11,6 +12,7 @@ interface Props {
|
||||
|
||||
export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
// Defaults: Unlimited + Never expires
|
||||
const [maxUses, setMaxUses] = useState<string>('-1')
|
||||
const [expiresInDays, setExpiresInDays] = useState<string>('-1')
|
||||
@ -95,9 +97,27 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
||||
|
||||
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()
|
||||
} catch {
|
||||
// optional error handling
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: t('referralManagement.createLink'),
|
||||
message: t('referralManagement.createError'),
|
||||
})
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
@ -108,8 +128,18 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
||||
try {
|
||||
setIsCopying(true)
|
||||
await navigator.clipboard.writeText(generatedLink)
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: t('referralManagement.copyLink'),
|
||||
message: t('referralManagement.copiedMessage'),
|
||||
})
|
||||
setTimeout(() => setIsCopying(false), 800)
|
||||
} catch {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: t('referralManagement.copyFailed'),
|
||||
message: t('referralManagement.copyFailedMessage'),
|
||||
})
|
||||
setIsCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user