Bibelbumser
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
a88c7cc133
commit
4074ea4eee
25
scripts/check-translations.mjs
Normal file
25
scripts/check-translations.mjs
Normal 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])}`));
|
||||||
@ -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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
template_type: '',
|
return {
|
||||||
name: '',
|
template_type: '',
|
||||||
subject: '',
|
name: '',
|
||||||
html_content: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>New Template</h2><p>Edit this HTML content.</p></div>',
|
subject: '',
|
||||||
};
|
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,193 +89,316 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (JPG, PNG, WebP only).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setError(`"${file.name}" exceeds the 10MB limit.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newFiles.push(file);
|
||||||
|
newPreviews.push(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
setGalleryFiles(prev => [...prev, ...newFiles]);
|
||||||
|
setGalleryPreviews(prev => [...prev, ...newPreviews]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveGalleryImage(index: number) {
|
||||||
|
setGalleryPreviews(prev => {
|
||||||
|
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 (
|
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">
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
|
||||||
{/* Header */}
|
|
||||||
<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">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kaa30f0cd')}</h1>
|
|
||||||
<p className="text-lg text-blue-700 mt-2">{t('autofix.kf72d41db')}</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="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
|
{/* Header card */}
|
||||||
<form onSubmit={onCreate} className="space-y-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">
|
||||||
{/* Picture Upload moved to top */}
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{t('autofix.kaa30f0cd')}</h1>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Picture</label>
|
<p className="mt-1 text-sm text-slate-500">{t('autofix.kf72d41db')}</p>
|
||||||
<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>
|
|
||||||
<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"
|
|
||||||
style={{ minHeight: '400px' }}
|
|
||||||
onClick={() => document.getElementById('file-upload')?.click()}
|
|
||||||
onDragOver={e => e.preventDefault()}
|
|
||||||
onDrop={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!previewUrl && (
|
|
||||||
<div className="text-center w-full px-6 py-10">
|
|
||||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
|
|
||||||
<div className="mt-4 text-base font-medium text-blue-700">
|
|
||||||
<span>{t('autofix.k6ee0a1b6')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-blue-600 mt-2">{t('autofix.k80ac9651')}</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">{t('autofix.k41ab9eb6')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{previewUrl && (
|
|
||||||
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt="Preview"
|
|
||||||
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-4 right-4 flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
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>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setPictureFile(undefined);
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
id="file-upload"
|
|
||||||
name="file-upload"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => handleSelectFile(e.target.files?.[0])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title moved above description */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label>
|
|
||||||
<input
|
|
||||||
id="title"
|
|
||||||
name="title"
|
|
||||||
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"
|
|
||||||
placeholder="Title"
|
|
||||||
value={title}
|
|
||||||
onChange={e => setTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description now after title */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-blue-900">Description</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
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"
|
|
||||||
rows={3}
|
|
||||||
placeholder={t('autofix.k3477c83a')}
|
|
||||||
value={description}
|
|
||||||
onChange={e => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-600">{t('autofix.k0affa826')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
|
||||||
{/* Price */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label>
|
|
||||||
<input
|
|
||||||
id="price"
|
|
||||||
name="price"
|
|
||||||
required
|
|
||||||
min={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"
|
|
||||||
value={price}
|
|
||||||
onChange={e => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setPrice(val);
|
|
||||||
}}
|
|
||||||
onBlur={e => {
|
|
||||||
const num = parseFloat(e.target.value);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
setPrice(num.toFixed(2));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Currency */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="currency" className="block text-sm font-medium text-blue-900">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))} />
|
|
||||||
</div>
|
|
||||||
{/* Featured */}
|
|
||||||
<div className="flex items-center gap-2 mt-6">
|
|
||||||
<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>
|
|
||||||
{/* Subscription Billing (Locked) + Availability */}
|
|
||||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-blue-900">{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>
|
|
||||||
<div className="mt-2 flex gap-4">
|
|
||||||
<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={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">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)}>
|
|
||||||
<option value="available">Available</option>
|
|
||||||
<option value="unavailable">Unavailable</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-x-4">
|
|
||||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
|
||||||
Cancel
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<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
|
||||||
|
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' }}
|
||||||
|
onClick={() => document.getElementById('file-upload')?.click()}
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!previewUrl && (
|
||||||
|
<div className="text-center w-full px-6 py-10">
|
||||||
|
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-slate-300" />
|
||||||
|
<div className="mt-4 text-base font-medium text-slate-600">
|
||||||
|
<span>{t('autofix.k6ee0a1b6')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mt-2">{t('autofix.k80ac9651')}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-2">{t('autofix.k41ab9eb6')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{previewUrl && (
|
||||||
|
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-slate-100 p-6">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-h-[380px] max-w-full object-contain rounded-xl shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); setShowCropModal(true); }}
|
||||||
|
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"
|
||||||
|
>{t('autofix.k73d1d7d7')}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); setPictureFile(undefined); setPreviewUrl(null); }}
|
||||||
|
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"
|
||||||
|
>Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
name="file-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => handleSelectFile(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
<div>
|
||||||
|
<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
|
||||||
|
id="title"
|
||||||
|
name="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"
|
||||||
|
placeholder="Title"
|
||||||
|
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"
|
||||||
|
name="description"
|
||||||
|
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"
|
||||||
|
rows={3}
|
||||||
|
placeholder={t('autofix.k3477c83a')}
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{t('autofix.k0affa826')}</p>
|
||||||
|
</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"
|
||||||
|
name="price"
|
||||||
|
required
|
||||||
|
min={0.01}
|
||||||
|
step={0.01}
|
||||||
|
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}
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Featured */}
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Billing + Availability */}
|
||||||
|
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-1">{t('autofix.ka3ee9ded')}</label>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<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-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="availability" className="block text-sm font-semibold text-slate-700 mb-1">Availability</label>
|
||||||
|
<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="unavailable">Unavailable</option>
|
||||||
|
</select>
|
||||||
|
</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 */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<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
|
||||||
|
</Link>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Crop Modal */}
|
{/* Image Crop Modal */}
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|
||||||
{loading && (
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">{t('autofix.k2d0798a6')}</div>
|
|
||||||
)}
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="rounded-md bg-red-50 p-4 text-red-700 text-sm mb-6">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && item && (
|
{/* Header card */}
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
|
<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 && (
|
||||||
|
<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 && (
|
||||||
|
<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 && (
|
||||||
|
<>
|
||||||
|
{/* 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>
|
||||||
)}
|
|
||||||
</main>
|
{/* 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,19 +86,45 @@ 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));
|
||||||
if (payload.currency) fd.append('currency', payload.currency);
|
if (payload.currency) fd.append('currency', payload.currency);
|
||||||
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
|
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
|
||||||
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
|
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
|
||||||
// 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');
|
||||||
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
};
|
||||||
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
|
|
||||||
|
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);
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</header>
|
</svg>
|
||||||
|
{t('autofix.kaa30f0cd')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{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
|
||||||
@ -339,7 +338,6 @@ export default function AdminSubscriptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
150
src/app/coffee-abonnements/[id]/page.tsx
Normal file
150
src/app/coffee-abonnements/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/coffee-abonnements/components/AboHeroHeader.tsx
Normal file
29
src/app/coffee-abonnements/components/AboHeroHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/coffee-abonnements/components/AboStepper.tsx
Normal file
25
src/app/coffee-abonnements/components/AboStepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/app/coffee-abonnements/components/CoffeeSelectionGrid.tsx
Normal file
172
src/app/coffee-abonnements/components/CoffeeSelectionGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/coffee-abonnements/components/PlanSelectorCard.tsx
Normal file
64
src/app/coffee-abonnements/components/PlanSelectorCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/coffee-abonnements/components/SelectionSummaryCard.tsx
Normal file
109
src/app/coffee-abonnements/components/SelectionSummaryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
109
src/app/coffee-abonnements/hooks/useCoffeePictures.ts
Normal file
109
src/app/coffee-abonnements/hooks/useCoffeePictures.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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 */}
|
<AboStepper currentStep={1} />
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
||||||
<div className="flex items-center">
|
<PlanSelectorCard
|
||||||
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
|
selectedPlanCapsules={selectedPlanCapsules}
|
||||||
<span className="ml-2 font-medium">Selection</span>
|
shippingLoading={shippingLoading}
|
||||||
</div>
|
isFreeShippingSelected={isFreeShippingSelected}
|
||||||
<div className="h-px flex-1 bg-gray-200" />
|
selectedShippingFee={selectedShippingFee}
|
||||||
<div className="flex items-center opacity-60">
|
shippingError={shippingError}
|
||||||
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
|
onDecrease={() => changePlanSize(-10)}
|
||||||
<span className="ml-2 font-medium">Summary</span>
|
onIncrease={() => changePlanSize(+10)}
|
||||||
</div>
|
loadingText={t('autofix.k12a86c71')}
|
||||||
|
freeShippingText={t('autofix.ke7f0a9e3')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CoffeeSelectionGrid
|
||||||
|
coffees={coffees}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
selections={selections}
|
||||||
|
bump={bump}
|
||||||
|
selectedPlanCapsules={selectedPlanCapsules}
|
||||||
|
totalCapsules={totalCapsules}
|
||||||
|
onToggleCoffee={toggleCoffee}
|
||||||
|
onChangeQuantity={changeQuantity}
|
||||||
|
title={t('autofix.k0b03e660')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectionSummaryCard
|
||||||
|
selectedEntries={selectedEntries}
|
||||||
|
shippingLoading={shippingLoading}
|
||||||
|
isFreeShippingSelected={isFreeShippingSelected}
|
||||||
|
selectedShippingFee={selectedShippingFee}
|
||||||
|
totalNetWithShipping={totalNetWithShipping}
|
||||||
|
totalCapsules={totalCapsules}
|
||||||
|
packsSelected={packsSelected}
|
||||||
|
selectedPlanCapsules={selectedPlanCapsules}
|
||||||
|
requiredPacks={requiredPacks}
|
||||||
|
canProceed={canProceed}
|
||||||
|
onProceed={proceedToSummary}
|
||||||
|
title={t('autofix.ke7b634f2')}
|
||||||
|
emptyText={t('autofix.kec078e54')}
|
||||||
|
continueText={t('autofix.k02665163')}
|
||||||
|
/>
|
||||||
</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" />
|
|
||||||
)}
|
|
||||||
{/* price badge (per 10) */}
|
|
||||||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
|
||||||
<span
|
|
||||||
aria-label={`Price €${coffee.pricePer10} per 10 capsules`}
|
|
||||||
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 ${
|
|
||||||
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
€{coffee.pricePer10}
|
|
||||||
</span>
|
|
||||||
<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>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
|
||||||
{coffee.description}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleCoffee(coffee.id)}
|
|
||||||
disabled={!canAddCoffee}
|
|
||||||
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'
|
|
||||||
: canAddCoffee
|
|
||||||
? 'border-gray-300 hover:bg-gray-100'
|
|
||||||
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{active ? 'Remove' : 'Add'}
|
|
||||||
</button>
|
|
||||||
{active && (
|
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[11px] font-medium text-gray-500">Quantity (10–{maxForCoffee} pcs)</span>
|
|
||||||
<span
|
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
@ -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 {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ?? '—'}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user