dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
55 changed files with 2721 additions and 1539 deletions
Showing only changes of commit 4074ea4eee - Show all commits

View File

@ -0,0 +1,25 @@
import { readFileSync } from 'fs';
const enRaw = readFileSync('src/app/i18n/translations/en.ts', 'utf8');
const deRaw = readFileSync('src/app/i18n/translations/de.ts', 'utf8');
// Extract all "key": "value" pairs
const kvRegex = /"(k[0-9a-f]+)": "([^"\\]*(\\.[^"\\]*)*)"/g;
const en = {};
const de = {};
let m;
while ((m = kvRegex.exec(enRaw)) !== null) en[m[1]] = m[2];
kvRegex.lastIndex = 0;
while ((m = kvRegex.exec(deRaw)) !== null) de[m[1]] = m[2];
// Find keys in both with identical values
const same = Object.keys(en).filter(k => de[k] !== undefined && en[k] === de[k]);
console.log(`Keys with identical en/de values (${same.length} total):`);
same.forEach(k => console.log(` ${k}: ${JSON.stringify(en[k])}`));
// Find keys in en that look German (contain typical German words or chars)
const germanPattern = /\b(den|die|das|der|und|nicht|Sie|Ihr|Bitte|werden|wurde|kein|eine|einem|ist|sind|können|bitte|beim|durch|für|mit|Keine|Alle|Alle|beim|oder)\b/;
const germanInEn = Object.keys(en).filter(k => germanPattern.test(en[k]));
console.log(`\nKeys in en.ts that look German (${germanInEn.length} total):`);
germanInEn.forEach(k => console.log(` ${k}: ${JSON.stringify(en[k])}`));

View File

@ -173,9 +173,7 @@ export default function AffiliateManagementPage() {
className="w-full sm:w-auto px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent" className="w-full sm:w-auto px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
> >
{categories.map(cat => ( {categories.map(cat => (
<option key={cat} value={cat}> <option key={cat} value={cat}>{cat === 'all' ? t('autofix.k32a13592') : cat}</option>
{cat === 'all' ? 'All Categories' : cat}
</option>
))} ))}
</select> </select>
</div> </div>
@ -347,11 +345,9 @@ export default function AffiliateManagementPage() {
<div className="col-span-full text-center py-12"> <div className="col-span-full text-center py-12">
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" /> <PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('autofix.k19f2c5dc')}</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">{t('autofix.k19f2c5dc')}</h3>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">{searchQuery || categoryFilter !== 'all'
{searchQuery || categoryFilter !== 'all' ? t('autofix.k6c341c65')
? 'Try adjusting your search or filter' : t('autofix.kfd0ee006')}</p>
: 'Get started by adding a new affiliate partner'}
</p>
</div> </div>
)} )}
</div> </div>
@ -877,9 +873,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
alt="Logo" alt="Logo"
className="max-h-[180px] max-w-full object-contain" className="max-h-[180px] max-w-full object-contain"
/> />
<p className="mt-2 text-xs text-gray-600"> <p className="mt-2 text-xs text-gray-600">{logoFile ? t('autofix.k55d88592') : t('autofix.k86d84b6d')}</p>
{logoFile ? '🆕 New logo selected' : '📷 Current logo'}
</p>
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {

View File

@ -231,9 +231,7 @@ export default function CompanySettingsPanel() {
onChange={e => handleQrUpload('60', e.target.files?.[0] || null)} onChange={e => handleQrUpload('60', e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100" className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
/> />
<div className="mt-1 text-xs text-gray-500"> <div className="mt-1 text-xs text-gray-500">{qr60DataUrl ? 'Selected (will be saved on Save)' : hasQr60 ? t('autofix.k0422a021') : t('autofix.k867bfd52')}</div>
{qr60DataUrl ? 'Selected (will be saved on Save)' : hasQr60 ? 'Already uploaded' : 'Not uploaded'}
</div>
</div> </div>
<div> <div>
@ -244,9 +242,7 @@ export default function CompanySettingsPanel() {
onChange={e => handleQrUpload('120', e.target.files?.[0] || null)} onChange={e => handleQrUpload('120', e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100" className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
/> />
<div className="mt-1 text-xs text-gray-500"> <div className="mt-1 text-xs text-gray-500">{qr120DataUrl ? 'Selected (will be saved on Save)' : hasQr120 ? t('autofix.k0422a021') : t('autofix.k867bfd52')}</div>
{qr120DataUrl ? 'Selected (will be saved on Save)' : hasQr120 ? 'Already uploaded' : 'Not uploaded'}
</div>
</div> </div>
</div> </div>
@ -261,9 +257,7 @@ export default function CompanySettingsPanel() {
className={`px-5 py-2 rounded-lg text-sm font-semibold text-white transition-colors ${ className={`px-5 py-2 rounded-lg text-sm font-semibold text-white transition-colors ${
saving ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-900 hover:bg-blue-800' saving ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-900 hover:bg-blue-800'
}`} }`}
> >{saving ? t('autofix.kac6cedc7') : 'Save'}</button>
{saving ? 'Saving…' : 'Save'}
</button>
{saved && ( {saved && (
<span className="text-sm text-green-600 font-medium">{t('autofix.ka29ac729')}</span> <span className="text-sm text-green-600 font-medium">{t('autofix.ka29ac729')}</span>
)} )}

View File

@ -283,9 +283,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
type="button" type="button"
onClick={() => setIsPreview((v) => !v)} onClick={() => setIsPreview((v) => !v)}
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition" className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition"
> >{isPreview ? t('autofix.k49165061') : t('autofix.k95d19932')}</button>
{isPreview ? 'Switch to Code' : 'Preview HTML'}
</button>
{isPreview && ( {isPreview && (
<button <button
type="button" type="button"

View File

@ -653,7 +653,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
<StatusBadge status={template.status} /> <StatusBadge status={template.status} />
</div> </div>
<p className="mt-2 truncate text-sm font-medium text-slate-900">{template.name}</p> <p className="mt-2 truncate text-sm font-medium text-slate-900">{template.name}</p>
<div className="mt-1 text-xs text-slate-500">{formatTimestamp(template.updatedAt) || 'No update time'}</div> <div className="mt-1 text-xs text-slate-500">{formatTimestamp(template.updatedAt) || t('autofix.kee838580')}</div>
</div> </div>
<div className="flex flex-wrap gap-2 lg:justify-end"> <div className="flex flex-wrap gap-2 lg:justify-end">
@ -728,9 +728,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
onClick={load} onClick={load}
disabled={loading} disabled={loading}
className="inline-flex shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60" className="inline-flex shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
> >{loading ? t('autofix.k832387c5') : 'Refresh'}</button>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div> </div>
</div> </div>
@ -838,11 +836,9 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
)} )}
{!activeFamily && ( {!activeFamily && (
<div className="rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500"> <div className="rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">{items.length === 0
{items.length === 0 ? t('autofix.k3772baff')
? 'No templates available yet. Create the first template to populate this workspace.' : t('autofix.k047a175d')}</div>
: t('autofix.k047a175d')}
</div>
)} )}
</div> </div>
</section> </section>

View File

@ -397,9 +397,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
onClick={confirmUpload} onClick={confirmUpload}
disabled={modalUploading || !modalFile} disabled={modalUploading || !modalFile}
className="text-sm px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60 transition" className="text-sm px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60 transition"
> >{modalUploading ? t('autofix.ka3076020') : 'Upload'}</button>
{modalUploading ? 'Uploading…' : 'Upload'}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useTranslation } from '../../../i18n/useTranslation'; import { useTranslation } from '../../../i18n/useTranslation';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { authFetch } from '../../../utils/authFetch'; import { authFetch } from '../../../utils/authFetch';
import { useToast } from '../../../components/toast/toastComponent'; import { useToast } from '../../../components/toast/toastComponent';
@ -70,16 +70,19 @@ type EditorState = {
html_content: string; html_content: string;
}; };
const BLANK_EDITOR: EditorState = { function createBlankEditor(t: (key: string) => string): EditorState {
return {
template_type: '', template_type: '',
name: '', name: '',
subject: '', 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>', html_content: `<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>${t('autofix.k88f0d12a')}</h2><p>${t('autofix.k4f530782')}</p></div>`,
}; };
}
export default function MailTemplatesManager() { export default function MailTemplatesManager() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showToast } = useToast(); const { showToast } = useToast();
const blankEditor = useMemo(() => createBlankEditor(t), [t]);
const [tab, setTab] = useState<'active' | 'archived'>('active'); const [tab, setTab] = useState<'active' | 'archived'>('active');
const [templates, setTemplates] = useState<MailTemplate[]>([]); const [templates, setTemplates] = useState<MailTemplate[]>([]);
@ -89,7 +92,7 @@ export default function MailTemplatesManager() {
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle'); const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle');
const [editor, setEditor] = useState<EditorState>(BLANK_EDITOR); const [editor, setEditor] = useState<EditorState>(blankEditor);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null); const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
@ -124,8 +127,8 @@ export default function MailTemplatesManager() {
fetchTemplates(tab === 'archived'); fetchTemplates(tab === 'archived');
setSelectedId(null); setSelectedId(null);
setIsCreating(false); setIsCreating(false);
setEditor(BLANK_EDITOR); setEditor(blankEditor);
}, [tab, fetchTemplates]); }, [tab, fetchTemplates, blankEditor]);
const visibleTemplates = tab === 'archived' const visibleTemplates = tab === 'archived'
? templates.filter((tmpl) => tmpl.is_archived) ? templates.filter((tmpl) => tmpl.is_archived)
@ -149,7 +152,7 @@ export default function MailTemplatesManager() {
setIsCreating(true); setIsCreating(true);
setSelectedId(null); setSelectedId(null);
setI18nSyncStatus('idle'); setI18nSyncStatus('idle');
setEditor(BLANK_EDITOR); setEditor(blankEditor);
}; };
const handleSave = async () => { const handleSave = async () => {
@ -179,7 +182,7 @@ export default function MailTemplatesManager() {
savedTemplateType = created.template_type; savedTemplateType = created.template_type;
savedSubject = created.subject ?? editor.subject; savedSubject = created.subject ?? editor.subject;
savedHtmlContent = created.html_content; savedHtmlContent = created.html_content;
showToast({ type: 'success', message: 'Mail template created.' }); showToast({ variant: 'success', message: t('autofix.ke8a3bd92') });
await fetchTemplates(false); await fetchTemplates(false);
setTab('active'); setTab('active');
setIsCreating(false); setIsCreating(false);
@ -205,7 +208,7 @@ export default function MailTemplatesManager() {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`); throw new Error((err as any)?.message || `Error ${res.status}`);
} }
showToast({ type: 'success', message: 'Mail template updated.' }); showToast({ variant: 'success', message: t('autofix.k9535ed27') });
await fetchTemplates(tab === 'archived'); await fetchTemplates(tab === 'archived');
} }
@ -217,7 +220,7 @@ export default function MailTemplatesManager() {
.catch(() => setI18nSyncStatus('error')); .catch(() => setI18nSyncStatus('error'));
} }
} catch (e: any) { } catch (e: any) {
showToast({ type: 'error', message: e?.message || 'Failed to save template.' }); showToast({ variant: 'error', message: e?.message || t('autofix.kb743b7c2') });
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@ -231,10 +234,10 @@ export default function MailTemplatesManager() {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`); throw new Error((err as any)?.message || `Error ${res.status}`);
} }
showToast({ type: 'success', message: 'Template activated.' }); showToast({ variant: 'success', message: t('autofix.ke1a18ce6') });
await fetchTemplates(tab === 'archived'); await fetchTemplates(tab === 'archived');
} catch (e: any) { } catch (e: any) {
showToast({ type: 'error', message: e?.message || 'Failed to activate template.' }); showToast({ variant: 'error', message: e?.message || t('autofix.k1c5f641a') });
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@ -248,21 +251,67 @@ export default function MailTemplatesManager() {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`); throw new Error((err as any)?.message || `Error ${res.status}`);
} }
showToast({ type: 'success', message: 'Template archived.' }); showToast({ variant: 'success', message: t('autofix.k2f162f5d') });
if (selectedId === id) { if (selectedId === id) {
setSelectedId(null); setSelectedId(null);
setEditor(BLANK_EDITOR); setEditor(blankEditor);
} }
await fetchTemplates(tab === 'archived'); await fetchTemplates(tab === 'archived');
} catch (e: any) { } catch (e: any) {
showToast({ type: 'error', message: e?.message || 'Failed to archive template.' }); showToast({ variant: 'error', message: e?.message || t('autofix.kff7b3b21') });
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
}; };
const handleUnarchive = async (id: number) => {
setActionLoadingId(id);
try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/unarchive`, { method: 'PATCH' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || (err as any)?.error || `Error ${res.status}`);
}
showToast({ variant: 'success', message: t('autofix.kc3a71e92') });
setSelectedId(null);
setEditor(blankEditor);
setTab('active');
await fetchTemplates(false);
} catch (e: any) {
showToast({ variant: 'error', message: e?.message || t('autofix.ka91f3c05') });
} finally {
setActionLoadingId(null);
}
};
const removeI18nKeys = async (templateType: string) => {
try {
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
const data = await res.json();
if (!res.ok || !data?.ok) return;
const translations: Record<string, Record<string, string>> = data.translations ?? {};
const sKey = subjectI18nKey(templateType);
const hKey = htmlI18nKey(templateType);
const updated: Record<string, Record<string, string>> = {};
for (const lang of Object.keys(translations)) {
const copy = { ...translations[lang] };
delete copy[sKey];
delete copy[hKey];
updated[lang] = copy;
}
await fetch('/api/i18n/translations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ translations: updated }),
});
} catch {
// best-effort — don't block the delete flow
}
};
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!window.confirm('Delete this mail template? This cannot be undone.')) return; if (!window.confirm(t('autofix.ka63bb731'))) return;
const templateType = templates.find((tmpl) => tmpl.id === id)?.template_type ?? '';
setActionLoadingId(id); setActionLoadingId(id);
try { try {
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}`, { method: 'DELETE' }); const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}`, { method: 'DELETE' });
@ -270,14 +319,17 @@ export default function MailTemplatesManager() {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err as any)?.message || `Error ${res.status}`); throw new Error((err as any)?.message || `Error ${res.status}`);
} }
showToast({ type: 'success', message: 'Template deleted.' }); showToast({ variant: 'success', message: t('autofix.kf6b83106') });
if (selectedId === id) { if (selectedId === id) {
setSelectedId(null); setSelectedId(null);
setEditor(BLANK_EDITOR); setEditor(blankEditor);
} }
await fetchTemplates(tab === 'archived'); await fetchTemplates(tab === 'archived');
if (templateType.trim()) {
await removeI18nKeys(templateType);
}
} catch (e: any) { } catch (e: any) {
showToast({ type: 'error', message: e?.message || 'Failed to delete template.' }); showToast({ variant: 'error', message: e?.message || t('autofix.kccf6593a') });
} finally { } finally {
setActionLoadingId(null); setActionLoadingId(null);
} }
@ -311,9 +363,7 @@ export default function MailTemplatesManager() {
onClick={handleSave} onClick={handleSave}
disabled={isSaving} 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" 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 ? t('autofix.kac6cedc7') : (isCreating ? t('autofix.k987f2b90') : t('autofix.k9f7c3d1e'))}</button>
{isSaving ? 'Saving…' : (isCreating ? 'Create' : 'Save')}
</button>
)} )}
<button <button
type="button" type="button"
@ -323,7 +373,7 @@ export default function MailTemplatesManager() {
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" 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
</button> </button>
</div> </div>
@ -337,9 +387,7 @@ export default function MailTemplatesManager() {
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${ 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' tab === 'active' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`} }`}
> >{t('autofix.kbdcb654a')}</button>
Active Templates
</button>
<button <button
type="button" type="button"
onClick={() => setTab('archived')} onClick={() => setTab('archived')}
@ -360,23 +408,19 @@ export default function MailTemplatesManager() {
<div className={`grid gap-4 ${editorOpen ? 'lg:grid-cols-[290px_minmax(0,1fr)]' : ''}`}> <div className={`grid gap-4 ${editorOpen ? 'lg:grid-cols-[290px_minmax(0,1fr)]' : ''}`}>
{/* Template List */} {/* Template List */}
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3"> <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"> <h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">{tab === 'archived' ? t('autofix.kc097ece0') : t('autofix.k2fbe0857')}</h3>
{tab === 'archived' ? 'Archived Templates' : t('autofix.k2fbe0857')}
</h3>
{isLoading && ( {isLoading && (
<div className="px-2 py-4 text-sm text-slate-400 text-center">Loading</div> <div className="px-2 py-4 text-sm text-slate-400 text-center">{t('autofix.k832387c5')}</div>
)} )}
{!isLoading && visibleTemplates.length === 0 && !isCreating && ( {!isLoading && visibleTemplates.length === 0 && !isCreating && (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500"> <div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">{tab === 'archived' ? t('autofix.k245ba4af') : t('autofix.k247b74e1')}</div>
{tab === 'archived' ? 'No archived templates.' : t('autofix.k247b74e1')}
</div>
)} )}
{isCreating && ( {isCreating && (
<div className="mb-2 rounded-xl border border-slate-900 bg-slate-900 px-3 py-2 text-white"> <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="text-sm font-semibold truncate">{t('autofix.k2f343849')}</div>
<div className="mt-0.5 text-[11px] text-slate-300">Draft</div> <div className="mt-0.5 text-[11px] text-slate-300">Draft</div>
</div> </div>
)} )}
@ -435,9 +479,7 @@ export default function MailTemplatesManager() {
)} )}
{!template.is_archived && template.is_active && ( {!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"> <span className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-100 text-emerald-700 cursor-default select-none">{t('autofix.k26404a1a')}</span>
Active
</span>
)} )}
{!template.is_archived && ( {!template.is_archived && (
@ -452,6 +494,18 @@ export default function MailTemplatesManager() {
</button> </button>
)} )}
{template.is_archived && (
<button
type="button"
title="Unarchive"
disabled={isBusy}
onClick={() => handleUnarchive(template.id)}
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-sky-50 text-sky-700 hover:bg-sky-100 transition disabled:opacity-50"
>
Unarchive
</button>
)}
<button <button
type="button" type="button"
title="Delete" title="Delete"
@ -481,12 +535,12 @@ export default function MailTemplatesManager() {
<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"> <label className="block">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Template Type</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fb166ad')}</div>
<input <input
value={editor.template_type} value={editor.template_type}
onChange={(e) => { setEditor((s) => ({ ...s, template_type: e.target.value })); setI18nSyncStatus('idle'); }} 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" 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" placeholder={t('autofix.k1a7aa84d')}
/> />
</label> </label>
@ -496,7 +550,7 @@ export default function MailTemplatesManager() {
value={editor.name} value={editor.name}
onChange={(e) => setEditor((s) => ({ ...s, name: e.target.value }))} 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={t('autofix.k32764a91')}
/> />
</label> </label>
@ -507,29 +561,25 @@ export default function MailTemplatesManager() {
<div className="flex items-center gap-1.5 text-xs font-semibold text-sky-700"> <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"> <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" /> <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> </svg>{t('autofix.k48b366e4')}</div>
Translation Keys edit in Language Management
</div>
{i18nSyncStatus === 'syncing' && ( {i18nSyncStatus === 'syncing' && (
<span className="text-[11px] text-sky-500 flex items-center gap-1"> <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> <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>{t('autofix.ke4326584')}</span>
Syncing
</span>
)} )}
{i18nSyncStatus === 'synced' && ( {i18nSyncStatus === 'synced' && (
<span className="text-[11px] text-emerald-600 font-semibold"> Synced to Language Management</span> <span className="text-[11px] text-emerald-600 font-semibold">{t('autofix.kf5fec72a')}</span>
)} )}
{i18nSyncStatus === 'error' && ( {i18nSyncStatus === 'error' && (
<span className="text-[11px] text-rose-600 font-semibold"> Sync failed (content saved to DB)</span> <span className="text-[11px] text-rose-600 font-semibold">{t('autofix.k5321f8f0')}</span>
)} )}
</div> </div>
<div className="flex flex-col gap-1 text-[11px] font-mono text-sky-800"> <div className="flex flex-col gap-1 text-[11px] font-mono text-sky-800">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-sky-400 select-none">subject:</span> <span className="text-sky-400 select-none">{t('autofix.k8aea9103')}</span>
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{subjectI18nKey(editor.template_type)}</span> <span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{subjectI18nKey(editor.template_type)}</span>
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-sky-400 select-none">html:</span> <span className="text-sky-400 select-none">{t('autofix.k0aa53382')}</span>
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{htmlI18nKey(editor.template_type)}</span> <span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{htmlI18nKey(editor.template_type)}</span>
</span> </span>
</div> </div>
@ -552,7 +602,7 @@ export default function MailTemplatesManager() {
value={editor.html_content} value={editor.html_content}
onChange={(e) => setEditor((s) => ({ ...s, html_content: e.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={t('autofix.k8735e9a4')}
/> />
</label> </label>
</div> </div>
@ -563,14 +613,14 @@ export default function MailTemplatesManager() {
<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">
{t('autofix.k64efb463')} {t('autofix.k64efb463')}
<span className="font-medium text-slate-800">{editor.subject || 'No subject'}</span> <span className="font-medium text-slate-800">{editor.subject || t('autofix.k1b76fc38')}</span>
{selectedTemplate && !isCreating && ( {selectedTemplate && !isCreating && (
<span className="ml-3">Last update: {new Date(selectedTemplate.updated_at).toLocaleString()}</span> <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: editor.html_content || '<p class="text-slate-500">No HTML content</p>' }} dangerouslySetInnerHTML={{ __html: editor.html_content || `<p class="text-slate-500">${t('autofix.k5201934d')}</p>` }}
/> />
</div> </div>
</div> </div>

View File

@ -80,9 +80,7 @@ export default function AdminDashboardManagementPage() {
: 'inline-flex items-center justify-center gap-2 rounded-2xl bg-[#8D6B1D] text-white px-4 py-2 text-sm font-semibold hover:bg-[#7A5E1A]' : 'inline-flex items-center justify-center gap-2 rounded-2xl bg-[#8D6B1D] text-white px-4 py-2 text-sm font-semibold hover:bg-[#7A5E1A]'
} }
> >
<CheckIcon className="h-5 w-5" /> <CheckIcon className="h-5 w-5" />{saving ? t('autofix.kac6cedc7') : 'Save'}</button>
{saving ? 'Saving…' : 'Save'}
</button>
</div> </div>
</header> </header>

View File

@ -433,8 +433,7 @@ export default function DevManagementPage() {
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0} disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60" className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
> >
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Creating...' : 'Create All'} <WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Creating...' : t('autofix.k1db77fc0')}</button>
</button>
</div> </div>
</div> </div>
@ -545,8 +544,7 @@ export default function DevManagementPage() {
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0} disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60" className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
> >
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Moving...' : 'Move All to Contract'} <WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Moving...' : t('autofix.k0188c7bc')}</button>
</button>
</div> </div>
</div> </div>

View File

@ -66,9 +66,7 @@ export default function VatEditPage() {
className="hidden" className="hidden"
onChange={e => onImport(e.target.files?.[0] || null)} onChange={e => onImport(e.target.files?.[0] || null)}
disabled={importing} disabled={importing}
/> />{importing ? 'Importing...' : t('autofix.k8a59f09e')}</label>
{importing ? 'Importing...' : 'Import CSV'}
</label>
<button <button
onClick={() => exportVatCsv(rates)} onClick={() => exportVatCsv(rates)}
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50" className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"

View File

@ -146,7 +146,7 @@ export default function CategoryManagerModal({
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-500 shadow-sm"> <span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-500 shadow-sm">
{cat.namespaces.length} {cat.namespaces.length}
</span> </span>
<span className="text-xs text-slate-400">{isExpanded ? '▴ Hide' : '▾ Manage'}</span> <span className="text-xs text-slate-400">{isExpanded ? t('autofix.k5daa1471') : t('autofix.k893106ba')}</span>
</div> </div>
{cat.isCustom && ( {cat.isCustom && (
<button <button

View File

@ -156,11 +156,9 @@ export default function TranslationWizardModal({
onClick={onNext} onClick={onNext}
disabled={isSavingStep || (wizardInput.trim() === '' && !wizardMarkGlobal && !wizardUseEnglishReference)} disabled={isSavingStep || (wizardInput.trim() === '' && !wizardMarkGlobal && !wizardUseEnglishReference)}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50" className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50"
> >{isSavingStep
{isSavingStep
? t('common.saving') ? t('common.saving')
: (wizardIndex >= wizardMissingCount - 1 ? 'Save and finish' : 'Save and next')} : (wizardIndex >= wizardMissingCount - 1 ? t('autofix.k230e2c3c') : t('autofix.kb270a988'))}</button>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -609,7 +609,7 @@ export default function LanguageManagementPage() {
showToast({ showToast({
variant: 'info', variant: 'info',
message: 'Reloading page to refresh namespace and key updates from auto-fix output.', message: t('autofix.k3871d88e'),
duration: 1800, duration: 1800,
}); });
@ -866,9 +866,7 @@ export default function LanguageManagementPage() {
onClick={handleSaveAll} onClick={handleSaveAll}
disabled={isSavingPreferences} disabled={isSavingPreferences}
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition disabled:opacity-50" className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition disabled:opacity-50"
> >{isSavingPreferences ? t('autofix.kac6cedc7') : 'Save'}</button>
{isSavingPreferences ? 'Saving…' : 'Save'}
</button>
</div> </div>
</div> </div>
)} )}

View File

@ -323,9 +323,7 @@ export default function SearchModal({
type="submit" type="submit"
disabled={loading || query.trim().length < 3} disabled={loading || query.trim().length < 3}
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition" className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
> >{loading ? t('autofix.kdff3e58d') : 'Search'}</button>
{loading ? 'Searching…' : 'Search'}
</button>
<button <button
type="button" type="button"
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }} onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
@ -486,9 +484,7 @@ export default function SearchModal({
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
title={addDisabledReason || undefined} // NEW title={addDisabledReason || undefined} // NEW
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition" className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
> >{adding ? t('autofix.k12f2d162') : t('autofix.k59b7a324')}</button>
{adding ? 'Adding…' : 'Add to Matrix'}
</button>
</div> </div>
</div> </div>
)} )}

View File

@ -396,11 +396,9 @@ export default function MatrixManagementPage() {
${m.status === 'active' ${m.status === 'active'
? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60' ? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
: 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`} : 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
> >{mutatingId === m.id
{mutatingId === m.id ? (m.status === 'active' ? t('autofix.k871d457e') : t('autofix.k5bcb3e1f'))
? (m.status === 'active' ? 'Deactivating…' : 'Activating…') : (m.status === 'active' ? 'Deactivate' : 'Activate')}</button>
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
</button>
<span className="text-[11px] text-gray-500">{t('autofix.k27f56959')}</span> <span className="text-[11px] text-gray-500">{t('autofix.k27f56959')}</span>
<button <button
className="text-sm font-medium text-blue-900 hover:text-blue-700" className="text-sm font-medium text-blue-900 hover:text-blue-700"

View File

@ -255,9 +255,7 @@ function CreateNewsModal({
type="submit" type="submit"
disabled={creating || !title.trim()} disabled={creating || !title.trim()}
className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed"
> >{creating ? t('autofix.k27b5b842') : t('autofix.k75078d0b')}</button>
{creating ? 'Creating…' : 'Add News'}
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -27,6 +27,9 @@ export default function CreateSubscriptionPage() {
const [showCropModal, setShowCropModal] = useState(false); const [showCropModal, setShowCropModal] = useState(false);
const [currency, setCurrency] = useState('EUR'); const [currency, setCurrency] = useState('EUR');
const [isFeatured, setIsFeatured] = useState(false); const [isFeatured, setIsFeatured] = useState(false);
// Gallery images (multi-upload, no crop)
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
// Fixed billing defaults (locked: month / 1) // Fixed billing defaults (locked: month / 1)
const billingInterval: 'month' = 'month'; const billingInterval: 'month' = 'month';
const intervalCount: number = 1; const intervalCount: number = 1;
@ -42,7 +45,8 @@ export default function CreateSubscriptionPage() {
currency, currency,
is_featured: isFeatured, is_featured: isFeatured,
state: state === 'available', state: state === 'available',
pictureFile pictureFile,
pictureFiles: galleryFiles.length ? galleryFiles : undefined,
}); });
router.push('/admin/subscriptions'); router.push('/admin/subscriptions');
} catch (e: any) { } catch (e: any) {
@ -55,8 +59,9 @@ export default function CreateSubscriptionPage() {
return () => { return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl); if (previewUrl) URL.revokeObjectURL(previewUrl);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc); if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
galleryPreviews.forEach(u => URL.revokeObjectURL(u));
}; };
}, []); }, [previewUrl, originalImageSrc, galleryPreviews]);
function handleSelectFile(file?: File) { function handleSelectFile(file?: File) {
if (!file) return; if (!file) return;
@ -71,6 +76,7 @@ export default function CreateSubscriptionPage() {
} }
setError(null); setError(null);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
// Create object URL for cropping // Create object URL for cropping
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
setOriginalImageSrc(url); setOriginalImageSrc(url);
@ -83,36 +89,82 @@ export default function CreateSubscriptionPage() {
setPictureFile(croppedFile); setPictureFile(croppedFile);
// Create preview URL // Create preview URL
if (previewUrl) URL.revokeObjectURL(previewUrl);
const url = URL.createObjectURL(croppedBlob); const url = URL.createObjectURL(croppedBlob);
setPreviewUrl(url); setPreviewUrl(url);
} }
return ( function handleAddGalleryFiles(files: FileList | null) {
<PageLayout> if (!files || files.length === 0) return;
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen"> const allowed = ['image/jpeg', 'image/png', 'image/webp'];
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8"> const newFiles: File[] = [];
{/* Header */} const newPreviews: string[] = [];
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8"> for (const file of Array.from(files)) {
<div className="flex items-center justify-between"> if (!allowed.includes(file.type)) {
<div> setError(`"${file.name}" is not a valid image type (JPG, PNG, WebP only).`);
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kaa30f0cd')}</h1> continue;
<p className="text-lg text-blue-700 mt-2">{t('autofix.kf72d41db')}</p> }
</div> if (file.size > 10 * 1024 * 1024) {
<Link href="/admin/subscriptions" setError(`"${file.name}" exceeds the 10MB limit.`);
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition" continue;
> }
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>{t('autofix.kd8a5ad17')}</Link> newFiles.push(file);
</div> newPreviews.push(URL.createObjectURL(file));
</header> }
setGalleryFiles(prev => [...prev, ...newFiles]);
setGalleryPreviews(prev => [...prev, ...newPreviews]);
}
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg"> function handleRemoveGalleryImage(index: number) {
<form onSubmit={onCreate} className="space-y-8"> setGalleryPreviews(prev => {
{/* Picture Upload moved to top */} URL.revokeObjectURL(prev[index]);
return prev.filter((_, i) => i !== index);
});
setGalleryFiles(prev => prev.filter((_, i) => i !== index));
}
function handleSetThumbnailFromGallery(index: number) {
const source = galleryFiles[index];
if (!source) return;
setError(null);
const thumbFile = new File([source], source.name, { type: source.type });
setPictureFile(thumbFile);
if (previewUrl) URL.revokeObjectURL(previewUrl);
const thumbPreview = URL.createObjectURL(source);
setPreviewUrl(thumbPreview);
}
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div> <div>
<label className="block text-sm font-medium text-blue-900 mb-2">Picture</label> <h1 className="text-2xl font-bold tracking-tight text-slate-900">{t('autofix.kaa30f0cd')}</h1>
<p className="text-xs text-gray-600 mb-3">Upload an image and crop it to fit the coffee thumbnail (16:9 aspect ratio, 144px height)</p> <p className="mt-1 text-sm text-slate-500">{t('autofix.kf72d41db')}</p>
</div>
<Link
href="/admin/subscriptions"
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{t('autofix.kd8a5ad17')}
</Link>
</div>
{/* Form card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<form onSubmit={onCreate} className="space-y-8">
{/* Thumbnail */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1">Thumbnail</label>
<p className="text-xs text-slate-500 mb-3">Single image used as the card thumbnail. You can crop it after selecting (16:9, sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">picture</code>).</p>
<div <div
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100" className="relative flex justify-center items-center rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50 cursor-pointer overflow-hidden transition hover:border-slate-400 hover:bg-slate-100"
style={{ minHeight: '400px' }} style={{ minHeight: '400px' }}
onClick={() => document.getElementById('file-upload')?.click()} onClick={() => document.getElementById('file-upload')?.click()}
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
@ -123,41 +175,32 @@ export default function CreateSubscriptionPage() {
> >
{!previewUrl && ( {!previewUrl && (
<div className="text-center w-full px-6 py-10"> <div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" /> <PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-slate-300" />
<div className="mt-4 text-base font-medium text-blue-700"> <div className="mt-4 text-base font-medium text-slate-600">
<span>{t('autofix.k6ee0a1b6')}</span> <span>{t('autofix.k6ee0a1b6')}</span>
</div> </div>
<p className="text-sm text-blue-600 mt-2">{t('autofix.k80ac9651')}</p> <p className="text-sm text-slate-500 mt-2">{t('autofix.k80ac9651')}</p>
<p className="text-xs text-gray-500 mt-2">{t('autofix.k41ab9eb6')}</p> <p className="text-xs text-slate-400 mt-2">{t('autofix.k41ab9eb6')}</p>
</div> </div>
)} )}
{previewUrl && ( {previewUrl && (
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6"> <div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-slate-100 p-6">
<img <img
src={previewUrl} src={previewUrl}
alt="Preview" alt="Preview"
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg" className="max-h-[380px] max-w-full object-contain rounded-xl shadow-lg"
/> />
<div className="absolute top-4 right-4 flex gap-2"> <div className="absolute top-4 right-4 flex gap-2">
<button <button
type="button" type="button"
onClick={e => { onClick={e => { e.stopPropagation(); setShowCropModal(true); }}
e.stopPropagation(); className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-slate-800 shadow hover:bg-white transition"
setShowCropModal(true);
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-blue-900 shadow hover:bg-white transition"
>{t('autofix.k73d1d7d7')}</button> >{t('autofix.k73d1d7d7')}</button>
<button <button
type="button" type="button"
onClick={e => { onClick={e => { e.stopPropagation(); setPictureFile(undefined); setPreviewUrl(null); }}
e.stopPropagation(); className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-rose-600 shadow hover:bg-white transition"
setPictureFile(undefined); >Remove</button>
setPreviewUrl(null);
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
>
Remove
</button>
</div> </div>
</div> </div>
)} )}
@ -172,85 +215,159 @@ export default function CreateSubscriptionPage() {
</div> </div>
</div> </div>
{/* Title moved above description */} {/* Gallery Images */}
<div> <div>
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label> <label className="block text-sm font-semibold text-slate-700 mb-1">Gallery Images</label>
<p className="text-xs text-slate-500 mb-3">
Upload additional product images (JPG, PNG, WebP · max 10 MB each). These are sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">pictures</code> and returned in <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">pictureUrls</code>.
</p>
{/* Gallery grid */}
{galleryPreviews.length > 0 && (
<div className="mb-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
{galleryPreviews.map((url, i) => (
<div key={i} className="relative group rounded-xl overflow-hidden border border-slate-200 bg-slate-50 aspect-video">
<img src={url} alt={`Gallery ${i + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => handleSetThumbnailFromGallery(i)}
className="absolute top-1 right-1 rounded-md bg-white/95 px-2 py-1 text-[10px] font-semibold text-slate-700 shadow hover:bg-white transition"
>
Set thumbnail
</button>
<button
type="button"
onClick={() => handleRemoveGalleryImage(i)}
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition"
aria-label="Remove image"
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<span className="absolute bottom-1 left-1 rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-white font-medium">#{i + 1}</span>
</div>
))}
</div>
)}
{/* Add images button */}
<label
htmlFor="gallery-upload"
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-600 hover:border-slate-400 hover:bg-slate-100 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{galleryFiles.length > 0 ? `Add more (${galleryFiles.length} selected)` : 'Add gallery images'}
<input
id="gallery-upload"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={e => handleAddGalleryFiles(e.target.files)}
/>
</label>
</div>
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-semibold text-slate-700 mb-1">Title</label>
<input <input
id="title" id="title"
name="title" name="title"
required required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="Title" placeholder="Title"
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
/> />
</div> </div>
{/* Description now after title */} {/* Description */}
<div> <div>
<label htmlFor="description" className="block text-sm font-medium text-blue-900">Description</label> <label htmlFor="description" className="block text-sm font-semibold text-slate-700 mb-1">Description</label>
<textarea <textarea
id="description" id="description"
name="description" name="description"
required required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
rows={3} rows={3}
placeholder={t('autofix.k3477c83a')} placeholder={t('autofix.k3477c83a')}
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
/> />
<p className="mt-1 text-xs text-gray-600">{t('autofix.k0affa826')}</p> <p className="mt-1 text-xs text-slate-500">{t('autofix.k0affa826')}</p>
</div> </div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Price */} {/* Price */}
<div> <div>
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label> <label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price</label>
<input <input
id="price" id="price"
name="price" name="price"
required required
min={0.01} min={0.01}
step={0.01} step={0.01}
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
placeholder="0.00"
type="number" type="number"
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="0.00"
value={price} value={price}
onChange={e => { onChange={e => setPrice(e.target.value)}
const val = e.target.value; onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }}
setPrice(val);
}}
onBlur={e => {
const num = parseFloat(e.target.value);
if (!isNaN(num)) {
setPrice(num.toFixed(2));
}
}}
/> />
</div> </div>
{/* Currency */} {/* Currency */}
<div> <div>
<label htmlFor="currency" className="block text-sm font-medium text-blue-900">Currency (e.g., EUR)</label> <label htmlFor="currency" className="block text-sm font-semibold text-slate-700 mb-1">Currency (e.g., EUR)</label>
<input id="currency" name="currency" required maxLength={3} pattern="[A-Za-z]{3}" className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" placeholder="EUR" value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))} /> <input
id="currency"
name="currency"
required
maxLength={3}
pattern="[A-Za-z]{3}"
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
placeholder="EUR"
value={currency}
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0, 3))}
/>
</div> </div>
{/* Featured */} {/* Featured */}
<div className="flex items-center gap-2 mt-6"> <div className="flex items-center gap-3 mt-2">
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} /> <input
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label> id="featured"
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
checked={isFeatured}
onChange={e => setIsFeatured(e.target.checked)}
/>
<label htmlFor="featured" className="text-sm font-semibold text-slate-700">Featured</label>
</div> </div>
{/* Subscription Billing (Locked) + Availability */}
{/* Billing + Availability */}
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-medium text-blue-900">{t('autofix.ka3ee9ded')}</label> <label className="block text-sm font-semibold text-slate-700 mb-1">{t('autofix.ka3ee9ded')}</label>
<p className="mt-1 text-xs text-gray-600">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p> <p className="text-xs text-slate-500 mb-2">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
<div className="mt-2 flex gap-4"> <div className="flex gap-3">
<input disabled value={billingInterval} className="w-40 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" /> <input disabled value={billingInterval} className="w-40 rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
<input disabled value={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" /> <input disabled value={intervalCount} className="w-24 rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
</div> </div>
</div> </div>
<div> <div>
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">Availability</label> <label htmlFor="availability" className="block text-sm font-semibold text-slate-700 mb-1">Availability</label>
<select id="availability" name="availability" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black" value={state} onChange={e => setState(e.target.value as any)}> <select
id="availability"
name="availability"
required
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:ring-2 focus:ring-slate-900 focus:border-transparent"
value={state}
onChange={e => setState(e.target.value as any)}
>
<option value="available">Available</option> <option value="available">Available</option>
<option value="unavailable">Unavailable</option> <option value="unavailable">Unavailable</option>
</select> </select>
@ -258,18 +375,30 @@ export default function CreateSubscriptionPage() {
</div> </div>
</div> </div>
{error && (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-end gap-x-4"> <div className="flex items-center justify-end gap-3 pt-2">
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700"> <Link
href="/admin/subscriptions"
className="rounded-xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition"
>
Cancel Cancel
</Link> </Link>
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">{t('autofix.kaa30f0cd')}</button> <button
type="submit"
className="inline-flex items-center rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-800 transition"
>
{t('autofix.kaa30f0cd')}
</button>
</div> </div>
{error && <p className="text-sm text-red-600">{error}</p>}
</form> </form>
</div> </div>
</main>
</div> </div>
{/* Image Crop Modal */} {/* Image Crop Modal */}

View File

@ -1,26 +1,28 @@
"use client"; "use client";
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import PageLayout from '../../../../components/PageLayout'; import PageLayout from '../../../../components/PageLayout';
import useCoffeeManagement, { CoffeeItem } from '../../hooks/useCoffeeManagement'; import useCoffeeManagement, { CoffeeItem } from '../../hooks/useCoffeeManagement';
import { PhotoIcon } from '@heroicons/react/24/solid'; import { PhotoIcon } from '@heroicons/react/24/solid';
import ImageCropModal from '../../components/ImageCropModal';
import { useTranslation } from '../../../../i18n/useTranslation'; import { useTranslation } from '../../../../i18n/useTranslation';
export default function EditSubscriptionPage() { export default function EditSubscriptionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
// next/navigation app router dynamic param
const params = useParams(); const params = useParams();
const idParam = params?.id; const idParam = params?.id;
const id = typeof idParam === 'string' ? parseInt(idParam, 10) : Array.isArray(idParam) ? parseInt(idParam[0], 10) : NaN; const id = typeof idParam === 'string' ? parseInt(idParam, 10) : Array.isArray(idParam) ? parseInt(idParam[0], 10) : NaN;
const { listProducts, updateProduct } = useCoffeeManagement(); const { listProducts, updateProduct, getProductPictures, updateProductPictures } = useCoffeeManagement();
const [item, setItem] = useState<CoffeeItem | null>(null); const [item, setItem] = useState<CoffeeItem | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [gallerySaving, setGallerySaving] = useState(false);
// Form state // Form state
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -29,11 +31,24 @@ export default function EditSubscriptionPage() {
const [currency, setCurrency] = useState('EUR'); const [currency, setCurrency] = useState('EUR');
const [isFeatured, setIsFeatured] = useState(false); const [isFeatured, setIsFeatured] = useState(false);
const [state, setState] = useState(true); const [state, setState] = useState(true);
// Thumbnail state
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined); const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [showCropModal, setShowCropModal] = useState(false);
const [removeExistingPicture, setRemoveExistingPicture] = useState(false); const [removeExistingPicture, setRemoveExistingPicture] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
// Gallery state
const [existingPictures, setExistingPictures] = useState<{ id: number; url: string }[]>([]);
const [galleryLoading, setGalleryLoading] = useState(false);
const [pendingRemoveIds, setPendingRemoveIds] = useState<number[]>([]);
const [newGalleryFiles, setNewGalleryFiles] = useState<File[]>([]);
const [newGalleryPreviews, setNewGalleryPreviews] = useState<string[]>([]);
const galleryInputRef = useRef<HTMLInputElement | null>(null);
// ── Load product + gallery
useEffect(() => { useEffect(() => {
let active = true; let active = true;
async function load() { async function load() {
@ -43,7 +58,7 @@ export default function EditSubscriptionPage() {
return; return;
} }
try { try {
const all = await listProducts(); const [all] = await Promise.all([listProducts()]);
const found = all.find((p: CoffeeItem) => p.id === id) || null; const found = all.find((p: CoffeeItem) => p.id === id) || null;
if (!active) return; if (!active) return;
if (!found) { if (!found) {
@ -56,7 +71,6 @@ export default function EditSubscriptionPage() {
setCurrency(found.currency || 'EUR'); setCurrency(found.currency || 'EUR');
setIsFeatured(!!found.is_featured); setIsFeatured(!!found.is_featured);
setState(!!found.state); setState(!!found.state);
setRemoveExistingPicture(false);
} }
} catch (e: any) { } catch (e: any) {
if (active) setError(e?.message ?? 'Failed to load subscription'); if (active) setError(e?.message ?? 'Failed to load subscription');
@ -69,14 +83,113 @@ export default function EditSubscriptionPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id]);
// Load gallery pictures once the item is set
useEffect(() => {
if (!id || Number.isNaN(id)) return;
let active = true;
setGalleryLoading(true);
getProductPictures(id)
.then(pics => { if (active) setExistingPictures(pics); })
.catch(() => {})
.finally(() => { if (active) setGalleryLoading(false); });
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
// Cleanup preview URLs
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
newGalleryPreviews.forEach(u => URL.revokeObjectURL(u));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Thumbnail handlers
function handleSelectThumbnail(file?: File) {
if (!file) return;
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.type)) { setError('Invalid image type. Allowed: JPG, PNG, WebP'); return; }
if (file.size > 10 * 1024 * 1024) { setError('Image exceeds 10MB limit'); return; }
setError(null);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
setOriginalImageSrc(URL.createObjectURL(file));
setShowCropModal(true);
}
function handleCropComplete(croppedBlob: Blob) {
const croppedFile = new File([croppedBlob], 'cropped-image.jpg', { type: 'image/jpeg' });
setPictureFile(croppedFile);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(URL.createObjectURL(croppedBlob));
setRemoveExistingPicture(false);
}
// ── Gallery handlers
function handleAddGalleryFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
const newFiles: File[] = [];
const newPreviews: string[] = [];
for (const file of Array.from(files)) {
if (!allowed.includes(file.type)) { setError(`"${file.name}" is not a valid image type.`); continue; }
if (file.size > 10 * 1024 * 1024) { setError(`"${file.name}" exceeds 10MB limit.`); continue; }
newFiles.push(file);
newPreviews.push(URL.createObjectURL(file));
}
setNewGalleryFiles(prev => [...prev, ...newFiles]);
setNewGalleryPreviews(prev => [...prev, ...newPreviews]);
}
function handleRemoveNewGalleryImage(index: number) {
setNewGalleryPreviews(prev => { URL.revokeObjectURL(prev[index]); return prev.filter((_, i) => i !== index); });
setNewGalleryFiles(prev => prev.filter((_, i) => i !== index));
}
function toggleRemoveExistingPicture(picId: number) {
setPendingRemoveIds(prev =>
prev.includes(picId) ? prev.filter(x => x !== picId) : [...prev, picId]
);
}
// ── Save gallery changes
async function handleSaveGallery() {
if (!item) return;
const hasChanges = pendingRemoveIds.length > 0 || newGalleryFiles.length > 0;
if (!hasChanges) return;
setGallerySaving(true);
setError(null);
try {
await updateProductPictures(item.id, {
removeIds: pendingRemoveIds.length > 0 ? pendingRemoveIds : undefined,
addFiles: newGalleryFiles.length > 0 ? newGalleryFiles : undefined,
});
// Refresh existing pictures
const refreshed = await getProductPictures(item.id);
setExistingPictures(refreshed);
setPendingRemoveIds([]);
newGalleryPreviews.forEach(u => URL.revokeObjectURL(u));
setNewGalleryFiles([]);
setNewGalleryPreviews([]);
} catch (e: any) {
setError(e?.message ?? 'Gallery update failed');
} finally {
setGallerySaving(false);
}
}
// ── Submit main form
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!item) return; if (!item) return;
setError(null); setError(null);
setSaving(true);
try { try {
const numericPrice = Number(price); const numericPrice = Number(price);
if (!Number.isFinite(numericPrice) || numericPrice < 0) { if (!Number.isFinite(numericPrice) || numericPrice < 0) {
setError('Price must be a valid non-negative number'); setError('Price must be a valid non-negative number');
setSaving(false);
return; return;
} }
await updateProduct(item.id, { await updateProduct(item.id, {
@ -89,199 +202,282 @@ export default function EditSubscriptionPage() {
pictureFile, pictureFile,
removePicture: removeExistingPicture && !pictureFile ? true : false, removePicture: removeExistingPicture && !pictureFile ? true : false,
}); });
// Also save gallery changes on submit if pending
if (pendingRemoveIds.length > 0 || newGalleryFiles.length > 0) {
await updateProductPictures(item.id, {
removeIds: pendingRemoveIds.length > 0 ? pendingRemoveIds : undefined,
addFiles: newGalleryFiles.length > 0 ? newGalleryFiles : undefined,
});
}
router.push('/admin/subscriptions'); router.push('/admin/subscriptions');
} catch (e: any) { } catch (e: any) {
setError(e?.message ?? 'Update failed'); setError(e?.message ?? 'Update failed');
} finally {
setSaving(false);
} }
} }
useEffect(() => { const existingThumbnail = item?.pictureUrl || null;
if (pictureFile) { const showThumb = previewUrl || (!removeExistingPicture && existingThumbnail);
const url = URL.createObjectURL(pictureFile);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
} else {
setPreviewUrl(null);
}
}, [pictureFile]);
function handleSelectFile(file?: File) { const galleryHasChanges = pendingRemoveIds.length > 0 || newGalleryFiles.length > 0;
if (!file) return;
const allowed = ['image/jpeg','image/png','image/webp'];
if (!allowed.includes(file.type)) {
setError('Invalid image type. Allowed: JPG, PNG, WebP');
return;
}
if (file.size > 10 * 1024 * 1024) {
setError('Image exceeds 10MB limit');
return;
}
setError(null);
setPictureFile(file);
setRemoveExistingPicture(false); // selecting new overrides removal flag
}
return ( return (
<PageLayout> <PageLayout contentClassName="flex-1 relative w-full">
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen"> {showCropModal && originalImageSrc && (
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8"> <ImageCropModal
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8"> isOpen={showCropModal}
<div className="flex items-center justify-between"> imageSrc={originalImageSrc}
<div> onClose={() => setShowCropModal(false)}
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kb06fa395')}</h1> onCropComplete={handleCropComplete}
<p className="text-lg text-blue-700 mt-2">{t('autofix.kb9e483c4')}</p> />
</div> )}
<Link href="/admin/subscriptions"
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>{t('autofix.kd8a5ad17')}</Link>
</div>
</header>
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
{/* Header card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{t('autofix.kb06fa395')}</h1>
<p className="mt-1 text-sm text-slate-500">{t('autofix.kb9e483c4')}</p>
</div>
<Link
href="/admin/subscriptions"
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{t('autofix.kd8a5ad17')}
</Link>
</div>
{/* Error / loading states */}
{loading && ( {loading && (
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">{t('autofix.k2d0798a6')}</div> <div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur text-sm text-slate-500">
{t('autofix.k2d0798a6')}
</div>
)} )}
{error && !loading && ( {error && !loading && (
<div className="rounded-md bg-red-50 p-4 text-red-700 text-sm mb-6">{error}</div> <div className="rounded-[28px] border border-red-100 bg-red-50 px-6 py-4 text-sm text-red-700 shadow-sm">
{error}
</div>
)} )}
{!loading && item && ( {!loading && item && (
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg"> <>
{/* Main form card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-blue-900">Title</label>
<input
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900">Price</label>
<input
type="number"
min={0}
step={0.01}
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={price}
onChange={e => setPrice(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900">Currency</label>
<input
required
maxLength={3}
pattern="[A-Za-z]{3}"
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={currency}
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))}
/>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="flex items-center gap-2">
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
</div>
<div className="flex items-center gap-2">
<input id="enabled" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={state} onChange={e => setState(e.target.checked)} />
<label htmlFor="enabled" className="text-sm font-medium text-blue-900">Enabled</label>
</div>
</div>
</div>
{/* Thumbnail */}
<div> <div>
<label className="block text-sm font-medium text-blue-900">Description</label> <label className="block text-sm font-semibold text-slate-700 mb-1">Thumbnail</label>
<textarea <p className="text-xs text-slate-500 mb-3">Single image used as the card thumbnail. Click to replace (crop available). Sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">picture</code>.</p>
required
rows={4}
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Picture (optional)</label>
<p className="text-xs text-gray-600 mb-3">Upload an image to replace the current picture (16:9 aspect ratio recommended)</p>
<div <div
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100" className="relative flex justify-center items-center rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50 cursor-pointer overflow-hidden transition hover:border-slate-400 hover:bg-slate-100"
style={{ minHeight: '400px' }} style={{ minHeight: '340px' }}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
onDrop={e => { onDrop={e => { e.preventDefault(); if (e.dataTransfer.files?.[0]) handleSelectThumbnail(e.dataTransfer.files[0]); }}
e.preventDefault();
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
}}
> >
{!previewUrl && !item.pictureUrl && ( {!showThumb && (
<div className="text-center w-full px-6 py-10"> <div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" /> <PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-slate-300" />
<div className="mt-4 text-base font-medium text-blue-700"> <div className="mt-4 text-base font-medium text-slate-600"><span>{t('autofix.k2e43a9c4')}</span></div>
<span>{t('autofix.k2e43a9c4')}</span> <p className="text-sm text-slate-500 mt-2">{t('autofix.k80ac9651')}</p>
</div>
<p className="text-sm text-blue-600 mt-2">{t('autofix.k80ac9651')}</p>
</div> </div>
)} )}
{(previewUrl || (!removeExistingPicture && item.pictureUrl)) && ( {showThumb && (
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6"> <div className="relative w-full h-full min-h-[340px] flex items-center justify-center bg-slate-100 p-6">
<img <img
src={previewUrl || item.pictureUrl || ''} src={previewUrl || existingThumbnail || ''}
alt={previewUrl ? "Preview" : item.title} alt={previewUrl ? 'Preview' : item.title}
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg" className="max-h-[320px] max-w-full object-contain rounded-xl shadow-lg"
/> />
<div className="absolute top-4 right-4"> <div className="absolute top-4 right-4 flex gap-2">
<button {previewUrl && (
type="button" <button type="button" onClick={e => { e.stopPropagation(); setShowCropModal(true); }}
onClick={e => { className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-slate-800 shadow hover:bg-white transition">
e.stopPropagation(); {t('autofix.k73d1d7d7')}
if (previewUrl) { </button>
setPictureFile(undefined); )}
setPreviewUrl(null); <button type="button"
} else if (item.pictureUrl) { onClick={e => { e.stopPropagation(); if (previewUrl) { setPictureFile(undefined); setPreviewUrl(null); } else { setRemoveExistingPicture(true); } }}
setRemoveExistingPicture(true); className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-rose-600 shadow hover:bg-white transition">
}
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
>
Remove Remove
</button> </button>
</div> </div>
</div> </div>
)} )}
{removeExistingPicture && !previewUrl && ( <input ref={fileInputRef} type="file" accept="image/*" className="hidden"
<div className="text-center w-full px-6 py-10"> onChange={e => handleSelectThumbnail(e.target.files?.[0])} />
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-gray-400" />
<div className="mt-4 text-base font-medium text-gray-600">
<span>{t('autofix.kd2a00802')}</span>
</div>
<p className="text-sm text-gray-500 mt-2">{t('autofix.k80ac9651')}</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => handleSelectFile(e.target.files?.[0])}
/>
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-x-4"> {/* Title */}
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700"> <div>
<label htmlFor="title" className="block text-sm font-semibold text-slate-700 mb-1">Title</label>
<input id="title" required
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
value={title} onChange={e => setTitle(e.target.value)} />
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-semibold text-slate-700 mb-1">Description</label>
<textarea id="description" required rows={4}
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Price */}
<div>
<label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price</label>
<input id="price" type="number" min={0} step={0.01} required
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
value={price} onChange={e => setPrice(e.target.value)}
onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} />
</div>
{/* Currency */}
<div>
<label htmlFor="currency" className="block text-sm font-semibold text-slate-700 mb-1">Currency (e.g., EUR)</label>
<input id="currency" required maxLength={3} pattern="[A-Za-z]{3}" placeholder="EUR"
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0, 3))} />
</div>
{/* Featured */}
<div className="flex items-center gap-3">
<input id="featured" type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
<label htmlFor="featured" className="text-sm font-semibold text-slate-700">Featured</label>
</div>
{/* Enabled */}
<div className="flex items-center gap-3">
<input id="enabled" type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
checked={state} onChange={e => setState(e.target.checked)} />
<label htmlFor="enabled" className="text-sm font-semibold text-slate-700">Enabled</label>
</div>
</div>
<div className="flex items-center justify-end gap-x-4 pt-2 border-t border-slate-100">
<Link href="/admin/subscriptions" className="text-sm font-semibold text-slate-600 hover:text-slate-900 transition">
Cancel Cancel
</Link> </Link>
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">{t('autofix.k5a489751')}</button> <button type="submit" disabled={saving}
className="inline-flex justify-center items-center gap-2 rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 transition disabled:opacity-60">
{saving && <svg className="w-4 h-4 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-8v8z" /></svg>}
{t('autofix.k5a489751')}
</button>
</div> </div>
{error && <p className="text-sm text-red-600">{error}</p>}
</form> </form>
</div> </div>
{/* Gallery management card */}
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-bold text-slate-900">Gallery Images</h2>
<p className="text-xs text-slate-500 mt-0.5">
Manage additional product images. Mark existing images for removal or add new ones, then click <span className="font-semibold text-slate-700">Save Gallery</span>.
</p>
</div>
{galleryHasChanges && (
<button type="button" onClick={handleSaveGallery} disabled={gallerySaving}
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700 transition disabled:opacity-60">
{gallerySaving && <svg className="w-4 h-4 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-8v8z" /></svg>}
Save Gallery
</button>
)}
</div>
{/* Existing pictures */}
{galleryLoading ? (
<p className="text-sm text-slate-500">Loading galleryâ¦</p>
) : existingPictures.length > 0 ? (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">Existing images</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
{existingPictures.map(pic => {
const markedForRemoval = pendingRemoveIds.includes(pic.id);
return (
<div key={pic.id} className={`relative group rounded-xl overflow-hidden border aspect-video transition ${markedForRemoval ? 'border-rose-400 opacity-50' : 'border-slate-200 bg-slate-50'}`}>
<img src={pic.url} alt="" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => toggleRemoveExistingPicture(pic.id)}
className={`absolute inset-0 flex items-center justify-center transition ${markedForRemoval ? 'bg-rose-500/30 opacity-100' : 'bg-black/40 opacity-0 group-hover:opacity-100'}`}
aria-label={markedForRemoval ? 'Undo remove' : 'Mark for removal'}
>
{markedForRemoval ? (
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
) : (
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</button>
{markedForRemoval && (
<span className="absolute top-1 left-1 rounded bg-rose-500 px-1.5 py-0.5 text-[10px] text-white font-semibold">Remove</span>
)}
</div>
);
})}
</div>
</div>
) : (
<p className="text-sm text-slate-400">No gallery images yet.</p>
)}
{/* New images to upload */}
{newGalleryPreviews.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">New images to add</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
{newGalleryPreviews.map((url, i) => (
<div key={i} className="relative group rounded-xl overflow-hidden border border-emerald-300 bg-slate-50 aspect-video">
<img src={url} alt={`New ${i + 1}`} className="w-full h-full object-cover" />
<button type="button" onClick={() => handleRemoveNewGalleryImage(i)}
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition"
aria-label="Remove">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<span className="absolute bottom-1 left-1 rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] text-white font-semibold">New</span>
</div>
))}
</div>
</div>
)}
{/* Add images button */}
<label htmlFor="gallery-edit-upload"
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-600 hover:border-slate-400 hover:bg-slate-100 transition">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{newGalleryFiles.length > 0 ? `Add more (${newGalleryFiles.length} queued)` : 'Add images'}
<input id="gallery-edit-upload" ref={galleryInputRef} type="file" accept="image/jpeg,image/png,image/webp"
multiple className="hidden" onChange={e => handleAddGalleryFiles(e.target.files)} />
</label>
{pendingRemoveIds.length > 0 && (
<p className="text-xs text-rose-600">
{pendingRemoveIds.length} image{pendingRemoveIds.length > 1 ? 's' : ''} marked for removal. Click <span className="font-semibold">Save Gallery</span> or submit the form above to apply.
</p>
)}
</div>
</>
)} )}
</main>
</div> </div>
</PageLayout> </PageLayout>
); );

View File

@ -14,6 +14,7 @@ export type CoffeeItem = {
original_filename?: string|null; original_filename?: string|null;
state: boolean; state: boolean;
pictureUrl?: string | null; pictureUrl?: string | null;
pictureUrls?: string[] | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
}; };
@ -85,8 +86,9 @@ export default function useCoffeeManagement() {
is_featured?: boolean; is_featured?: boolean;
state?: boolean; state?: boolean;
pictureFile?: File; pictureFile?: File;
pictureFiles?: File[];
}): Promise<CoffeeItem> => { }): Promise<CoffeeItem> => {
const fd = new FormData(); const appendBaseFields = (fd: FormData) => {
fd.append('title', payload.title); fd.append('title', payload.title);
fd.append('description', payload.description); fd.append('description', payload.description);
fd.append('price', String(payload.price)); fd.append('price', String(payload.price));
@ -96,8 +98,33 @@ export default function useCoffeeManagement() {
// Fixed billing defaults // Fixed billing defaults
fd.append('billing_interval', 'month'); fd.append('billing_interval', 'month');
fd.append('interval_count', '1'); fd.append('interval_count', '1');
};
const createLegacyFormData = () => {
const legacy = new FormData();
appendBaseFields(legacy);
const fallbackPicture = payload.pictureFile ?? payload.pictureFiles?.[0];
if (fallbackPicture) legacy.append('picture', fallbackPicture);
return legacy;
};
const hasMultipleGalleryImages = (payload.pictureFiles?.length ?? 0) > 1;
if (hasMultipleGalleryImages) {
const fd = new FormData();
appendBaseFields(fd);
if (payload.pictureFile) fd.append('picture', payload.pictureFile); if (payload.pictureFile) fd.append('picture', payload.pictureFile);
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd }); for (const f of payload.pictureFiles!) fd.append('pictures', f);
try {
return await authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
} catch {
// Compatibility fallback for deployments that only support legacy single-file payloads.
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: createLegacyFormData() });
}
}
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: createLegacyFormData() });
}, [authorizedFetch]); }, [authorizedFetch]);
const updateProduct = useCallback(async (id: number, payload: Partial<{ const updateProduct = useCallback(async (id: number, payload: Partial<{
@ -136,11 +163,40 @@ export default function useCoffeeManagement() {
return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' }); return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' });
}, [authorizedFetch]); }, [authorizedFetch]);
const getProductPictures = useCallback(async (id: number): Promise<{ id: number; url: string }[]> => {
const data = await authorizedFetch<any>(`/api/admin/coffee/${id}/pictures`, { method: 'GET' });
// Normalize response: may be { pictures: [{id, url}] } or { pictureUrls: string[] }
if (Array.isArray(data?.pictures)) {
return data.pictures.map((p: any) => ({ id: Number(p.id), url: p.url ?? p.pictureUrl ?? p.src ?? '' }));
}
if (Array.isArray(data?.pictureUrls)) {
return data.pictureUrls.map((url: string, i: number) => ({ id: i, url }));
}
return [];
}, [authorizedFetch]);
const updateProductPictures = useCallback(async (
id: number,
opts: { addFiles?: File[]; removeIds?: number[]; replaceAll?: boolean }
): Promise<any> => {
const fd = new FormData();
if (opts.replaceAll) fd.append('replaceAll', 'true');
if (opts.removeIds && opts.removeIds.length > 0) {
fd.append('removePictureIds', JSON.stringify(opts.removeIds));
}
if (opts.addFiles) {
for (const f of opts.addFiles) fd.append('pictures', f);
}
return authorizedFetch<any>(`/api/admin/coffee/${id}/pictures`, { method: 'PATCH', body: fd });
}, [authorizedFetch]);
return { return {
listProducts, listProducts,
createProduct, createProduct,
updateProduct, updateProduct,
setProductState, setProductState,
deleteProduct, deleteProduct,
getProductPictures,
updateProductPictures,
}; };
} }

View File

@ -137,52 +137,51 @@ export default function AdminSubscriptionsPage() {
const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null);
return ( return (
<PageLayout> <PageLayout contentClassName="flex-1 relative w-full">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]"> <div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */} {/* Header */}
<header className="sticky top-0 z-10 rounded-[30px] border border-white/80 bg-white/88 backdrop-blur py-6 px-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] mb-8"> <div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500"> <div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
Admin Admin
</div> </div>
<h1 className="mt-4 text-4xl font-black text-slate-950 tracking-tight break-words">Coffees</h1> <h1 className="mt-3 text-2xl font-bold text-slate-900 tracking-tight">Coffees</h1>
<p className="text-lg text-slate-600 mt-2 break-words">{t('autofix.k875f4054')}</p> <p className="text-sm text-slate-500 mt-1">{t('autofix.k875f4054')}</p>
</div> </div>
<Link <Link
href="/admin/subscriptions/createSubscription" href="/admin/subscriptions/createSubscription"
className="inline-flex items-center gap-2 rounded-2xl bg-slate-900 hover:bg-slate-800 text-slate-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto" className="inline-flex items-center gap-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white px-4 py-2.5 text-sm font-semibold shadow transition self-start sm:self-auto"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>{t('autofix.kaa30f0cd')}</Link> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{t('autofix.kaa30f0cd')}
</Link>
</div> </div>
</header>
{error && ( {error && (
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div> <div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
)} )}
{/* Shipping Fees */} {/* Shipping Fees */}
<section className="mb-8 rounded-[28px] border border-white/80 bg-white/88 backdrop-blur shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] p-6"> <section className="rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4 mb-5">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900 break-words">Shipping Fees (ABO)</h2> <h2 className="text-lg font-semibold text-slate-900">Shipping Fees (ABO)</h2>
<p className="mt-1 text-sm text-slate-600 break-words">{t('autofix.k027bd82e')}</p> <p className="mt-1 text-sm text-slate-500">{t('autofix.k027bd82e')}</p>
</div> </div>
<button <button
className="inline-flex items-center rounded-2xl bg-white px-4 py-2 text-xs font-medium text-slate-700 ring-1 ring-inset ring-slate-200 hover:bg-slate-50 shadow transition self-start" className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 shadow-sm transition"
onClick={loadShippingFees} onClick={loadShippingFees}
disabled={shippingFeesLoading} disabled={shippingFeesLoading}
> >{shippingFeesLoading ? t('autofix.k14a4b43e') : 'Refresh'}</button>
{shippingFeesLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div> </div>
{shippingFeesError && ( {shippingFeesError && (
<div className="mt-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{shippingFeesError}</div> <div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{shippingFeesError}</div>
)} )}
<div className="mt-5 grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => { {([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => {
const saving = shippingFeeSaving[pieceCount]; const saving = shippingFeeSaving[pieceCount];
const savedAt = shippingFeeSavedAt[pieceCount]; const savedAt = shippingFeeSavedAt[pieceCount];
@ -193,7 +192,7 @@ export default function AdminSubscriptionsPage() {
return ( return (
<div <div
key={pieceCount} key={pieceCount}
className="rounded-2xl border border-white/80 bg-white/85 ring-1 ring-inset ring-slate-100 p-4" className="rounded-2xl border border-slate-200 bg-white/80 p-4"
> >
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
@ -209,19 +208,19 @@ export default function AdminSubscriptionsPage() {
) : null} ) : null}
</div> </div>
{fieldError ? ( {fieldError ? (
<div className="mt-2 text-xs text-red-700 break-words">{fieldError}</div> <div className="mt-2 text-xs text-rose-700">{fieldError}</div>
) : ( ) : (
<div className="mt-2 text-xs text-slate-500 break-words">Enter a price in EUR ( 0).</div> <div className="mt-2 text-xs text-slate-400">Enter a price in EUR ( 0).</div>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500"></span> <span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-slate-400"></span>
<input <input
inputMode="decimal" inputMode="decimal"
className={`w-40 rounded-2xl border px-8 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-200 ${ className={`w-40 rounded-xl border px-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent ${
fieldError ? 'border-red-300 ring-1 ring-red-200' : 'border-gray-300' fieldError ? 'border-rose-300' : 'border-slate-200'
}`} }`}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
@ -233,16 +232,14 @@ export default function AdminSubscriptionsPage() {
/> />
</div> </div>
<button <button
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-semibold shadow transition ${ className={`inline-flex items-center rounded-xl px-4 py-2 text-sm font-semibold shadow-sm transition ${
saving saving
? 'bg-gray-200 text-gray-600 cursor-not-allowed' ? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-900 text-slate-50 hover:bg-slate-800' : 'bg-slate-900 text-white hover:bg-slate-800'
}`} }`}
disabled={saving} disabled={saving}
onClick={() => saveShippingFee(pieceCount)} onClick={() => saveShippingFee(pieceCount)}
> >{saving ? t('autofix.kac6cedc7') : 'Save'}</button>
{saving ? 'Saving…' : 'Save'}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -251,55 +248,57 @@ export default function AdminSubscriptionsPage() {
</div> </div>
</section> </section>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{loading && ( {loading && (
<div className="col-span-full text-sm text-slate-700 break-words">{t('autofix.k832387c5')}</div> <div className="col-span-full text-sm text-slate-500 py-4">{t('autofix.k832387c5')}</div>
)} )}
{!loading && items.map(item => ( {!loading && items.map(item => (
<div key={item.id} className="rounded-[28px] border border-white/80 bg-white/88 backdrop-blur shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] p-6 flex flex-col gap-3 hover:shadow-[0_26px_70px_-36px_rgba(15,23,42,0.35)] transition"> <div key={item.id} className="rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] p-5 flex flex-col gap-3 hover:shadow-[0_28px_72px_-38px_rgba(15,23,42,0.38)] transition">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h3 className="text-xl font-semibold text-slate-900 break-words">{item.title}</h3> <h3 className="text-base font-semibold text-slate-900 leading-snug">{item.title}</h3>
{availabilityBadge(!!item.state)} <span className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${item.state ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200'}`}>
{item.state ? 'Available' : 'Unavailable'}
</span>
</div> </div>
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50"> <div className="w-full h-36 rounded-xl border border-slate-100 overflow-hidden flex items-center justify-center bg-slate-50">
{item.pictureUrl ? ( {item.pictureUrl ? (
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" /> <img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
) : ( ) : (
<PhotoIcon className="w-12 h-12 text-gray-300" /> <PhotoIcon className="w-10 h-10 text-slate-200" />
)} )}
</div> </div>
<p className="mt-3 text-sm text-slate-700 break-words line-clamp-4">{item.description}</p> <p className="text-sm text-slate-600 line-clamp-3">{item.description}</p>
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm"> <dl className="grid grid-cols-1 gap-y-1 text-sm">
<div> <div>
<dt className="text-slate-500">Price</dt> <dt className="text-xs text-slate-400">Price</dt>
<dd className="font-medium text-slate-900 break-words"> <dd className="font-semibold text-slate-900">
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)} {item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
</dd> </dd>
</div> </div>
{item.billing_interval && item.interval_count ? ( {item.billing_interval && item.interval_count ? (
<div className="text-slate-600 break-words"> <div className="text-xs text-slate-400">
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span> Subscription: {item.billing_interval} × {item.interval_count}
</div> </div>
) : null} ) : null}
</dl> </dl>
<div className="mt-4 flex gap-2"> <div className="mt-auto flex flex-wrap gap-2 pt-1">
<button <button
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition className={`inline-flex items-center rounded-xl px-3 py-1.5 text-xs font-semibold transition ${
${item.state item.state
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100' ? 'bg-amber-50 text-amber-700 hover:bg-amber-100'
: 'bg-slate-900 text-slate-50 hover:bg-slate-800'}`} : 'bg-slate-900 text-white hover:bg-slate-800'}`}
onClick={async () => { await setProductState(item.id, !item.state); await load(); }} onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
> >
{item.state ? 'Disable' : 'Enable'} {item.state ? 'Disable' : 'Enable'}
</button> </button>
<Link <Link
href={`/admin/subscriptions/edit/${item.id}`} href={`/admin/subscriptions/edit/${item.id}`}
className="inline-flex items-center rounded-lg bg-indigo-50 px-4 py-2 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 hover:bg-indigo-100 shadow transition" className="inline-flex items-center rounded-xl bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-200 transition"
> >
Edit Edit
</Link> </Link>
<button <button
className="inline-flex items-center rounded-lg bg-red-50 px-4 py-2 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-200 hover:bg-red-100 shadow transition" className="inline-flex items-center rounded-xl bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition"
onClick={() => setDeleteTarget(item)} onClick={() => setDeleteTarget(item)}
> >
Delete Delete
@ -308,7 +307,7 @@ export default function AdminSubscriptionsPage() {
</div> </div>
))} ))}
{!loading && !items.length && ( {!loading && !items.length && (
<div className="col-span-full py-8 text-center text-sm text-slate-500 break-words">{t('autofix.k8c75468c')}</div> <div className="col-span-full py-10 text-center text-sm text-slate-400">{t('autofix.k8c75468c')}</div>
)} )}
</div> </div>
{/* Confirm Delete Modal */} {/* Confirm Delete Modal */}
@ -318,18 +317,18 @@ export default function AdminSubscriptionsPage() {
<div className="absolute inset-0 flex items-center justify-center p-4"> <div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]"> <div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]">
<div className="px-6 pt-6"> <div className="px-6 pt-6">
<h3 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.kddd4832f')}</h3> <h3 className="text-lg font-semibold text-slate-900">{t('autofix.kddd4832f')}</h3>
<p className="mt-2 text-sm text-slate-700 break-words">You are about to delete the coffee {deleteTarget.title}. This action cannot be undone.</p> <p className="mt-2 text-sm text-slate-600">You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.</p>
</div> </div>
<div className="px-6 pb-6 pt-4 flex justify-end gap-3"> <div className="px-6 pb-6 pt-4 flex justify-end gap-3">
<button <button
className="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-medium text-slate-700 ring-1 ring-inset ring-slate-300 hover:bg-slate-50" className="inline-flex items-center rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition"
onClick={() => setDeleteTarget(null)} onClick={() => setDeleteTarget(null)}
> >
Cancel Cancel
</button> </button>
<button <button
className="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow" className="inline-flex items-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-500 shadow transition"
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }} onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
> >
Delete Delete
@ -340,7 +339,6 @@ export default function AdminSubscriptionsPage() {
</div> </div>
)} )}
</div> </div>
</div>
</PageLayout> </PageLayout>
); );
} }

View File

@ -81,6 +81,7 @@ interface AddMissingKeysResult {
const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'alt', 'aria-label'] as const; const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'alt', 'aria-label'] as const;
const USE_CLIENT_PREFIX_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['\"]use client['\"]\s*;?\s*/; const USE_CLIENT_PREFIX_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['\"]use client['\"]\s*;?\s*/;
const LEADING_PREAMBLE_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/; const LEADING_PREAMBLE_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/;
const JSX_TAG_REGEX = /<(?:[^'"<>]|"[^"]*"|'[^']*'|\{[^{}]*\})+>/g;
async function walk(dir: string, outFiles: string[], counters: { dirs: number }) { async function walk(dir: string, outFiles: string[], counters: { dirs: number }) {
counters.dirs += 1; counters.dirs += 1;
@ -162,6 +163,46 @@ function extractPotentialUiLiterals(content: string): string[] {
literals.push(text); literals.push(text);
} }
// Simple JSX child expressions, e.g. >{isSaving ? 'Saving' : 'Save'}<
const jsxExpressionRegex = />\s*\{([^{}\n][^{}]*?)\}\s*</g;
while ((match = jsxExpressionRegex.exec(content)) !== null) {
const expression = String(match[1] ?? '');
for (const text of extractSimpleExpressionLiterals(expression)) {
literals.push(text);
}
}
const confirmRegex = /\bwindow\.confirm\(\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*\)/g;
while ((match = confirmRegex.exec(content)) !== null) {
const text = match[2]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
const messageRegex = /\bmessage\s*:\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g;
while ((match = messageRegex.exec(content)) !== null) {
const text = match[2]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
return literals;
}
function extractSimpleExpressionLiterals(expression: string): string[] {
const literals: string[] = [];
const directMatch = expression.match(/^\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*$/);
if (directMatch?.[2]) {
literals.push(directMatch[2].replace(/\s+/g, ' ').trim());
}
for (const match of expression.matchAll(/(?:\?\?|\|\||\?|:)\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g)) {
const text = match[2]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
return literals; return literals;
} }
@ -244,6 +285,125 @@ function hasExistingTUsage(content: string): boolean {
return /\bt\(\s*['"`][^'"`]+['"`]\s*[,\)\]]/.test(content); return /\bt\(\s*['"`][^'"`]+['"`]\s*[,\)\]]/.test(content);
} }
function validateAutoFixOutput(content: string): string | null {
const duplicateImports = content.match(/import\s+\{\s*useTranslation\s*\}\s+from\s+['\"][^'\"]*\/i18n\/useTranslation['\"];?/g) ?? [];
if (duplicateImports.length > 1) {
return 'Unsafe autofix output: duplicate useTranslation import detected.';
}
const functionPatterns = [
/(?:^|\n)\s*export\s+default\s+async\s+function\s+\w*\s*\([\s\S]*?\)\s*\{/gm,
/(?:^|\n)\s*export\s+default\s+function\s+\w*\s*\([\s\S]*?\)\s*\{/gm,
/(?:^|\n)\s*export\s+async\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm,
/(?:^|\n)\s*export\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm,
/(?:^|\n)\s*async\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/gm,
/(?:^|\n)\s*function\s+\w+\s*\([\s\S]*?\)\s*\{/gm,
/(?:^|\n)\s*const\s+\w+\s*:\s*[^=\n]+\s*=\s*async\s*\([\s\S]*?\)\s*=>\s*\{/gm,
/(?:^|\n)\s*const\s+\w+\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
/(?:^|\n)\s*const\s+\w+\s*=\s*async\s*\([\s\S]*?\)\s*=>\s*\{/gm,
/(?:^|\n)\s*const\s+\w+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
];
const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => {
let depth = 1;
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
const prev = index > 0 ? source[index - 1] : '';
const next = index + 1 < source.length ? source[index + 1] : '';
if (inLineComment) {
if (char === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (prev === '*' && char === '/') inBlockComment = false;
continue;
}
if (inSingleQuote) {
if (char === '\'' && prev !== '\\') inSingleQuote = false;
continue;
}
if (inDoubleQuote) {
if (char === '"' && prev !== '\\') inDoubleQuote = false;
continue;
}
if (inTemplate) {
if (char === '`' && prev !== '\\') inTemplate = false;
continue;
}
if (char === '/' && next === '/') {
inLineComment = true;
index += 1;
continue;
}
if (char === '/' && next === '*') {
inBlockComment = true;
index += 1;
continue;
}
if (char === '\'') {
inSingleQuote = true;
continue;
}
if (char === '"') {
inDoubleQuote = true;
continue;
}
if (char === '`') {
inTemplate = true;
continue;
}
if (char === '{') {
depth += 1;
continue;
}
if (char === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return null;
};
const processedBodyStarts = new Set<number>();
for (const pattern of functionPatterns) {
for (const match of content.matchAll(pattern)) {
if (typeof match.index !== 'number') continue;
const bodyStart = match.index + match[0].length;
if (processedBodyStarts.has(bodyStart)) continue;
processedBodyStarts.add(bodyStart);
const bodyEnd = findFunctionBodyEnd(content, bodyStart);
if (bodyEnd === null) continue;
const bodyContent = content.slice(bodyStart, bodyEnd);
const hookMatches = bodyContent.match(/const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\)\s*;?/g) ?? [];
if (hookMatches.length > 1) {
return 'Unsafe autofix output: duplicate useTranslation hooks detected in a function scope.';
}
}
}
return null;
}
function hasServerOnlySignals(content: string): string | null { function hasServerOnlySignals(content: string): string | null {
if (/from\s+['\"]next\/headers['\"]/.test(content)) return 'uses next/headers'; if (/from\s+['\"]next\/headers['\"]/.test(content)) return 'uses next/headers';
if (/from\s+['\"]next\/server['\"]/.test(content)) return 'uses next/server'; if (/from\s+['\"]next\/server['\"]/.test(content)) return 'uses next/server';
@ -353,7 +513,7 @@ function ensureUseTranslationHooksInComponents(content: string): { content: stri
/const\s+[A-Z]\w*\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm, /const\s+[A-Z]\w*\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/gm,
]; ];
const hookPrefixRegex = /^\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\);/; const hookPrefixRegex = /^\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\)\s*;?/;
const insertPositions = new Set<number>(); const insertPositions = new Set<number>();
const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => { const findFunctionBodyEnd = (source: string, bodyStart: number): number | null => {
@ -550,10 +710,10 @@ function replaceJsxAttributeLiterals(content: string): { content: string; replac
let replacements = 0; let replacements = 0;
const attrsPattern = TRANSLATABLE_ATTRIBUTES.map((a) => a.replace('-', '\\-')).join('|'); const attrsPattern = TRANSLATABLE_ATTRIBUTES.map((a) => a.replace('-', '\\-')).join('|');
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, tagOffset: number) => { const next = content.replace(JSX_TAG_REGEX, (tag, tagOffset: number) => {
if (isIndexInsideStringOrComment(content, tagOffset)) { if (isIndexInsideStringOrComment(content, tagOffset)) {
return tag; return tag;
} }
@ -594,6 +754,98 @@ function replaceJsxTextNodes(content: string): { content: string; replacements:
return { content: next, replacements, keyValueMap }; return { content: next, replacements, keyValueMap };
} }
function replaceSimpleExpressionLiterals(expression: string, keyValueMap: Map<string, string>): { expression: string; replacements: number } {
let replacements = 0;
const directMatch = expression.match(/^\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*$/);
if (directMatch?.[2]) {
const text = directMatch[2].replace(/\s+/g, ' ').trim();
if (!shouldIgnoreLiteral(text)) {
const key = toAutofixKey(text);
keyValueMap.set(key, text);
return { expression: `t('${key}')`, replacements: 1 };
}
}
const nextExpression = expression.replace(/(\?\?|\|\||\?|:)\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\2/g, (full, operator: string, quote: string, captured: string) => {
const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
if (shouldIgnoreLiteral(text)) return full;
const key = toAutofixKey(text);
keyValueMap.set(key, text);
replacements += 1;
return `${operator} t('${key}')`;
});
return { expression: nextExpression, replacements };
}
function replaceJsxExpressionLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
const keyValueMap = new Map<string, string>();
let replacements = 0;
const next = content.replace(/>\s*\{([^{}\n][^{}]*?)\}\s*</g, (full, captured: string, offset: number) => {
if (isIndexInsideStringOrComment(content, offset)) {
return full;
}
const result = replaceSimpleExpressionLiterals(String(captured ?? ''), keyValueMap);
replacements += result.replacements;
return result.replacements > 0 ? `>{${result.expression}}<` : full;
});
return { content: next, replacements, keyValueMap };
}
function replaceCommonCallLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
const keyValueMap = new Map<string, string>();
let replacements = 0;
let next = content.replace(/\bwindow\.confirm\(\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1\s*\)/g, (full, quote: string, captured: string, offset: number) => {
if (isIndexInsideStringOrComment(content, offset)) {
return full;
}
const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
if (shouldIgnoreLiteral(text)) return full;
const key = toAutofixKey(text);
keyValueMap.set(key, text);
replacements += 1;
return `window.confirm(t('${key}'))`;
});
next = next.replace(/\bmessage\s*:\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g, (full, quote: string, captured: string, offset: number) => {
if (isIndexInsideStringOrComment(next, offset)) {
return full;
}
const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
if (shouldIgnoreLiteral(text)) return full;
const key = toAutofixKey(text);
keyValueMap.set(key, text);
replacements += 1;
return `message: t('${key}')`;
});
next = next.replace(/\bmessage\s*:\s*([^,\n]+?)(\?\?|\|\|)\s*(['"])([^"'\\]*(?:\\.[^"'\\]*)*)\3/g, (full, before: string, operator: string, quote: string, captured: string, offset: number) => {
if (isIndexInsideStringOrComment(next, offset)) {
return full;
}
const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
if (shouldIgnoreLiteral(text)) return full;
const key = toAutofixKey(text);
keyValueMap.set(key, text);
replacements += 1;
return `message: ${before}${operator} t('${key}')`;
});
return { content: next, replacements, keyValueMap };
}
function upsertAutofixNamespace(content: string, entries: Map<string, string>): string { function upsertAutofixNamespace(content: string, entries: Map<string, string>): string {
if (entries.size === 0) return content; if (entries.size === 0) return content;
@ -769,9 +1021,11 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
} }
const textReplaced = replaceJsxTextNodes(ensured.content); const textReplaced = replaceJsxTextNodes(ensured.content);
const attrReplaced = replaceJsxAttributeLiterals(textReplaced.content); const exprReplaced = replaceJsxExpressionLiterals(textReplaced.content);
const ensuredHooks = ensureUseTranslationHooksInComponents(attrReplaced.content); const attrReplaced = replaceJsxAttributeLiterals(exprReplaced.content);
const totalReplacements = textReplaced.replacements + attrReplaced.replacements; const callReplaced = replaceCommonCallLiterals(attrReplaced.content);
const ensuredHooks = ensureUseTranslationHooksInComponents(callReplaced.content);
const totalReplacements = textReplaced.replacements + exprReplaced.replacements + attrReplaced.replacements + callReplaced.replacements;
const afterLiteralCount = extractPotentialUiLiterals(ensuredHooks.content).filter((text) => !shouldIgnoreLiteral(text)).length; const afterLiteralCount = extractPotentialUiLiterals(ensuredHooks.content).filter((text) => !shouldIgnoreLiteral(text)).length;
if (totalReplacements === 0) { if (totalReplacements === 0) {
const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).'; const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).';
@ -788,12 +1042,33 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
continue; continue;
} }
const validationIssue = validateAutoFixOutput(ensuredHooks.content);
if (validationIssue) {
skippedFiles.push({ file: relPath, reason: validationIssue });
debugEntries.push({
file: relPath,
status: 'skipped',
beforeLiteralCount: literalCheck.length,
textReplacements: textReplaced.replacements + exprReplaced.replacements + callReplaced.replacements,
attrReplacements: attrReplaced.replacements,
afterLiteralCount,
reason: validationIssue,
});
continue;
}
for (const [k, v] of textReplaced.keyValueMap.entries()) { for (const [k, v] of textReplaced.keyValueMap.entries()) {
createdEntries.set(k, v); createdEntries.set(k, v);
} }
for (const [k, v] of exprReplaced.keyValueMap.entries()) {
createdEntries.set(k, v);
}
for (const [k, v] of attrReplaced.keyValueMap.entries()) { for (const [k, v] of attrReplaced.keyValueMap.entries()) {
createdEntries.set(k, v); createdEntries.set(k, v);
} }
for (const [k, v] of callReplaced.keyValueMap.entries()) {
createdEntries.set(k, v);
}
if (ensuredHooks.content !== raw) { if (ensuredHooks.content !== raw) {
await fs.writeFile(absPath, ensuredHooks.content, 'utf8'); await fs.writeFile(absPath, ensuredHooks.content, 'utf8');
@ -807,7 +1082,7 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
file: relPath, file: relPath,
status: 'changed', status: 'changed',
beforeLiteralCount: literalCheck.length, beforeLiteralCount: literalCheck.length,
textReplacements: textReplaced.replacements, textReplacements: textReplaced.replacements + exprReplaced.replacements + callReplaced.replacements,
attrReplacements: attrReplaced.replacements, attrReplacements: attrReplaced.replacements,
afterLiteralCount, afterLiteralCount,
}); });
@ -816,7 +1091,7 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
file: relPath, file: relPath,
status: 'no-op', status: 'no-op',
beforeLiteralCount: literalCheck.length, beforeLiteralCount: literalCheck.length,
textReplacements: textReplaced.replacements, textReplacements: textReplaced.replacements + exprReplaced.replacements + callReplaced.replacements,
attrReplacements: attrReplaced.replacements, attrReplacements: attrReplaced.replacements,
afterLiteralCount, afterLiteralCount,
reason: 'Generated output matched input; no file write needed.', reason: 'Generated output matched input; no file write needed.',

View File

@ -0,0 +1,150 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import PageLayout from '../../components/PageLayout';
import { useActiveCoffees } from '../hooks/getActiveCoffees';
import CoffeeDetailGallery from '../components/CoffeeDetailGallery';
import { useCoffeePictures } from '../hooks/useCoffeePictures';
import { useTranslation } from '../../i18n/useTranslation';
export default function CoffeeAbonnementDetailPage() {
const { t } = useTranslation();
const params = useParams();
const { coffees, loading, error } = useActiveCoffees();
const rawId = params?.id;
const coffeeId = Array.isArray(rawId) ? rawId[0] : rawId;
const { pictureUrls: endpointPictureUrls, loading: picturesLoading } = useCoffeePictures(coffeeId ? String(coffeeId) : undefined);
const coffee = coffees.find((item) => item.id === String(coffeeId));
const fallbackGallery = coffee ? (coffee.gallery.length ? coffee.gallery : (coffee.image ? [coffee.image] : [])) : [];
const gallery = endpointPictureUrls.length ? endpointPictureUrls : fallbackGallery;
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div>
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
Coffee ABO
</div>
<h1 className="mt-3 text-2xl font-bold tracking-tight text-slate-900">Coffee Details</h1>
<p className="mt-1 text-sm text-slate-500">Detailed view with gallery images and product information.</p>
</div>
<Link
href="/coffee-abonnements"
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to selection
</Link>
</div>
{loading && (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="h-80 rounded-2xl bg-slate-100 animate-pulse" />
</div>
)}
{!loading && error && (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
{!loading && !error && !coffee && (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<h2 className="text-lg font-semibold text-slate-900">Coffee not found</h2>
<p className="mt-2 text-sm text-slate-500">The coffee may be unavailable or inactive right now.</p>
</div>
)}
{!loading && !error && coffee && (
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
<div className="xl:col-span-3 rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<CoffeeDetailGallery images={gallery} alt={coffee.name} />
</div>
<div className="xl:col-span-2 space-y-5">
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<h2 className="text-xl font-semibold text-slate-900">{coffee.name}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{coffee.description || t('autofix.kec078e54')}</p>
<div className="mt-5 space-y-2">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">Price per 10</span>
<span className="text-sm font-semibold text-slate-900">EUR {coffee.pricePer10.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">Price per capsule</span>
<span className="text-sm font-semibold text-slate-900">EUR {(coffee.pricePer10 / 10).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">Gallery images</span>
<span className="text-sm font-semibold text-slate-900">
{picturesLoading ? 'Loading...' : gallery.length}
</span>
</div>
</div>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<p className="text-sm text-slate-600">Ready to add this coffee to your plan? Go back to the selection page and choose quantity in 10-piece steps.</p>
<Link
href="/coffee-abonnements"
className="mt-4 inline-flex items-center justify-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
>
Back and select coffee
</Link>
</div>
</div>
<div className="xl:col-span-5 rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-slate-900">All Images</h3>
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
{picturesLoading ? 'Loading...' : `${gallery.length} image${gallery.length === 1 ? '' : 's'}`}
</span>
</div>
{picturesLoading ? (
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="h-24 rounded-xl bg-slate-100 animate-pulse" />
<div className="h-24 rounded-xl bg-slate-100 animate-pulse" />
<div className="h-24 rounded-xl bg-slate-100 animate-pulse" />
<div className="h-24 rounded-xl bg-slate-100 animate-pulse" />
<div className="h-24 rounded-xl bg-slate-100 animate-pulse" />
<div className="h-24 rounded-xl bg-slate-100 animate-pulse" />
</div>
) : gallery.length === 0 ? (
<p className="mt-4 text-sm text-slate-500">No gallery images are available for this coffee yet.</p>
) : (
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{gallery.map((img, index) => (
<a
key={`${img}-${index}`}
href={img}
target="_blank"
rel="noreferrer"
className="relative block h-24 overflow-hidden rounded-xl border border-slate-200 bg-slate-50 hover:border-slate-300 transition"
aria-label={`Open image ${index + 1}`}
>
<img src={img} alt={`${coffee.name} gallery ${index + 1}`} className="h-full w-full object-cover" />
<span className="absolute left-1.5 bottom-1.5 rounded bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
#{index + 1}
</span>
</a>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,29 @@
import Link from 'next/link';
type Props = {
title: string;
subtitle: string;
};
export default function AboHeroHeader({ title, subtitle }: Props) {
return (
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
<div>
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
Coffee ABO
</div>
<h1 className="mt-3 text-2xl font-bold tracking-tight text-slate-900">{title}</h1>
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
</div>
<Link
href="/profile/subscriptions"
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
My subscriptions
</Link>
</div>
);
}

View File

@ -0,0 +1,25 @@
type Props = {
currentStep: 1 | 2;
};
export default function AboStepper({ currentStep }: Props) {
return (
<div className="rounded-[24px] border border-white/80 bg-white/90 px-5 py-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.2)] backdrop-blur">
<div className="flex items-center gap-3 text-sm text-slate-600">
<div className="flex items-center">
<span className={`h-8 w-8 rounded-full flex items-center justify-center font-semibold ${
currentStep === 1 ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500'
}`}>1</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-slate-200" />
<div className="flex items-center">
<span className={`h-8 w-8 rounded-full flex items-center justify-center font-semibold ${
currentStep === 2 ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500'
}`}>2</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { useState } from 'react';
type Props = {
images: string[];
alt: string;
};
export default function CoffeeDetailGallery({ images, alt }: Props) {
const [index, setIndex] = useState(0);
const safeImages = images.length > 0 ? images : [''];
const current = safeImages[index] || '';
return (
<div className="space-y-3">
<div className="relative h-72 sm:h-96 overflow-hidden rounded-2xl border border-slate-200 bg-slate-100">
{current ? (
<img src={current} alt={alt} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center text-sm text-slate-400">No image available</div>
)}
</div>
{safeImages.length > 1 && (
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
{safeImages.map((img, i) => (
<button
key={`${img}-${i}`}
type="button"
onClick={() => setIndex(i)}
className={`relative h-16 sm:h-20 overflow-hidden rounded-xl border transition ${
i === index ? 'border-slate-900 ring-2 ring-slate-900/20' : 'border-slate-200 hover:border-slate-300'
}`}
aria-label={`Show gallery image ${i + 1}`}
>
<img src={img} alt={`${alt} ${i + 1}`} className="h-full w-full object-cover" />
</button>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,172 @@
import Link from 'next/link';
import type { CoffeeItem } from '../hooks/getActiveCoffees';
type Props = {
coffees: CoffeeItem[];
loading: boolean;
error: string | null;
selections: Record<string, number>;
bump: Record<string, boolean>;
selectedPlanCapsules: number;
totalCapsules: number;
onToggleCoffee: (id: string) => void;
onChangeQuantity: (id: string, delta: number) => void;
title: string;
};
export default function CoffeeSelectionGrid({
coffees,
loading,
error,
selections,
bump,
selectedPlanCapsules,
totalCapsules,
onToggleCoffee,
onChangeQuantity,
title,
}: Props) {
return (
<section className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<h2 className="text-lg font-semibold text-slate-900 mb-4">{title}</h2>
{error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
)}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{coffees.map((coffee) => {
const active = coffee.id in selections;
const qty = selections[coffee.id] || 0;
const remainingCapsules = selectedPlanCapsules - totalCapsules;
const maxForCoffee = active ? Math.min(120, qty + remainingCapsules) : 0;
const sliderMax = Math.max(10, maxForCoffee);
const sliderProgress = sliderMax <= 10
? 100
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
const canAddCoffee = active || remainingCapsules >= 10;
return (
<div
key={coffee.id}
className={`group rounded-2xl border p-4 shadow-sm transition ${
active ? 'border-slate-900/30 bg-slate-50' : 'border-slate-200 bg-white'
}`}
>
<Link href={`/coffee-abonnements/${coffee.id}`} className="block relative overflow-hidden rounded-xl mb-3">
{coffee.image ? (
<img
src={coffee.image}
alt={coffee.name}
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="h-36 w-full bg-slate-100 rounded-xl" />
)}
<div className="absolute top-2 left-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-semibold text-white">Details</div>
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
<span className={`inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm ${
active ? 'bg-slate-900' : 'bg-slate-700/90'
}`}>
EUR {coffee.pricePer10}
</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-slate-900/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">per 10</span>
</div>
</Link>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-sm text-slate-900 line-clamp-1">{coffee.name}</h3>
</div>
<p className="mt-2 text-xs text-slate-600 leading-relaxed line-clamp-3">{coffee.description}</p>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => onToggleCoffee(coffee.id)}
disabled={!canAddCoffee}
className={`flex-1 text-xs font-semibold rounded-xl px-3 py-2 border transition ${
active
? 'border-slate-900 text-slate-900 bg-white hover:bg-slate-100'
: canAddCoffee
? 'border-slate-300 hover:bg-slate-100 text-slate-700'
: 'border-slate-200 bg-slate-100 text-slate-400 cursor-not-allowed'
}`}
>
{active ? 'Remove' : 'Add'}
</button>
<Link
href={`/coffee-abonnements/${coffee.id}`}
className="inline-flex items-center text-xs font-semibold rounded-xl px-3 py-2 border border-slate-200 text-slate-600 hover:bg-slate-100 transition"
>
View
</Link>
</div>
{active && (
<div className="mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-slate-500">Quantity (10-{maxForCoffee} pcs)</span>
<span className={`inline-flex items-center justify-center rounded-full bg-slate-900 text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`}>
{qty} pcs
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onChangeQuantity(coffee.id, -10)}
disabled={qty <= 10}
className="h-8 w-14 rounded-full bg-slate-100 hover:bg-slate-200 text-xs font-medium transition active:scale-95"
>
-10
</button>
<div className="flex-1 relative">
<input
type="range"
min={10}
max={sliderMax}
step={10}
value={qty}
onChange={(e) => onChangeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)}
className="w-full appearance-none cursor-pointer bg-transparent"
style={{
background:
'linear-gradient(to right,#0f172a 0%,#0f172a ' +
sliderProgress +
'%,#e2e8f0 ' +
sliderProgress +
'%,#e2e8f0 100%)',
height: '6px',
borderRadius: '999px',
}}
/>
</div>
<button
onClick={() => onChangeQuantity(coffee.id, +10)}
disabled={qty + 10 > maxForCoffee}
className="h-8 w-14 rounded-full bg-slate-100 hover:bg-slate-200 text-xs font-medium transition active:scale-95"
>
+10
</button>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span>Subtotal</span>
<span className="font-semibold text-slate-700">EUR {((qty / 10) * coffee.pricePer10).toFixed(2)}</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,64 @@
type Props = {
selectedPlanCapsules: number;
shippingLoading: boolean;
isFreeShippingSelected: boolean;
selectedShippingFee: number;
shippingError: string | null;
onDecrease: () => void;
onIncrease: () => void;
loadingText: string;
freeShippingText: string;
};
export default function PlanSelectorCard({
selectedPlanCapsules,
shippingLoading,
isFreeShippingSelected,
selectedShippingFee,
shippingError,
onDecrease,
onIncrease,
loadingText,
freeShippingText,
}: Props) {
return (
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={onDecrease}
disabled={selectedPlanCapsules <= 60}
className="h-10 w-10 rounded-full bg-slate-100 hover:bg-slate-200 text-lg font-bold transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center"
>
-
</button>
<div className="flex-1 text-center min-w-[190px]">
<div className="text-2xl font-extrabold text-slate-900">{selectedPlanCapsules} pcs</div>
<div className="text-xs text-slate-500">{selectedPlanCapsules / 10} packs of 10 · min. 60</div>
</div>
<button
type="button"
onClick={onIncrease}
className="h-10 w-10 rounded-full bg-slate-100 hover:bg-slate-200 text-lg font-bold transition flex items-center justify-center"
>
+
</button>
<div className="ml-auto">
{shippingLoading ? (
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-slate-100 text-slate-700">{loadingText}</span>
) : isFreeShippingSelected ? (
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">{freeShippingText}</span>
) : (
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-slate-100 text-slate-700 ring-1 ring-inset ring-slate-200">Shipping EUR {selectedShippingFee.toFixed(2)}</span>
)}
</div>
</div>
{shippingError && (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
Shipping fees could not be loaded: {shippingError}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,109 @@
import type { CoffeeItem } from '../hooks/getActiveCoffees';
type SelectedEntry = {
coffee: CoffeeItem;
quantity: number;
};
type Props = {
selectedEntries: SelectedEntry[];
shippingLoading: boolean;
isFreeShippingSelected: boolean;
selectedShippingFee: number;
totalNetWithShipping: number;
totalCapsules: number;
packsSelected: number;
selectedPlanCapsules: number;
requiredPacks: number;
canProceed: boolean;
onProceed: () => void;
title: string;
emptyText: string;
continueText: string;
};
export default function SelectionSummaryCard({
selectedEntries,
shippingLoading,
isFreeShippingSelected,
selectedShippingFee,
totalNetWithShipping,
totalCapsules,
packsSelected,
selectedPlanCapsules,
requiredPacks,
canProceed,
onProceed,
title,
emptyText,
continueText,
}: Props) {
return (
<section className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-4">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
{selectedEntries.length === 0 && <p className="text-sm text-slate-600">{emptyText}</p>}
{selectedEntries.map((entry) => (
<div key={entry.coffee.id} className="flex justify-between text-sm border-b border-slate-100 last:border-b-0 pb-2 last:pb-0">
<div className="flex flex-col">
<span className="font-medium text-slate-800">{entry.coffee.name}</span>
<span className="text-xs text-slate-500">
{entry.quantity} pcs <span className="inline-flex items-center font-semibold text-slate-900">EUR {entry.coffee.pricePer10}/10</span>
</span>
</div>
<div className="text-right font-semibold text-slate-800">EUR {((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div>
</div>
))}
<div className="flex justify-between text-sm border-b border-slate-100 pb-2">
<span className="text-sm font-medium text-slate-700">Shipping</span>
<span className="text-sm font-semibold text-slate-900">
{shippingLoading ? 'Loading...' : isFreeShippingSelected ? 'FREE SHIPPING' : `EUR ${selectedShippingFee.toFixed(2)}`}
</span>
</div>
<div className="flex justify-between pt-2 border-t border-slate-200">
<span className="text-sm font-semibold text-slate-700">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-slate-900">EUR {totalNetWithShipping.toFixed(2)}</span>
</div>
<div className="text-xs text-slate-700">
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected !== requiredPacks && (
<span className="ml-2 inline-flex items-center rounded-md bg-rose-50 text-rose-700 px-2 py-1 border border-rose-200">
{packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many.`}
</span>
)}
</div>
<button
onClick={onProceed}
disabled={!canProceed}
className={`group w-full mt-2 rounded-xl px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
canProceed ? 'bg-slate-900 text-white hover:bg-slate-800 shadow-md hover:shadow-lg' : 'bg-slate-200 text-slate-500 cursor-not-allowed'
}`}
>
{continueText}
<svg
className={`ml-2 h-5 w-5 transition-transform ${canProceed ? 'group-hover:translate-x-0.5' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{!canProceed && (
<p className="text-xs text-slate-600">
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
</p>
)}
</section>
);
}

View File

@ -7,6 +7,7 @@ export type ActiveCoffee = {
description: string; description: string;
price: string | number; // price can be a string or number price: string | number; // price can be a string or number
pictureUrl?: string; pictureUrl?: string;
pictureUrls?: string[] | string | null;
state: number; // 1 for active, 0 for inactive state: number; // 1 for active, 0 for inactive
}; };
@ -16,8 +17,27 @@ export type CoffeeItem = {
description: string; description: string;
pricePer10: number; // price for 10 pieces pricePer10: number; // price for 10 pieces
image: string; image: string;
gallery: string[];
}; };
function normalizePictureUrls(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
}
} catch {
// Accept comma-separated fallback values.
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
}
}
return [];
}
export function useActiveCoffees() { export function useActiveCoffees() {
const [coffees, setCoffees] = useState<CoffeeItem[]>([]); const [coffees, setCoffees] = useState<CoffeeItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -70,12 +90,15 @@ export function useActiveCoffees() {
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1') .filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
.map((coffee) => { .map((coffee) => {
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price; const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price;
const gallery = normalizePictureUrls((coffee as any).pictureUrls);
const thumbnail = coffee.pictureUrl || gallery[0] || '';
return { return {
id: String(coffee.id), id: String(coffee.id),
name: coffee.title || `Coffee ${coffee.id}`, name: coffee.title || `Coffee ${coffee.id}`,
description: coffee.description || '', description: coffee.description || '',
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0, pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
image: coffee.pictureUrl || '', image: thumbnail,
gallery,
}; };
}); });

View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { authFetch } from '../../utils/authFetch';
type PictureApiRecord = {
url?: string;
pictureUrl?: string;
src?: string;
path?: string;
};
function normalizePictureUrls(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
}
} catch {
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
}
}
return [];
}
function uniqueUrls(urls: string[]): string[] {
return Array.from(new Set(urls.filter((u) => typeof u === 'string' && u.trim() !== '')));
}
export function useCoffeePictures(coffeeId?: string) {
const [pictureUrls, setPictureUrls] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!coffeeId) {
setPictureUrls([]);
return;
}
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
const candidateUrls = [
`${base}/api/admin/coffee/${coffeeId}/pictures`,
`${base}/api/coffee/${coffeeId}/pictures`,
];
let isCancelled = false;
setLoading(true);
const loadPictures = async () => {
for (const url of candidateUrls) {
try {
const response = await authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) {
if (response.status === 401 || response.status === 403 || response.status === 404) {
continue;
}
continue;
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) continue;
const json = await response.json().catch(() => null);
const payload = json && typeof json === 'object' && json.data && typeof json.data === 'object'
? json.data
: json;
const fromList = normalizePictureUrls((payload as any)?.pictureUrls);
const fromSingle = typeof (payload as any)?.pictureUrl === 'string' ? [(payload as any).pictureUrl] : [];
const fromDetails = Array.isArray((payload as any)?.pictures)
? ((payload as any).pictures as PictureApiRecord[])
.map((item) => item?.url || item?.pictureUrl || item?.src || item?.path || '')
.filter(Boolean)
: [];
const merged = uniqueUrls([...fromList, ...fromDetails, ...fromSingle]);
if (!isCancelled) setPictureUrls(merged);
return;
} catch {
// Try next candidate endpoint.
}
}
if (!isCancelled) setPictureUrls([]);
};
void loadPictures().finally(() => {
if (!isCancelled) setLoading(false);
});
return () => {
isCancelled = true;
};
}, [coffeeId]);
return { pictureUrls, loading };
}

View File

@ -4,6 +4,11 @@ import PageLayout from '../components/PageLayout';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useActiveCoffees } from './hooks/getActiveCoffees'; import { useActiveCoffees } from './hooks/getActiveCoffees';
import { useShippingFees } from './hooks/useShippingFees'; import { useShippingFees } from './hooks/useShippingFees';
import AboHeroHeader from './components/AboHeroHeader';
import AboStepper from './components/AboStepper';
import PlanSelectorCard from './components/PlanSelectorCard';
import CoffeeSelectionGrid from './components/CoffeeSelectionGrid';
import SelectionSummaryCard from './components/SelectionSummaryCard';
import { useTranslation } from '../i18n/useTranslation'; import { useTranslation } from '../i18n/useTranslation';
@ -117,290 +122,58 @@ export default function CoffeeAbonnementPage() {
}; };
return ( return (
<PageLayout> <PageLayout contentClassName="flex-1 relative w-full">
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]"> <div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
<h1 className="text-3xl font-bold tracking-tight"> <div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
<span className="text-[#1C2B4A]">{t('autofix.kb0b660e2')}</span> <AboHeroHeader
</h1> title={t('autofix.kb0b660e2')}
subtitle={t('autofix.k7f48f374')}
{/* Stepper */}
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="flex items-center">
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
<div className="flex items-center opacity-60">
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
{/* Section 1: Multi coffee selection + per-coffee quantity */}
<section>
<h2 className="text-xl font-semibold mb-4">{t('autofix.k7f48f374')}</h2>
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => changePlanSize(-10)}
disabled={selectedPlanCapsules <= 60}
className="h-10 w-10 rounded-full bg-gray-100 hover:bg-gray-200 text-lg font-bold transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center"
></button>
<div className="flex-1 text-center">
<div className="text-2xl font-extrabold text-[#1C2B4A]">{selectedPlanCapsules} pcs</div>
<div className="text-xs text-gray-500">{selectedPlanCapsules / 10} packs of 10 · min. 60</div>
</div>
<button
type="button"
onClick={() => changePlanSize(+10)}
className="h-10 w-10 rounded-full bg-gray-100 hover:bg-gray-200 text-lg font-bold transition flex items-center justify-center"
>+</button>
<div className="ml-4">
{shippingLoading ? (
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700">{t('autofix.k12a86c71')}</span>
) : isFreeShippingSelected ? (
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">{t('autofix.ke7f0a9e3')}</span>
) : (
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">Shipping {selectedShippingFee.toFixed(2)}</span>
)}
</div>
</div>
{shippingError && (
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
Shipping fees could not be loaded: {shippingError}
</div>
)}
</div>
<h2 className="text-xl font-semibold mb-4">{t('autofix.k0b03e660')}</h2>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{coffees.map((coffee) => {
const active = coffee.id in selections;
const qty = selections[coffee.id] || 0;
const remainingCapsules = selectedPlanCapsules - totalCapsules;
const maxForCoffee = active
? Math.min(120, qty + remainingCapsules)
: 0;
const sliderMax = Math.max(10, maxForCoffee);
const sliderProgress = sliderMax <= 10
? 100
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
const canAddCoffee = active || remainingCapsules >= 10;
return (
<div
key={coffee.id}
className={`group rounded-xl border p-4 shadow-sm transition ${
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
}`}
>
<div className="relative overflow-hidden rounded-md mb-3">
{coffee.image ? (
<img
src={coffee.image}
alt={coffee.name}
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/> />
) : (
<div className="h-36 w-full bg-gray-100 rounded-md" /> <AboStepper currentStep={1} />
)}
{/* price badge (per 10) */} <PlanSelectorCard
<div className="absolute top-2 right-2 flex flex-col items-end gap-1"> selectedPlanCapsules={selectedPlanCapsules}
<span shippingLoading={shippingLoading}
aria-label={`Price €${coffee.pricePer10} per 10 capsules`} isFreeShippingSelected={isFreeShippingSelected}
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${ selectedShippingFee={selectedShippingFee}
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80' shippingError={shippingError}
}`} onDecrease={() => changePlanSize(-10)}
> onIncrease={() => changePlanSize(+10)}
{coffee.pricePer10} loadingText={t('autofix.k12a86c71')}
</span> freeShippingText={t('autofix.ke7f0a9e3')}
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">{t('autofix.k83deba83')}</span> />
</div>
</div> <CoffeeSelectionGrid
<div className="flex items-start justify-between"> coffees={coffees}
<h3 className="font-semibold text-sm">{coffee.name}</h3> loading={loading}
</div> error={error}
<p className="mt-2 text-xs text-gray-600 leading-relaxed"> selections={selections}
{coffee.description} bump={bump}
</p> selectedPlanCapsules={selectedPlanCapsules}
<button totalCapsules={totalCapsules}
type="button" onToggleCoffee={toggleCoffee}
onClick={() => toggleCoffee(coffee.id)} onChangeQuantity={changeQuantity}
disabled={!canAddCoffee} title={t('autofix.k0b03e660')}
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${ />
active
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10' <SelectionSummaryCard
: canAddCoffee selectedEntries={selectedEntries}
? 'border-gray-300 hover:bg-gray-100' shippingLoading={shippingLoading}
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed' isFreeShippingSelected={isFreeShippingSelected}
}`} selectedShippingFee={selectedShippingFee}
> totalNetWithShipping={totalNetWithShipping}
{active ? 'Remove' : 'Add'} totalCapsules={totalCapsules}
</button> packsSelected={packsSelected}
{active && ( selectedPlanCapsules={selectedPlanCapsules}
<div className="mt-4 flex flex-col gap-3"> requiredPacks={requiredPacks}
<div className="flex items-center justify-between"> canProceed={canProceed}
<span className="text-[11px] font-medium text-gray-500">Quantity (10{maxForCoffee} pcs)</span> onProceed={proceedToSummary}
<span title={t('autofix.ke7b634f2')}
className={`inline-flex items-center justify-center rounded-full bg-[#1C2B4A] text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`} emptyText={t('autofix.kec078e54')}
> continueText={t('autofix.k02665163')}
{qty} pcs
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => changeQuantity(coffee.id, -10)}
disabled={qty <= 10}
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
>
-10
</button>
<div className="flex-1 relative">
<input
type="range"
min={10}
max={sliderMax}
step={10}
value={qty}
onChange={(e) =>
changeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)
}
className="w-full appearance-none cursor-pointer bg-transparent"
style={{
background:
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
sliderProgress +
'%,#e5e7eb ' +
sliderProgress +
'%,#e5e7eb 100%)',
height: '6px',
borderRadius: '999px',
}}
/> />
</div> </div>
<button
onClick={() => changeQuantity(coffee.id, +10)}
disabled={qty + 10 > maxForCoffee}
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
>
+10
</button>
</div>
<div className="flex items-center justify-between text-[11px] text-gray-500">
<span>Subtotal</span>
<span className="font-semibold text-gray-700">
{((qty / 10) * coffee.pricePer10).toFixed(2)}
</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Section 2: Compact preview + next steps */}
<section>
<h2 className="text-xl font-semibold mb-4">{t('autofix.ke7b634f2')}</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
{selectedEntries.length === 0 && (
<p className="text-sm text-gray-600">{t('autofix.kec078e54')}</p>
)}
{selectedEntries.map((entry) => (
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
<div className="flex flex-col">
<span className="font-medium">{entry.coffee.name}</span>
<span className="text-xs text-gray-500">
{entry.quantity} Stk {' '}
<span className="inline-flex items-center font-semibold text-[#1C2B4A]">
{entry.coffee.pricePer10}/10
</span>
</span>
</div>
<div className="text-right font-semibold">
{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}
</div>
</div>
))}
{/* Shipping */}
<div className="flex justify-between text-sm border-b pb-2">
<span className="text-sm font-medium">Shipping</span>
<span className="text-sm font-semibold">
{shippingLoading ? (
'Loading…'
) : isFreeShippingSelected ? (
'FREE SHIPPING'
) : (
`${selectedShippingFee.toFixed(2)}`
)}
</span>
</div>
<div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
{totalNetWithShipping.toFixed(2)}
</span>
</div>
{/* Packs/capsules summary and validation hint (refined design) */}
<div className="text-xs text-gray-700">
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
{packsSelected !== requiredPacks && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
{packsSelected < requiredPacks ? `${requiredPacks - packsSelected} packs missing.` : `${packsSelected - requiredPacks} packs too many — reduce plan size or remove some coffees.`}
</span>
)}
</div>
<button
onClick={proceedToSummary}
disabled={!canProceed}
className={`group w-full mt-2 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
canProceed
? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg'
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
}`}
>{t('autofix.k02665163')}<svg
className={`ml-2 h-5 w-5 transition-transform ${
canProceed ? 'group-hover:translate-x-0.5' : ''
}`}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{!canProceed && (
<p className="text-xs text-gray-600">
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected. Use the / buttons above to adjust the plan size.
</p>
)}
</div>
</section>
</div> </div>
</PageLayout> </PageLayout>
); );

View File

@ -1,5 +1,8 @@
'use client' 'use client'
import { useTranslation } from '../../../i18n/useTranslation';
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
type Props = { type Props = {
@ -11,6 +14,7 @@ type Props = {
} }
export default function SignaturePad({ value, onChange, className, required = false, error = null }: Props) { export default function SignaturePad({ value, onChange, className, required = false, error = null }: Props) {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement | null>(null) const canvasRef = useRef<HTMLCanvasElement | null>(null)
const isDrawing = useRef(false) const isDrawing = useRef(false)
@ -163,9 +167,7 @@ export default function SignaturePad({ value, onChange, className, required = fa
onTouchEnd={endDrawing} onTouchEnd={endDrawing}
/> />
</div> </div>
<p className={`mt-2 text-xs ${error ? 'text-red-700' : 'text-gray-500'}`}> <p className={`mt-2 text-xs ${error ? 'text-red-700' : 'text-gray-500'}`}>{error || (value ? t('autofix.k352c82ef') : t('autofix.k99595e55'))}</p>
{error || (value ? 'Signature captured.' : 'Draw your signature in the box.')}
</p>
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,7 @@ type ContractFileItem = {
} }
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) { export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null) const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -153,7 +153,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to fetch user details') throw new Error(response.message || 'Failed to fetch user details')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details' const errorMessage = err instanceof Error ? err.message: t('autofix.k4db605d2')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.fetchUserDetails error:', err) console.error('UserDetailModal.fetchUserDetails error:', err)
} finally { } finally {
@ -173,7 +173,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to fetch permissions') throw new Error(response.message || 'Failed to fetch permissions')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch permissions' const errorMessage = err instanceof Error ? err.message: t('autofix.k5d0bc259')
setPermissionsError(errorMessage) setPermissionsError(errorMessage)
console.error('UserDetailModal.fetchAllPermissions error:', err) console.error('UserDetailModal.fetchAllPermissions error:', err)
} finally { } finally {
@ -205,7 +205,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to update permissions') throw new Error(response.message || 'Failed to update permissions')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update permissions' const errorMessage = err instanceof Error ? err.message: t('autofix.kd95d1874')
setPermissionsError(errorMessage) setPermissionsError(errorMessage)
console.error('UserDetailModal.handleSavePermissions error:', err) console.error('UserDetailModal.handleSavePermissions error:', err)
} finally { } finally {
@ -231,7 +231,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to update user status') throw new Error(response.message || 'Failed to update user status')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update user status' const errorMessage = err instanceof Error ? err.message: t('autofix.kc87fa1e9')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.handleStatusChange error:', err) console.error('UserDetailModal.handleStatusChange error:', err)
} finally { } finally {
@ -258,7 +258,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to update verification status') throw new Error(response.message || 'Failed to update verification status')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status' const errorMessage = err instanceof Error ? err.message: t('autofix.k1c3676e1')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.handleToggleAdminVerification error:', err) console.error('UserDetailModal.handleToggleAdminVerification error:', err)
} finally { } finally {

View File

@ -73,7 +73,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to fetch user details') throw new Error(response.message || 'Failed to fetch user details')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details' const errorMessage = err instanceof Error ? err.message: t('autofix.k4db605d2')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.fetchUserDetails error:', err) console.error('UserDetailModal.fetchUserDetails error:', err)
} finally { } finally {
@ -114,7 +114,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
} }
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to archive/unarchive user' const errorMessage = err instanceof Error ? err.message: t('autofix.k6ea4b2f8')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.handleArchiveUser error:', err) console.error('UserDetailModal.handleArchiveUser error:', err)
} finally { } finally {
@ -143,7 +143,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to update user profile') throw new Error(response.message || 'Failed to update user profile')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update user profile' const errorMessage = err instanceof Error ? err.message: t('autofix.kcf993bd6')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.handleSaveProfile error:', err) console.error('UserDetailModal.handleSaveProfile error:', err)
} finally { } finally {
@ -171,7 +171,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
throw new Error(response.message || 'Failed to update verification status') throw new Error(response.message || 'Failed to update verification status')
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status' const errorMessage = err instanceof Error ? err.message: t('autofix.k1c3676e1')
setError(errorMessage) setError(errorMessage)
console.error('UserDetailModal.handleToggleAdminVerification error:', err) console.error('UserDetailModal.handleToggleAdminVerification error:', err)
} finally { } finally {
@ -707,15 +707,11 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className={`${userDetails?.userStatus?.status === 'inactive' ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'} border rounded-lg p-4 mb-4`}> <div className={`${userDetails?.userStatus?.status === 'inactive' ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'} border rounded-lg p-4 mb-4`}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<ExclamationTriangleIcon className={`h-5 w-5 ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-600' : 'text-red-600'}`} /> <ExclamationTriangleIcon className={`h-5 w-5 ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-600' : 'text-red-600'}`} />
<h4 className={`text-sm font-medium ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-900' : 'text-red-900'}`}> <h4 className={`text-sm font-medium ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-900' : 'text-red-900'}`}>{userDetails?.userStatus?.status === 'inactive' ? t('autofix.k27e6186d') : t('autofix.k9e22a656')}</h4>
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
</h4>
</div> </div>
<p className={`text-sm ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-700' : 'text-red-700'} mb-4`}> <p className={`text-sm ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-700' : 'text-red-700'} mb-4`}>{userDetails?.userStatus?.status === 'inactive'
{userDetails?.userStatus?.status === 'inactive' ? t('autofix.k87f6f23f')
? 'Are you sure you want to unarchive this user? This will reactivate their account.' : t('autofix.k2a537280')}</p>
: 'Are you sure you want to archive this user? This action will disable their account but preserve all their data.'}
</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button" type="button"
@ -734,9 +730,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</> </>
) : ( ) : (
<> <>
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />{userDetails?.userStatus?.status === 'inactive' ? t('autofix.k27e6186d') : t('autofix.k9e22a656')}</>
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
</>
)} )}
</button> </button>
<button <button
@ -816,9 +810,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</> </>
) : ( ) : (
<> <>
<ShieldCheckIcon className="h-4 w-4" /> <ShieldCheckIcon className="h-4 w-4" />{userDetails.userStatus.is_admin_verified === 1 ? t('autofix.kd9bdbef8') : t('autofix.k2778a0a3')}</>
{userDetails.userStatus.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
</>
)} )}
</button> </button>
)} )}
@ -832,9 +824,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600' : 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
}`} }`}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />{userDetails?.userStatus?.status === 'inactive' ? t('autofix.k27e6186d') : t('autofix.k9e22a656')}</button>
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
</button>
<button <button
type="button" type="button"

View File

@ -417,9 +417,7 @@ function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, o
onClick={sendVerificationEmail} onClick={sendVerificationEmail}
disabled={loading} disabled={loading}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >{loading ? 'Sending...' : t('autofix.k225da00c')}</button>
{loading ? 'Sending...' : 'Send Verification Email'}
</button>
</div> </div>
<div> <div>
@ -439,9 +437,7 @@ function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, o
onClick={verifyCode} onClick={verifyCode}
disabled={loading || !verificationCode.trim()} disabled={loading || !verificationCode.trim()}
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >{loading ? 'Verifying...' : t('autofix.k17aaf31e')}</button>
{loading ? 'Verifying...' : 'Verify Email'}
</button>
{message && ( {message && (
<div className="text-green-600 text-sm text-center">{message}</div> <div className="text-green-600 text-sm text-center">{message}</div>
@ -558,8 +554,7 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">{userType === 'company' ? t('autofix.kd6ac4bf2') : t('autofix.kbc71bfb0')}<span className="text-red-500">*</span>
{userType === 'company' ? 'Document Type' : 'ID Type'} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={idType} value={idType}
@ -586,8 +581,7 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">{userType === 'company' ? t('autofix.k0cf53bd7') : t('autofix.k7822eb6b')}<span className="text-red-500">*</span>
{userType === 'company' ? 'Registration/Document Number' : 'ID Number'} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -613,9 +607,7 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
onClick={handleUpload} onClick={handleUpload}
disabled={loading || !frontFile || !idType || !idNumber || !expiryDate} disabled={loading || !frontFile || !idType || !idNumber || !expiryDate}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >{loading ? 'Uploading...' : t('autofix.k522174ba')}</button>
{loading ? 'Uploading...' : 'Upload Documents'}
</button>
{message && ( {message && (
<div className="text-green-600 text-sm text-center">{message}</div> <div className="text-green-600 text-sm text-center">{message}</div>
@ -738,9 +730,7 @@ function ProfileCompletionModal({ userType, onClose, onSuccess }: {
onClick={handleSubmit} onClick={handleSubmit}
disabled={loading} disabled={loading}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >{loading ? 'Saving...' : t('autofix.k91f24187')}</button>
{loading ? 'Saving...' : 'Complete Profile'}
</button>
{message && ( {message && (
<div className="text-green-600 text-sm text-center">{message}</div> <div className="text-green-600 text-sm text-center">{message}</div>
@ -845,9 +835,7 @@ function ContractSigningModal({ userType, onClose, onSuccess }: {
onClick={handleUpload} onClick={handleUpload}
disabled={loading || !contractFile || !agreed} disabled={loading || !contractFile || !agreed}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >{loading ? 'Uploading...' : t('autofix.k0724bd46')}</button>
{loading ? 'Uploading...' : 'Upload Signed Contract'}
</button>
{message && ( {message && (
<div className="text-green-600 text-sm text-center">{message}</div> <div className="text-green-600 text-sm text-center">{message}</div>

View File

@ -59,7 +59,7 @@ interface HeaderProps {
} }
export default function Header({ setGlobalLoggingOut }: HeaderProps) { export default function Header({ setGlobalLoggingOut }: HeaderProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { showToast } = useToast() const { showToast } = useToast()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@ -107,12 +107,12 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
await logout() await logout()
setMobileMenuOpen(false) setMobileMenuOpen(false)
startPageTransition() // mark transition active BEFORE toast so it defers startPageTransition() // mark transition active BEFORE toast so it defers
showToast({ variant: 'success', title: t('nav.logout'), message: 'You have been logged out successfully.' }) showToast({ variant: 'success', title: t('nav.logout'), message: t('autofix.k61d6b63b') })
router.push('/login') router.push('/login')
} catch (err) { } catch (err) {
console.error('Logout failed:', err) console.error('Logout failed:', err)
setGlobalLoggingOut?.(false) setGlobalLoggingOut?.(false)
showToast({ variant: 'error', message: 'Logout failed. Please try again.' }) showToast({ variant: 'error', message: t('autofix.k928c4e5d') })
} }
} }
@ -474,9 +474,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-400 transition-transform duration-300 ease-out data-[open=true]:rotate-90" className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-400 transition-transform duration-300 ease-out data-[open=true]:rotate-90"
data-open={mobileMenuOpen ? 'true' : 'false'} data-open={mobileMenuOpen ? 'true' : 'false'}
> >
<span className="sr-only"> <span className="sr-only">{mobileMenuOpen ? t('autofix.k00d01d0b') : t('autofix.k708e8be9')}</span>
{mobileMenuOpen ? 'Close main menu' : 'Open main menu'}
</span>
<span className="relative flex h-6 w-6 items-center justify-center"> <span className="relative flex h-6 w-6 items-center justify-center">
<Bars3Icon <Bars3Icon
aria-hidden="true" aria-hidden="true"
@ -555,9 +553,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
className="inline-flex items-center justify-center rounded-md p-2.5 text-gray-300 hover:text-white hover:bg-white/10 transition-colors" className="inline-flex items-center justify-center rounded-md p-2.5 text-gray-300 hover:text-white hover:bg-white/10 transition-colors"
> >
<span className="sr-only"> <span className="sr-only">{mobileMenuOpen ? t('autofix.k00d01d0b') : t('autofix.k708e8be9')}</span>
{mobileMenuOpen ? 'Close main menu' : 'Open main menu'}
</span>
<span className="relative flex h-6 w-6 items-center justify-center"> <span className="relative flex h-6 w-6 items-center justify-center">
<Bars3Icon <Bars3Icon
aria-hidden="true" aria-hidden="true"
@ -653,9 +649,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
<div className="font-medium text-gray-900 dark:text-white truncate"> <div className="font-medium text-gray-900 dark:text-white truncate">
{user?.firstName && user?.lastName ? `${user.firstName} ${user.lastName}` : (user?.email || 'User')} {user?.firstName && user?.lastName ? `${user.firstName} ${user.lastName}` : (user?.email || 'User')}
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400 truncate"> <div className="text-sm text-gray-600 dark:text-gray-400 truncate">{user?.email || t('autofix.k551c3cb3')}</div>
{user?.email || 'user@example.com'}
</div>
</div> </div>
</div> </div>

View File

@ -1907,7 +1907,7 @@ export const de: Translations = {
"kf962066f": "Vertragstyp", "kf962066f": "Vertragstyp",
"kb1cf599b": "Zählt als globaler Schlüssel", "kb1cf599b": "Zählt als globaler Schlüssel",
"k1c7ec4f2": "Admin Fetch Log", "k1c7ec4f2": "Admin Fetch Log",
"k057b3dbd": "visible for slow fetches", "k057b3dbd": "Sichtbar bei langsamen Ladevorgängen",
"k6d79b1df": "Englischen Wert benutzen", "k6d79b1df": "Englischen Wert benutzen",
"k78e1bf35": "Sprachdaten werden gesammelt...", "k78e1bf35": "Sprachdaten werden gesammelt...",
"k77d01d6a": "Offen ▾", "k77d01d6a": "Offen ▾",
@ -1950,25 +1950,143 @@ export const de: Translations = {
"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", "k2437072a": "ist fällig am",
"k247b74e1": "No templates yet.", "k247b74e1": "Keine Vorlagen gefunden.",
"k2f343849": "New Template", "k2f343849": "Neue Vorlage",
"k2fbe0857": "Template List", "k2fbe0857": "Vorlagen-Liste",
"k2fc164d2": "Template Name", "k2fc164d2": "Vorlagen-Name",
"k5270d585": "your invoice", "k5270d585": "Ihre Rechnung",
"k64efb463": "Subject:", "k64efb463": "Betreff:",
"k8e6829d0": "No HTML content", "k8e6829d0": "Kein HTML Inhalt",
"k9876e80c": "Profit Planet Team", "k9876e80c": "Profit Planet Team",
"ka1d10c8e": "Thanks for joining Profit Planet. We are happy to have you onboard.", "ka1d10c8e": "Herzlich willkommen bei Profit Planet! Es ist schön, Sie mit an Bord zu haben.",
"kb56e3ea8": "HTML Body", "kb56e3ea8": "HTML Body",
"kb8191dff": "Thank you!", "kb8191dff": "Danke!",
"kbbe1d6ba": "Invoice Reminder", "kbbe1d6ba": "Rechnungserinnerung",
"kbe86fadd": "Best regards,", "kbe86fadd": "Mit freundlichen Grüßen,",
"kd62cc394": "Frontend-only mock editor. Data is stored in local component state and resets on page reload.", "kd62cc394": "Nur-Frontend-Dummy-Editor. Daten werden im lokalen Komponentenzustand gespeichert und bei Seitenaktualisierung zurückgesetzt.",
"kd93a60af": "Mail Templates", "kd93a60af": "Mail Vorlagen",
"kefc3a3f9": "Live Preview", "kefc3a3f9": "Live-Vorschau",
"kf2426f05": "Edit this HTML content.", "kf2426f05": "Bearbeiten Sie diesen HTML-Inhalt.",
"kf34ed3b3": "Your HTML here", "kf34ed3b3": "Ihr HTML hier",
"k0aa53382": "html:",
"k26404a1a": "✓ Aktiv",
"k2fb166ad": "Vorlagen Typ",
"k48b366e4": "Übersetzungsschlüssel — in der Sprachverwaltung bearbeiten",
"k8aea9103": "Betreff:",
"kbdcb654a": "Aktive Vorlagen:",
"ke4326584": "Wird synchronisiert…",
"kf5fec72a": "✓ In der Sprachverwaltung synchronisiert",
"k3c6499f9": "Bericht konnte nicht gesendet werden.",
"k00d01d0b": "Hauptmenü schließen",
"k0188c7bc": "Alle in Vertrag verschieben",
"k0422a021": "Bereits hochgeladen",
"k0724bd46": "Unterzeichneten Vertrag hochladen",
"k0831f6d6": "✅ Gültig",
"k0cf53bd7": "Registrierungs-/Dokumentnummer",
"k12f2d162": "Wird hinzugefügt…",
"k1560a920": "Abonnement kündigen",
"k17aaf31e": "E-Mail verifizieren",
"k1b76fc38": "Kein Betreff",
"k1c3676e1": "Verifizierungsstatus konnte nicht aktualisiert werden",
"k1c5f641a": "Vorlage konnte nicht aktiviert werden.",
"k1db77fc0": "Alle erstellen",
"k225da00c": "Verifizierungs-E-Mail senden",
"k230e2c3c": "Speichern und abschließen",
"k245ba4af": "Keine archivierten Vorlagen.",
"k26c99007": "Wählen Sie aus, welche Matrix Sie prüfen möchten.",
"k2778a0a3": "Benutzer verifizieren",
"k27b5b842": "Wird erstellt…",
"k27e6186d": "Benutzer entarchivieren",
"k2a537280": "Sind Sie sicher, dass Sie diesen Benutzer archivieren möchten? Dadurch wird sein Konto deaktiviert, aber alle Daten bleiben erhalten.",
"k2dbfebb6": "In den Warenkorb",
"k2f162f5d": "Vorlage archiviert.",
"k32a13592": "Alle Kategorien",
"k352c82ef": "Unterschrift erfasst.",
"k3772baff": "Noch keine Vorlagen verfügbar. Erstellen Sie die erste Vorlage, um diesen Bereich zu füllen.",
"k3871d88e": "Seite wird neu geladen, um Namespace- und Schlüssel-Updates aus dem Auto-Fix zu übernehmen.",
"k481c2be7": "Speichern fehlgeschlagen",
"k485c3919": "Im Warenkorb",
"k49165061": "Zu Code wechseln",
"k4db605d2": "Benutzerdetails konnten nicht geladen werden",
"k522174ba": "Dokumente hochladen",
"k551c3cb3": "benutzer@beispiel.de",
"k55d88592": "🆕 Neues Logo ausgewählt",
"k59b7a324": "Zur Matrix hinzufügen",
"k5bcb3e1f": "Wird aktiviert…",
"k5d0bc259": "Berechtigungen konnten nicht geladen werden",
"k5daa1471": "▴ Ausblenden",
"k61d6b63b": "Sie wurden erfolgreich abgemeldet.",
"k69519588": "❌ Nein",
"k6c341c65": "Versuchen Sie, Ihre Suche oder den Filter anzupassen",
"k6ea4b2f8": "Benutzer konnte nicht archiviert/entarchiviert werden",
"k708e8be9": "Hauptmenü öffnen",
"k7822eb6b": "Ausweisnummer",
"k7c0b4d3d": "Ihr Passwort wurde geändert. Weiterleitung zum Login...",
"k867bfd52": "Nicht hochgeladen",
"k86d84b6d": "📷 Aktuelles Logo",
"k871d457e": "Wird deaktiviert…",
"k87f6f23f": "Sind Sie sicher, dass Sie diesen Benutzer entarchivieren möchten? Dadurch wird sein Konto reaktiviert.",
"k893106ba": "▾ Verwalten",
"k8a59f09e": "CSV importieren",
"k8e3691fb": "Download wird vorbereitet...",
"k8e3ac56f": "✅ Ja",
"k8ed66b3a": "Kein Inhalt verfügbar.",
"k928c4e5d": "Abmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"k9535ed27": "Mail-Vorlage aktualisiert.",
"k95d19932": "HTML-Vorschau",
"k99595e55": "Zeichnen Sie Ihre Unterschrift in das Feld.",
"k9e22a656": "Benutzer archivieren",
"k9e5c813b": "Fordern Sie einen Link zum Zurücksetzen Ihres Passworts an.",
"ka034e447": "Meine Matrizen",
"ka2c57fec": "Falls diese E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.",
"ka3076020": "Wird hochgeladen…",
"ka63bb731": "Diese Mail-Vorlage löschen? Dies kann nicht rückgängig gemacht werden.",
"kb07c8000": "Kontodaten herunterladen",
"kb270a988": "Speichern und weiter",
"kb2f958ef": "💀 Abgelaufen",
"kb743b7c2": "Speichern der Vorlage fehlgeschlagen.",
"kb8ee2877": "Wird heruntergeladen…",
"kbc71bfb0": "Ausweistyp",
"kc097ece0": "Archivierte Vorlagen",
"kc87fa1e9": "Benutzerstatus konnte nicht aktualisiert werden",
"kca441dcd": "Sie sind jetzt angemeldet.",
"kccf6593a": "Löschen der Vorlage fehlgeschlagen.",
"kcd59adff": "Persönlicher Teilbaum, datenschutzwahrend über Ebene 1 hinaus.",
"kcf993bd6": "Benutzerprofil konnte nicht aktualisiert werden",
"kd2e603d0": "Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten und versuchen Sie es erneut.",
"kd6ac4bf2": "Dokumenttyp",
"kd95d1874": "Berechtigungen konnten nicht aktualisiert werden",
"kd97a60ca": "Kaffee-Abonnement",
"kd9bdbef8": "Benutzerverifizierung aufheben",
"kdb037e3c": "Setzen Sie ein neues sicheres Passwort.",
"kdff3e58d": "Suche läuft…",
"ke0fc18df": "Abonnement fortsetzen",
"ke1a18ce6": "Vorlage aktiviert.",
"ke7ef5b62": "Keine Beschreibung",
"ke8a3bd92": "Mail-Vorlage erstellt.",
"keac0c9e7": "Vertragsunterzeichnung fehlgeschlagen",
"kecd706d7": "Wird aktualisiert…",
"kee838580": "Keine Aktualisierungszeit",
"kf12063b4": "Abonnement pausieren",
"kf1935909": "Meine Matrix-Übersicht",
"kf6b83106": "Vorlage gelöscht.",
"kfd0ee006": "Starten Sie, indem Sie einen neuen Affiliate-Partner hinzufügen",
"kff7b3b21": "Archivieren der Vorlage fehlgeschlagen.",
"kc3a71e92": "Vorlage wiederhergestellt.",
"ka91f3c05": "Wiederherstellen der Vorlage fehlgeschlagen.",
"k113e47af": "Kreditkarte",
"k1a7aa84d": "z. B. welcome, invoice-reminder",
"k2f00d2db": "Sofort-Überweisung",
"k32764a91": "Begrüßungsmail",
"k4f530782": "Bearbeiten Sie diesen HTML-Inhalt.",
"k5201934d": "Kein HTML-Inhalt",
"k5321f8f0": "Synchronisierung fehlgeschlagen (Inhalt in DB gespeichert)",
"k737db983": "Abonnement abschließen",
"k8735e9a4": "<div>Ihr HTML hier</div>",
"k88f0d12a": "Neue Vorlage",
"k987f2b90": "Erstellen",
"k9f7c3d1e": "Speichern"
}, },
"toasts": { "toasts": {
"loginSuccess": "Anmeldung erfolgreich", "loginSuccess": "Anmeldung erfolgreich",

View File

@ -1969,6 +1969,124 @@ export const en: Translations = {
"kefc3a3f9": "Live Preview", "kefc3a3f9": "Live Preview",
"kf2426f05": "Edit this HTML content.", "kf2426f05": "Edit this HTML content.",
"kf34ed3b3": "Your HTML here", "kf34ed3b3": "Your HTML here",
"k0aa53382": "html:",
"k26404a1a": "✓ Active",
"k2fb166ad": "Template Type",
"k48b366e4": "Translation Keys — edit in Language Management",
"k8aea9103": "subject:",
"kbdcb654a": "Active Templates",
"ke4326584": "Syncing…",
"kf5fec72a": "✓ Synced to Language Management",
"k3c6499f9": "Failed to send report.",
"k00d01d0b": "Close main menu",
"k0188c7bc": "Move All to Contract",
"k0422a021": "Already uploaded",
"k0724bd46": "Upload Signed Contract",
"k0831f6d6": "✅ Valid",
"k0cf53bd7": "Registration/Document Number",
"k12f2d162": "Adding…",
"k1560a920": "Cancel subscription",
"k17aaf31e": "Verify Email",
"k1b76fc38": "No subject",
"k1c3676e1": "Failed to update verification status",
"k1c5f641a": "Failed to activate template.",
"k1db77fc0": "Create All",
"k225da00c": "Send Verification Email",
"k230e2c3c": "Save and finish",
"k245ba4af": "No archived templates.",
"k26c99007": "Select which matrix you want to inspect.",
"k2778a0a3": "Verify User",
"k27b5b842": "Creating…",
"k27e6186d": "Unarchive User",
"k2a537280": "Are you sure you want to archive this user? This action will disable their account but preserve all their data.",
"k2dbfebb6": "Add to Cart",
"k2f162f5d": "Template archived.",
"k32a13592": "All Categories",
"k352c82ef": "Signature captured.",
"k3772baff": "No templates available yet. Create the first template to populate this workspace.",
"k3871d88e": "Reloading page to refresh namespace and key updates from auto-fix output.",
"k481c2be7": "Save failed",
"k485c3919": "In Warenkorb",
"k49165061": "Switch to Code",
"k4db605d2": "Failed to fetch user details",
"k522174ba": "Upload Documents",
"k551c3cb3": "user@example.com",
"k55d88592": "🆕 New logo selected",
"k59b7a324": "Add to Matrix",
"k5bcb3e1f": "Activating…",
"k5d0bc259": "Failed to fetch permissions",
"k5daa1471": "▴ Hide",
"k61d6b63b": "You have been logged out successfully.",
"k69519588": "❌ No",
"k6c341c65": "Try adjusting your search or filter",
"k6ea4b2f8": "Failed to archive/unarchive user",
"k708e8be9": "Open main menu",
"k7822eb6b": "ID Number",
"k7c0b4d3d": "Your password has been changed. Redirecting to login...",
"k867bfd52": "Not uploaded",
"k86d84b6d": "📷 Current logo",
"k871d457e": "Deactivating…",
"k87f6f23f": "Are you sure you want to unarchive this user? This will reactivate their account.",
"k893106ba": "▾ Manage",
"k8a59f09e": "Import CSV",
"k8e3691fb": "Preparing download...",
"k8e3ac56f": "✅ Yes",
"k8ed66b3a": "No content available.",
"k928c4e5d": "Logout failed. Please try again.",
"k9535ed27": "Mail template updated.",
"k95d19932": "Preview HTML",
"k99595e55": "Draw your signature in the box.",
"k9e22a656": "Archive User",
"k9e5c813b": "Request a link to reset your password.",
"ka034e447": "My Matrices",
"ka2c57fec": "If this email exists, a reset link has been sent.",
"ka3076020": "Uploading…",
"ka63bb731": "Delete this mail template? This cannot be undone.",
"kb07c8000": "Download Account Data",
"kb270a988": "Save and next",
"kb2f958ef": "💀 Expired",
"kb743b7c2": "Failed to save template.",
"kb8ee2877": "Downloading…",
"kbc71bfb0": "ID Type",
"kc097ece0": "Archived Templates",
"kc87fa1e9": "Failed to update user status",
"kca441dcd": "You are now logged in.",
"kccf6593a": "Failed to delete template.",
"kcd59adff": "Personal subtree, privacy-preserving beyond level 1.",
"kcf993bd6": "Failed to update user profile",
"kd2e603d0": "Login failed. Please check your credentials and try again.",
"kd6ac4bf2": "Document Type",
"kd95d1874": "Failed to update permissions",
"kd97a60ca": "Coffee Subscription",
"kd9bdbef8": "Unverify User",
"kdb037e3c": "Set a new secure password.",
"kdff3e58d": "Searching…",
"ke0fc18df": "Resume subscription",
"ke1a18ce6": "Template activated.",
"ke7ef5b62": "No description",
"ke8a3bd92": "Mail template created.",
"keac0c9e7": "Contract signing failed",
"kecd706d7": "Updating…",
"kee838580": "No update time",
"kf12063b4": "Pause subscription",
"kf1935909": "My Matrix Overview",
"kf6b83106": "Template deleted.",
"kfd0ee006": "Get started by adding a new affiliate partner",
"kff7b3b21": "Failed to archive template.",
"kc3a71e92": "Template unarchived.",
"ka91f3c05": "Failed to unarchive template.",
"k113e47af": "Credit Card",
"k1a7aa84d": "e.g. welcome, invoice-reminder",
"k2f00d2db": "Sofort Banking",
"k32764a91": "Welcome Mail",
"k4f530782": "Edit this HTML content.",
"k5201934d": "No HTML content",
"k5321f8f0": "Sync failed (content saved to DB)",
"k737db983": "Complete subscription",
"k8735e9a4": "<div>Your HTML here</div>",
"k88f0d12a": "New Template",
"k987f2b90": "Create",
"k9f7c3d1e": "Save"
}, },
"toasts": { "toasts": {
"loginSuccess": "Login successful", "loginSuccess": "Login successful",
@ -1995,3 +2113,4 @@ export const en: Translations = {
}, },
"mailTemplates": {} "mailTemplates": {}
}; };

View File

@ -79,7 +79,7 @@ export default function LoginForm() {
showToast({ showToast({
variant: 'success', variant: 'success',
title: 'Login successful', title: 'Login successful',
message: 'You are now logged in.' message: t('autofix.kca441dcd')
}) })
const redirectPath = (result as any).redirectPath || '/dashboard' const redirectPath = (result as any).redirectPath || '/dashboard'
@ -92,7 +92,7 @@ export default function LoginForm() {
showToast({ showToast({
variant: 'error', variant: 'error',
title: 'Login failed', title: 'Login failed',
message: result?.error || 'Login failed. Please check your credentials and try again.' message: result?.error || t('autofix.kd2e603d0')
}) })
} }
} }

View File

@ -137,9 +137,7 @@ export default function NewsDetailPage() {
/> />
</div> </div>
) : null} ) : null}
<div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere"> <div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere">{item.content || t('autofix.k8ed66b3a')}</div>
{item.content || 'No content available.'}
</div>
<div className="clear-both" /> <div className="clear-both" />
</div> </div>
</div> </div>

View File

@ -65,7 +65,7 @@ function PasswordResetPageInner() {
showToast({ showToast({
variant: 'success', variant: 'success',
title: 'Password reset email', title: 'Password reset email',
message: 'If this email exists, a reset link has been sent.', message: t('autofix.ka2c57fec'),
}) })
} catch { } catch {
const msg = 'Request failed. Please try again.' const msg = 'Request failed. Please try again.'
@ -112,7 +112,7 @@ function PasswordResetPageInner() {
showToast({ showToast({
variant: 'success', variant: 'success',
title: 'Password updated', title: 'Password updated',
message: 'Your password has been changed. Redirecting to login...', message: t('autofix.k7c0b4d3d'),
}) })
} catch { } catch {
const msg = 'Reset failed. Please try again.' const msg = 'Reset failed. Please try again.'
@ -174,11 +174,9 @@ function PasswordResetPageInner() {
<div className="relative"> <div className="relative">
<div className="mx-auto max-w-2xl text-center mb-8"> <div className="mx-auto max-w-2xl text-center mb-8">
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">{t('autofix.k09f4290f')}</h1> <h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">{t('autofix.k09f4290f')}</h1>
<p className="mt-3 text-slate-700 text-base sm:text-lg"> <p className="mt-3 text-slate-700 text-base sm:text-lg">{!token
{!token ? t('autofix.k9e5c813b')
? 'Request a link to reset your password.' : t('autofix.kdb037e3c')}</p>
: 'Set a new secure password.'}
</p>
</div> </div>
{!token && ( {!token && (
<form onSubmit={handleRequestSubmit} className="space-y-6"> <form onSubmit={handleRequestSubmit} className="space-y-6">

View File

@ -54,17 +54,11 @@ export default function PersonalMatrixPage() {
onClick={() => (selectedId == null ? router.push('/') : setSelectedId(undefined))} onClick={() => (selectedId == null ? router.push('/') : setSelectedId(undefined))}
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700" className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />{selectedId == null ? 'Back' : t('autofix.k65b67dc3')}</button>
{selectedId == null ? 'Back' : 'Back to matrices'} <h1 className="text-3xl font-extrabold text-blue-900">{selectedId == null ? t('autofix.ka034e447') : t('autofix.kf1935909')}</h1>
</button> <p className="text-base text-blue-700">{selectedId == null
<h1 className="text-3xl font-extrabold text-blue-900"> ? t('autofix.k26c99007')
{selectedId == null ? 'My Matrices' : 'My Matrix Overview'} : t('autofix.kcd59adff')}</p>
</h1>
<p className="text-base text-blue-700">
{selectedId == null
? 'Select which matrix you want to inspect.'
: 'Personal subtree, privacy-preserving beyond level 1.'}
</p>
</div> </div>
</div> </div>
</header> </header>

View File

@ -215,9 +215,7 @@ export default function FinanceInvoices({ abonementId }: Props) {
onClick={() => onDownload(invoice)} onClick={() => onDownload(invoice)}
disabled={busyId === invoice.id} disabled={busyId === invoice.id}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed" className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
> >{busyId === invoice.id ? t('autofix.kb8ee2877') : 'Download'}</button>
{busyId === invoice.id ? 'Downloading…' : 'Download'}
</button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -67,7 +67,7 @@ export default function UserAbo({ onAboChange }: Props) {
<div key={abonement.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg"> <div key={abonement.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-900">{abonement.name || 'Coffee Subscription'}</p> <p className="text-sm font-medium text-gray-900">{abonement.name || t('autofix.kd97a60ca')}</p>
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Next billing: {nextBilling} Next billing: {nextBilling}
{' • '}Frequency: {abonement.frequency ?? '—'} {' • '}Frequency: {abonement.frequency ?? '—'}

View File

@ -21,9 +21,10 @@ import { authFetch } from '../utils/authFetch'
// Helper to display missing fields in subtle gray italic (no yellow highlight) // Helper to display missing fields in subtle gray italic (no yellow highlight)
function HighlightIfMissing({ value, children, missingLabel }: { value: any, children: React.ReactNode, missingLabel?: React.ReactNode }) { function HighlightIfMissing({ value, children, missingLabel }: { value: any, children: React.ReactNode, missingLabel?: React.ReactNode }) {
const { t } = useTranslation();
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return ( return (
<span className="italic text-gray-400">{missingLabel ?? 'Not provided'}</span> <span className="italic text-gray-400">{missingLabel ?? t('autofix.kf2147f07')}</span>
); );
} }
return <>{children}</>; return <>{children}</>;
@ -329,7 +330,7 @@ export default function ProfilePage() {
<PageLayout className="bg-transparent text-gray-900"> <PageLayout className="bg-transparent text-gray-900">
<BlueBlurryBackground> <BlueBlurryBackground>
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8"> <main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* MASTER GLASS PANEL (prevents non-translucent gaps between cards) */} {/* MASTER GLASS PANEL (prevents non-translucent gaps between cards) */}
<div className={`${PROFILE_TOKENS.masterPanel} p-4 sm:p-6 lg:p-8`}> <div className={`${PROFILE_TOKENS.masterPanel} p-4 sm:p-6 lg:p-8`}>
{/* Page Header */} {/* Page Header */}
@ -402,9 +403,7 @@ export default function ProfilePage() {
onClick={handleDownloadAccountData} onClick={handleDownloadAccountData}
disabled={downloadLoading} disabled={downloadLoading}
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 rounded-2xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed break-words" className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 rounded-2xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed break-words"
> >{downloadLoading ? t('autofix.k8e3691fb') : t('autofix.kb07c8000')}</button>
{downloadLoading ? 'Preparing download...' : 'Download Account Data'}
</button>
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-2xl transition-colors break-words">{t('autofix.k41f7c81d')}</button> <button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-2xl transition-colors break-words">{t('autofix.k41f7c81d')}</button>
</div> </div>
{downloadError && ( {downloadError && (

View File

@ -305,7 +305,7 @@ export default function ProfileSubscriptionsPage() {
<div className="flex items-start justify-between gap-3 flex-wrap"> <div className="flex items-start justify-between gap-3 flex-wrap">
<div> <div>
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k3b8e0964')}</h2> <h2 className="text-lg font-semibold text-gray-900">{t('autofix.k3b8e0964')}</h2>
<p className="text-sm text-gray-600 mt-1">{selectedAbo.name || 'Coffee Subscription'}</p> <p className="text-sm text-gray-600 mt-1">{selectedAbo.name || t('autofix.kd97a60ca')}</p>
</div> </div>
<span <span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(status)}`} className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(status)}`}
@ -320,27 +320,21 @@ export default function ProfileSubscriptionsPage() {
onClick={() => openStatusConfirm('pause')} onClick={() => openStatusConfirm('pause')}
disabled={statusBusy} disabled={statusBusy}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed" className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
> >{statusBusy ? t('autofix.kecd706d7') : t('autofix.kf12063b4')}</button>
{statusBusy ? 'Updating…' : 'Pause subscription'}
</button>
)} )}
{status === 'pause' && ( {status === 'pause' && (
<button <button
onClick={() => openStatusConfirm('ongoing')} onClick={() => openStatusConfirm('ongoing')}
disabled={statusBusy} disabled={statusBusy}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed" className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
> >{statusBusy ? t('autofix.kecd706d7') : t('autofix.ke0fc18df')}</button>
{statusBusy ? 'Updating…' : 'Resume subscription'}
</button>
)} )}
{(status === 'ongoing' || status === 'pause' || status === 'issued') && ( {(status === 'ongoing' || status === 'pause' || status === 'issued') && (
<button <button
onClick={() => openStatusConfirm('cancelled')} onClick={() => openStatusConfirm('cancelled')}
disabled={statusBusy} disabled={statusBusy}
className="rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60 disabled:cursor-not-allowed" className="rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60 disabled:cursor-not-allowed"
> >{statusBusy ? t('autofix.kecd706d7') : t('autofix.k1560a920')}</button>
{statusBusy ? 'Updating…' : 'Cancel subscription'}
</button>
)} )}
{(status === 'finished' || status === 'cancelled') && ( {(status === 'finished' || status === 'cancelled') && (
<p className="text-xs text-gray-600">{t('autofix.k416bfe70')}</p> <p className="text-xs text-gray-600">{t('autofix.k416bfe70')}</p>
@ -447,7 +441,7 @@ export default function ProfileSubscriptionsPage() {
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-gray-200 px-3 py-2"> <div key={key} className="flex items-center justify-between gap-3 rounded-md border border-gray-200 px-3 py-2">
<div> <div>
<p className="text-sm font-medium text-gray-900">{coffee.name}</p> <p className="text-sm font-medium text-gray-900">{coffee.name}</p>
<p className="text-xs text-gray-600 line-clamp-1">{coffee.description || 'No description'}</p> <p className="text-xs text-gray-600 line-clamp-1">{coffee.description || t('autofix.ke7ef5b62')}</p>
</div> </div>
<input <input
type="number" type="number"
@ -472,9 +466,7 @@ export default function ProfileSubscriptionsPage() {
onClick={saveContentChanges} onClick={saveContentChanges}
disabled={savingContent || coffeesLoading} disabled={savingContent || coffeesLoading}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed" className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
> >{savingContent ? t('autofix.kac6cedc7') : t('autofix.k4be6f631')}</button>
{savingContent ? 'Saving…' : 'Save changes'}
</button>
<button <button
onClick={cancelEditingContent} onClick={cancelEditingContent}
disabled={savingContent} disabled={savingContent}

View File

@ -73,7 +73,7 @@ function ModernSelect({
onChange: (next: string) => void onChange: (next: string) => void
options: { value: string; label: string }[] options: { value: string; label: string }[]
}) { }) {
const { t } = useTranslation() const { t } = useTranslation();
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const btnRef = useRef<HTMLButtonElement | null>(null) const btnRef = useRef<HTMLButtonElement | null>(null)
@ -183,13 +183,13 @@ function ModernSelect({
} }
export default function CompanyAdditionalInformationPage() { export default function CompanyAdditionalInformationPage() {
const { t } = useTranslation();
const router = useRouter() const router = useRouter()
const user = useAuthStore(s => s.user) // NEW const user = useAuthStore(s => s.user) // NEW
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const { accessToken } = useAuthStore() const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
const { showToast } = useToast() const { showToast } = useToast()
const { t } = useTranslation()
const companyPhoneRef = useRef<TelephoneInputHandle | null>(null) const companyPhoneRef = useRef<TelephoneInputHandle | null>(null)
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null) const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
const secondPhoneRef = useRef<TelephoneInputHandle | null>(null) const secondPhoneRef = useRef<TelephoneInputHandle | null>(null)
@ -444,7 +444,7 @@ export default function CompanyAdditionalInformationPage() {
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Save failed' })) const errorData = await response.json().catch(() => ({ message: t('autofix.k481c2be7') }))
throw new Error(errorData.message || 'Save failed') throw new Error(errorData.message || 'Save failed')
} }

View File

@ -95,7 +95,7 @@ function ModernSelect({
onChange: (next: string) => void onChange: (next: string) => void
options: SelectOption[] options: SelectOption[]
}) { }) {
const { t } = useTranslation() const { t } = useTranslation();
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const btnRef = useRef<HTMLButtonElement | null>(null) const btnRef = useRef<HTMLButtonElement | null>(null)
@ -224,8 +224,8 @@ function ModernSelect({
} }
export default function PersonalAdditionalInformationPage() { export default function PersonalAdditionalInformationPage() {
const { t } = useTranslation();
const router = useRouter() const router = useRouter()
const { t } = useTranslation()
const user = useAuthStore(s => s.user) // NEW const user = useAuthStore(s => s.user) // NEW
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const { accessToken } = useAuthStore() const { accessToken } = useAuthStore()
@ -495,7 +495,7 @@ export default function PersonalAdditionalInformationPage() {
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Save failed' })) const errorData = await response.json().catch(() => ({ message: t('autofix.k481c2be7') }))
throw new Error(errorData.message || 'Save failed') throw new Error(errorData.message || 'Save failed')
} }

View File

@ -10,13 +10,13 @@ import { useToast } from '../../../components/toast/toastComponent'
import { useTranslation } from '../../../i18n/useTranslation' import { useTranslation } from '../../../i18n/useTranslation'
export default function CompanySignContractPage() { export default function CompanySignContractPage() {
const { t } = useTranslation();
const router = useRouter() const router = useRouter()
const user = useAuthStore(s => s.user) // NEW const user = useAuthStore(s => s.user) // NEW
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const { accessToken } = useAuthStore() const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
const { showToast } = useToast() const { showToast } = useToast()
const { t } = useTranslation()
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [signatureDataUrl, setSignatureDataUrl] = useState('') const [signatureDataUrl, setSignatureDataUrl] = useState('')
@ -265,7 +265,7 @@ export default function CompanySignContractPage() {
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Contract signing failed' })) const errorData = await response.json().catch(() => ({ message: t('autofix.keac0c9e7') }))
throw new Error(errorData.message || 'Contract signing failed') throw new Error(errorData.message || 'Contract signing failed')
} }

View File

@ -10,13 +10,13 @@ import { useToast } from '../../../components/toast/toastComponent'
import { useTranslation } from '../../../i18n/useTranslation' import { useTranslation } from '../../../i18n/useTranslation'
export default function PersonalSignContractPage() { export default function PersonalSignContractPage() {
const { t } = useTranslation();
const router = useRouter() const router = useRouter()
const user = useAuthStore(s => s.user) // NEW const user = useAuthStore(s => s.user) // NEW
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const { accessToken } = useAuthStore() const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() // CHANGED const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() // CHANGED
const { showToast } = useToast() const { showToast } = useToast()
const { t } = useTranslation()
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [signatureDataUrl, setSignatureDataUrl] = useState('') const [signatureDataUrl, setSignatureDataUrl] = useState('')
@ -304,7 +304,7 @@ export default function PersonalSignContractPage() {
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Contract signing failed' })) const errorData = await response.json().catch(() => ({ message: t('autofix.keac0c9e7') }))
throw new Error(errorData.message || 'Contract signing failed') throw new Error(errorData.message || 'Contract signing failed')
} }

View File

@ -693,9 +693,7 @@ export default function StorePage() {
} }
`} `}
> >
<ShoppingCartIcon className="mr-2 h-4 w-4" /> <ShoppingCartIcon className="mr-2 h-4 w-4" />{product.inStock ? t('autofix.k485c3919') : 'Ausverkauft'}</button>
{product.inStock ? 'In Warenkorb' : 'Ausverkauft'}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -694,9 +694,7 @@ export default function StorePage() {
} }
`} `}
> >
<ShoppingCartIcon className="mr-2 h-4 w-4" /> <ShoppingCartIcon className="mr-2 h-4 w-4" />{product.inStock ? t('autofix.k485c3919') : 'Ausverkauft'}</button>
{product.inStock ? 'In Warenkorb' : 'Ausverkauft'}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -205,9 +205,7 @@ export default function VipShopPage() {
disabled={!product.inStock} disabled={!product.inStock}
className={classNames('mt-4 flex w-full items-center justify-center rounded-md border border-transparent px-8 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2', product.inStock ? 'bg-[#8D6B1D] text-white hover:bg-[#7A5E1A]' : 'bg-gray-100 text-gray-400 cursor-not-allowed')} className={classNames('mt-4 flex w-full items-center justify-center rounded-md border border-transparent px-8 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2', product.inStock ? 'bg-[#8D6B1D] text-white hover:bg-[#7A5E1A]' : 'bg-gray-100 text-gray-400 cursor-not-allowed')}
> >
<ShoppingCartIcon className="mr-2 h-4 w-4" /> <ShoppingCartIcon className="mr-2 h-4 w-4" />{product.inStock ? t('autofix.k2dbfebb6') : 'Ausverkauft'}</button>
{product.inStock ? 'In den Warenkorb' : 'Ausverkauft'}
</button>
</div> </div>
</a> </a>
))} ))}

View File

@ -70,9 +70,7 @@ export default function TestRefreshPage() {
<div className="bg-white p-6 rounded-lg shadow text-black"> <div className="bg-white p-6 rounded-lg shadow text-black">
<h2 className="text-xl font-semibold mb-4 text-black">{t('autofix.kee28b8c6')}</h2> <h2 className="text-xl font-semibold mb-4 text-black">{t('autofix.kee28b8c6')}</h2>
<div className="space-y-2 font-mono text-sm text-black"> <div className="space-y-2 font-mono text-sm text-black">
<div>{t('autofix.k47b952de')}<span className={tokenInfo.hasToken ? 'text-green-600' : 'text-red-600'}> <div>{t('autofix.k47b952de')}<span className={tokenInfo.hasToken ? 'text-green-600' : 'text-red-600'}>{tokenInfo.hasToken ? t('autofix.k8e3ac56f') : t('autofix.k69519588')}</span></div>
{tokenInfo.hasToken ? '✅ Yes' : '❌ No'}
</span></div>
{tokenInfo.hasToken && ( {tokenInfo.hasToken && (
<> <>
@ -81,9 +79,7 @@ export default function TestRefreshPage() {
<div>{t('autofix.k4ed7f4d1')}<span className={tokenInfo.timeLeftSec <= 180 ? 'text-red-600' : 'text-green-600'}> <div>{t('autofix.k4ed7f4d1')}<span className={tokenInfo.timeLeftSec <= 180 ? 'text-red-600' : 'text-green-600'}>
{tokenInfo.timeLeftMin}m {tokenInfo.timeLeftSec % 60}s {tokenInfo.timeLeftMin}m {tokenInfo.timeLeftSec % 60}s
</span></div> </span></div>
<div>{t('autofix.k81c0b74b')}<span className={tokenInfo.isExpired ? 'text-red-600' : 'text-green-600'}> <div>{t('autofix.k81c0b74b')}<span className={tokenInfo.isExpired ? 'text-red-600' : 'text-green-600'}>{tokenInfo.isExpired ? t('autofix.kb2f958ef') : t('autofix.k0831f6d6')}</span></div>
{tokenInfo.isExpired ? '💀 Expired' : '✅ Valid'}
</span></div>
</> </>
)} )}
</div> </div>