238 specs (CH/CZ/DE/NORDICS), dark/light theme, cascading filters, side-by-side comparison, checklist export, admin panel, bulk import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1185 lines
52 KiB
JavaScript
1185 lines
52 KiB
JavaScript
'use strict';
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
let allSpecs = [];
|
||
let currentSpec = null;
|
||
let adminAuthenticated = false;
|
||
|
||
const ADMIN_PASSWORD = 'loreal2024'; // override via server env in production
|
||
|
||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
try {
|
||
const res = await fetch('specs.json?v=' + Date.now());
|
||
const data = await res.json();
|
||
allSpecs = data.specs || [];
|
||
populateFilters();
|
||
renderCards();
|
||
renderRecentlyViewed();
|
||
checkPermalink();
|
||
initDropZone();
|
||
} catch (e) {
|
||
console.error('Failed to load specs.json', e);
|
||
showToast('Failed to load specs', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Tab navigation ────────────────────────────────────────────────────────────
|
||
function showTab(name) {
|
||
['browse', 'admin', 'help'].forEach(t => {
|
||
document.getElementById(`tab-${t}-content`).classList.add('hidden');
|
||
document.getElementById(`tab-${t}`).classList.remove('active');
|
||
});
|
||
document.getElementById(`tab-${name}-content`).classList.remove('hidden');
|
||
document.getElementById(`tab-${name}`).classList.add('active');
|
||
|
||
if (name === 'admin') renderAdminTable();
|
||
}
|
||
|
||
// ── Filters ───────────────────────────────────────────────────────────────────
|
||
function getFilterValues() {
|
||
return {
|
||
country: document.getElementById('filterCountry').value,
|
||
retailer: document.getElementById('filterRetailer').value,
|
||
content: document.getElementById('filterContent').value,
|
||
search: document.getElementById('filterSearch').value.trim().toLowerCase(),
|
||
};
|
||
}
|
||
|
||
function populateFilters() {
|
||
const f = getFilterValues();
|
||
|
||
// For cascading: filtered by upstream selections
|
||
const afterCountry = f.country ? allSpecs.filter(s => s.country === f.country) : allSpecs;
|
||
const afterRetailer = f.retailer ? afterCountry.filter(s => s.retailer === f.retailer) : afterCountry;
|
||
|
||
fillSelect('filterCountry', unique(allSpecs, 'country').sort(), f.country, 'All Countries');
|
||
fillSelect('filterRetailer', unique(afterCountry, 'retailer').sort(), f.retailer, 'All Retailers');
|
||
fillSelect('filterContent', unique(afterRetailer, 'contentGrouping').sort(), f.content, 'All Types');
|
||
}
|
||
|
||
function fillSelect(id, values, current, placeholder) {
|
||
const el = document.getElementById(id);
|
||
const prev = current || el.value;
|
||
el.innerHTML = `<option value="">${placeholder}</option>`;
|
||
values.forEach(v => {
|
||
if (!v) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = v;
|
||
opt.textContent = v;
|
||
if (v === prev) opt.selected = true;
|
||
el.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function unique(specs, key) {
|
||
return [...new Set(specs.map(s => s[key]).filter(Boolean))];
|
||
}
|
||
|
||
function onFilterChange() {
|
||
populateFilters();
|
||
renderCards();
|
||
}
|
||
|
||
function clearFilters() {
|
||
document.getElementById('filterCountry').value = '';
|
||
document.getElementById('filterRetailer').value = '';
|
||
document.getElementById('filterContent').value = '';
|
||
document.getElementById('filterSearch').value = '';
|
||
populateFilters();
|
||
renderCards();
|
||
}
|
||
|
||
function filteredSpecs() {
|
||
const { country, retailer, content, search } = getFilterValues();
|
||
return allSpecs.filter(s => {
|
||
if (country && s.country !== country) return false;
|
||
if (retailer && s.retailer !== retailer) return false;
|
||
if (content && s.contentGrouping !== content) return false;
|
||
if (search) {
|
||
const haystack = [s.format, s.retailer, s.dimensions, s.guidelines, s.contentGrouping, s.country]
|
||
.join(' ').toLowerCase();
|
||
if (!haystack.includes(search)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// ── Cards ─────────────────────────────────────────────────────────────────────
|
||
function hasActiveFilter() {
|
||
const { country, retailer, content, search } = getFilterValues();
|
||
return !!(country || retailer || content || search);
|
||
}
|
||
|
||
function renderCards() {
|
||
const specs = filteredSpecs();
|
||
const grid = document.getElementById('cardsGrid');
|
||
const empty = document.getElementById('emptyState');
|
||
const prompt = document.getElementById('filterPrompt');
|
||
|
||
// No filter selected yet — show prompt, hide everything else
|
||
if (!hasActiveFilter()) {
|
||
grid.innerHTML = '';
|
||
empty.classList.add('hidden');
|
||
prompt.classList.remove('hidden');
|
||
document.getElementById('resultsCount').textContent = '';
|
||
document.getElementById('checklistBtn').classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
prompt.classList.add('hidden');
|
||
document.getElementById('resultsCount').textContent =
|
||
`${specs.length} spec${specs.length !== 1 ? 's' : ''} found`;
|
||
|
||
// Show checklist button when there are results worth exporting
|
||
const checklistBtn = document.getElementById('checklistBtn');
|
||
checklistBtn.classList.toggle('hidden', specs.length === 0);
|
||
|
||
if (!specs.length) {
|
||
grid.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
empty.classList.add('hidden');
|
||
|
||
grid.innerHTML = specs.map((s, i) => {
|
||
const rv = reviewStatus(s);
|
||
return `
|
||
<div class="spec-card fade-up" style="animation-delay:${Math.min(i*20, 200)}ms" data-spec-id="${escHtml(s.id)}">
|
||
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;">
|
||
<span class="badge ${contentBadgeClass(s.contentGrouping)}">${shortContentLabel(s.contentGrouping)}</span>
|
||
${rv.status !== 'ok' ? `<span class="badge ${rv.badgeClass}">${rv.label}</span>` : ''}
|
||
</div>
|
||
<span class="badge badge-country">${s.country || '–'}</span>
|
||
</div>
|
||
<p style="font-weight:600;color:var(--text);font-size:14px;line-height:1.4;margin-bottom:4px;">${escHtml(s.format) || '–'}</p>
|
||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">${escHtml(s.retailer) || '–'}</p>
|
||
<div style="display:flex;flex-wrap:wrap;gap:10px;font-size:12px;color:var(--text-muted);">
|
||
${s.dimensions && s.dimensions !== 'NO INFO' ? `<span style="display:flex;align-items:center;gap:4px;">
|
||
<svg width="11" height="11" fill="none" stroke="#f59e0b" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||
${escHtml(s.dimensions)}</span>` : ''}
|
||
${s.maxWeight && s.maxWeight !== 'NO INFO' ? `<span style="display:flex;align-items:center;gap:4px;">
|
||
<svg width="11" height="11" fill="none" stroke="var(--text-faint)" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||
${escHtml(s.maxWeight)}</span>` : ''}
|
||
</div>
|
||
${s.fileTypes && s.fileTypes.length ? `<div style="margin-top:8px;">${s.fileTypes.map(t => `<span class="file-type-pill">${t}</span>`).join('')}</div>` : ''}
|
||
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<button class="card-download-btn" data-dl-id="${escHtml(s.id)}">
|
||
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
||
Download
|
||
</button>
|
||
<button class="card-compare-btn ${selectedForCompare.has(s.id) ? 'active' : ''}" data-cmp-id="${escHtml(s.id)}" title="Add to comparison">
|
||
<div class="card-checkbox ${selectedForCompare.has(s.id) ? 'checked' : ''}"></div>
|
||
Compare
|
||
</button>
|
||
</div>
|
||
<span style="font-size:12px;color:#f59e0b;font-weight:500;">View full spec →</span>
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
|
||
// Attach click handlers after render (avoids inline JSON issues)
|
||
grid.querySelectorAll('.spec-card').forEach(card => {
|
||
const id = card.dataset.specId;
|
||
const spec = specs.find(s => s.id === id);
|
||
if (!spec) return;
|
||
// Download button — stops propagation so card modal doesn't open
|
||
const dlBtn = card.querySelector('.card-download-btn');
|
||
if (dlBtn) dlBtn.addEventListener('click', e => { e.stopPropagation(); downloadSpec(spec); });
|
||
// Compare button — stops propagation so card modal doesn't open
|
||
const cmpBtn = card.querySelector('.card-compare-btn');
|
||
if (cmpBtn) cmpBtn.addEventListener('click', e => toggleCompare(spec.id, e));
|
||
// Rest of card opens modal
|
||
card.addEventListener('click', () => openModal(spec));
|
||
});
|
||
}
|
||
|
||
// ── Review status ─────────────────────────────────────────────────────────────
|
||
function reviewStatus(spec) {
|
||
const dateStr = spec.lastVerified || spec.updatedAt || '2026-04-27';
|
||
const verified = new Date(dateStr);
|
||
const nextReview = new Date(verified);
|
||
nextReview.setMonth(nextReview.getMonth() + 6);
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const daysLeft = Math.ceil((nextReview - today) / 86400000);
|
||
|
||
if (daysLeft < 0) return { status: 'overdue', label: 'Needs Review', badgeClass: 'badge-overdue', daysLeft, nextReview: fmtDate(nextReview) };
|
||
if (daysLeft <= 30) return { status: 'due-soon', label: 'Review Soon', badgeClass: 'badge-due-soon', daysLeft, nextReview: fmtDate(nextReview) };
|
||
return { status: 'ok', label: 'Verified', badgeClass: 'badge-verified', daysLeft, nextReview: fmtDate(nextReview) };
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
return d instanceof Date
|
||
? d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||
: d || '–';
|
||
}
|
||
|
||
function contentBadgeClass(cg) {
|
||
if (!cg) return 'badge-pdp';
|
||
const u = cg.toUpperCase();
|
||
if (u.includes('BANNER')) return 'badge-banners';
|
||
if (u.includes('BRAND')) return 'badge-brand';
|
||
if (u.includes('PDP')) return 'badge-pdp';
|
||
if (u.includes('CRM') || u.includes('NEWSLETTER')) return 'badge-crm';
|
||
if (u.includes('RETAIL')) return 'badge-retail';
|
||
return 'badge-pdp';
|
||
}
|
||
|
||
function shortContentLabel(cg) {
|
||
if (!cg) return 'Other';
|
||
const u = cg.toUpperCase();
|
||
if (u.includes('BANNER')) return 'Banners';
|
||
if (u.includes('BRAND')) return 'Brand Store';
|
||
if (u.includes('PDP')) return 'PDP';
|
||
if (u.includes('CRM') || u.includes('NEWSLETTER')) return 'CRM / Email';
|
||
if (u.includes('RETAIL')) return 'Retail Media';
|
||
return cg;
|
||
}
|
||
|
||
// ── Spec Modal ────────────────────────────────────────────────────────────────
|
||
function openModal(spec) {
|
||
currentSpec = spec;
|
||
|
||
document.getElementById('modalTitle').textContent = spec.format || 'Spec';
|
||
document.getElementById('modalSubtitle').textContent = `${spec.retailer || ''} · ${spec.country || ''}`;
|
||
|
||
// Badges
|
||
const rv = reviewStatus(spec);
|
||
document.getElementById('modalBadges').innerHTML = `
|
||
<span class="badge ${contentBadgeClass(spec.contentGrouping)}">${shortContentLabel(spec.contentGrouping)}</span>
|
||
<span class="badge badge-country">${spec.country || '–'}</span>
|
||
<span class="badge ${rv.badgeClass}">${rv.label}</span>
|
||
`;
|
||
|
||
// Quick stats
|
||
const stats = [
|
||
{ label: 'Dimensions', value: spec.dimensions && spec.dimensions !== 'NO INFO' ? spec.dimensions : null, icon: 'M3 3h18v18H3z', color: '#f59e0b' },
|
||
{ label: 'Max Weight', value: spec.maxWeight && spec.maxWeight !== 'NO INFO' ? spec.maxWeight : null, icon: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4', color: '#34d399' },
|
||
{ label: 'File Types', value: spec.fileTypes && spec.fileTypes.length ? spec.fileTypes.join(', ') : null, icon: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z', color: '#818cf8' },
|
||
].filter(s => s.value);
|
||
|
||
document.getElementById('modalQuickStats').innerHTML = stats.map(s => `
|
||
<div style="background:#111;border:1px solid #262626;border-radius:8px;padding:12px;">
|
||
<p style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#6b7280;margin-bottom:4px;">${s.label}</p>
|
||
<p style="font-size:14px;font-weight:600;color:${s.color};">${escHtml(s.value)}</p>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Detail rows
|
||
const fields = [
|
||
{ label: 'Retailer', value: spec.retailer },
|
||
{ label: 'Country', value: spec.country },
|
||
{ label: 'Content Grouping', value: spec.contentGrouping },
|
||
{ label: 'Division', value: spec.division },
|
||
{ label: 'Dimensions', value: spec.dimensions },
|
||
{ label: 'Max File Weight', value: spec.maxWeight },
|
||
{ label: 'File Types', value: spec.fileTypesRaw || (spec.fileTypes || []).join(', ') },
|
||
{ label: 'Max Assets', value: spec.maxAssets },
|
||
{ label: 'Asset Guidelines', value: spec.guidelines },
|
||
{ label: 'Delivery Method', value: spec.deliveryMethod },
|
||
{ label: 'Delivery Detail', value: spec.deliveryDetail },
|
||
{ label: 'Handled By', value: spec.handledBy },
|
||
{ label: 'Naming Convention', value: spec.namingConvention, field: 'namingConvention' },
|
||
{ label: 'Upload Link', value: spec.uploadLink },
|
||
{ label: 'Notes', value: spec.notes },
|
||
{ label: 'Last Updated', value: spec.updatedAt },
|
||
{ label: 'Last Verified', value: fmtDate(spec.lastVerified) },
|
||
{ label: 'Next Review Due', value: rv.nextReview + (rv.status === 'overdue' ? ' — OVERDUE' : rv.status === 'due-soon' ? ` — ${rv.daysLeft} days` : '') },
|
||
];
|
||
|
||
document.getElementById('modalDetails').innerHTML = fields.map(f => {
|
||
const empty = !f.value || ['NO INFO', 'N/A', '-', ''].includes(f.value.trim ? f.value.trim() : '');
|
||
const displayVal = empty ? 'No information provided' : escHtml(f.value);
|
||
const dataAttr = f.field ? `data-field="${f.field}"` : '';
|
||
return `
|
||
<div class="spec-detail-row" ${dataAttr}>
|
||
<span class="spec-detail-label">${f.label}</span>
|
||
<span class="spec-detail-value ${empty ? 'empty' : ''}">${displayVal}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Naming convention preview
|
||
const namingRow = document.querySelector('.spec-detail-row[data-field="namingConvention"]');
|
||
const namingExample = buildNamingExample(spec.namingConvention);
|
||
if (namingRow && namingExample) {
|
||
const existing = namingRow.querySelector('.naming-preview');
|
||
if (!existing) {
|
||
const preview = document.createElement('div');
|
||
preview.innerHTML = `<p class="naming-preview-label">Example filename</p><div class="naming-preview">${escHtml(namingExample)}</div>`;
|
||
namingRow.querySelector('.spec-detail-value').appendChild(preview);
|
||
}
|
||
}
|
||
|
||
updateURL(spec.id);
|
||
addToRecentlyViewed(spec);
|
||
document.getElementById('specModal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeModal(e) {
|
||
if (e.target === document.getElementById('specModal')) {
|
||
document.getElementById('specModal').classList.add('hidden');
|
||
clearURL();
|
||
}
|
||
}
|
||
|
||
function closeEditModal(e) {
|
||
if (e.target === document.getElementById('editModal')) {
|
||
document.getElementById('editModal').classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ── Export / Download ─────────────────────────────────────────────────────────
|
||
function buildExportHTML(s) {
|
||
const rv = reviewStatus(s);
|
||
const fields = [
|
||
['Retailer', s.retailer],
|
||
['Country', s.country],
|
||
['Content Grouping', s.contentGrouping],
|
||
['Dimensions', s.dimensions],
|
||
['Max File Weight', s.maxWeight],
|
||
['File Types', (s.fileTypes || []).join(', ') || s.fileTypesRaw],
|
||
['Max Number of Assets', s.maxAssets],
|
||
['Asset Guidelines', s.guidelines],
|
||
['Delivery Method', s.deliveryMethod],
|
||
['Delivery Detail', s.deliveryDetail],
|
||
['Naming Convention', s.namingConvention],
|
||
['Notes', s.notes],
|
||
['Last Verified', fmtDate(s.lastVerified)],
|
||
['Next Review Due', rv.nextReview],
|
||
].filter(([, v]) => v && !['NO INFO', 'N/A', '', '–'].includes(String(v).trim()));
|
||
|
||
const reviewBadge = rv.status === 'ok'
|
||
? `<span style="background:#f0fdf4;color:#16a34a;border:1px solid #bbf7d0;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:0.05em;">Verified</span>`
|
||
: rv.status === 'due-soon'
|
||
? `<span style="background:#fffbeb;color:#d97706;border:1px solid #fde68a;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:0.05em;">Review Soon</span>`
|
||
: `<span style="background:#fef2f2;color:#dc2626;border:1px solid #fecaca;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:0.05em;">Needs Review</span>`;
|
||
|
||
return `
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:680px;margin:0 auto;padding:32px 24px;color:#111;background:#fff;">
|
||
<div style="border-bottom:2px solid #f59e0b;padding-bottom:16px;margin-bottom:20px;">
|
||
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#f59e0b;margin:0 0 8px;">L'Oréal Spec Tool</p>
|
||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
|
||
<div>
|
||
<h1 style="font-size:19px;font-weight:700;color:#111;margin:0 0 4px;line-height:1.3;">${escHtml(s.format)}</h1>
|
||
<p style="font-size:13px;color:#6b7280;margin:0;">${escHtml(s.retailer)} · ${escHtml(s.country)} · ${escHtml(shortContentLabel(s.contentGrouping))}</p>
|
||
</div>
|
||
${reviewBadge}
|
||
</div>
|
||
</div>
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
||
${fields.map(([label, value]) => `
|
||
<tr style="border-bottom:1px solid #f3f4f6;">
|
||
<td style="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;padding:9px 12px 9px 0;width:170px;vertical-align:top;">${label}</td>
|
||
<td style="padding:9px 0;color:#111;line-height:1.6;vertical-align:top;">${escHtml(value)}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</table>
|
||
<p style="font-size:10px;color:#d1d5db;margin-top:20px;border-top:1px solid #f3f4f6;padding-top:12px;">
|
||
Generated by L'Oréal Spec Tool · ${new Date().toLocaleDateString('en-GB', { day:'2-digit', month:'long', year:'numeric' })}
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAndDownload(spec) {
|
||
const printArea = document.getElementById('printArea');
|
||
printArea.innerHTML = buildExportHTML(spec);
|
||
printArea.style.display = 'block';
|
||
html2canvas(printArea, { scale: 2, backgroundColor: '#ffffff', logging: false }).then(canvas => {
|
||
printArea.style.display = 'none';
|
||
const link = document.createElement('a');
|
||
link.download = `loreal-spec-${slugify(spec.retailer)}-${slugify(spec.format)}.png`;
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.click();
|
||
showToast('Spec downloaded', 'success');
|
||
}).catch(() => {
|
||
printArea.style.display = 'none';
|
||
window.print();
|
||
});
|
||
}
|
||
|
||
function exportPDF() {
|
||
if (!currentSpec) return;
|
||
renderAndDownload(currentSpec);
|
||
}
|
||
|
||
function downloadSpec(spec) {
|
||
renderAndDownload(spec);
|
||
}
|
||
|
||
function copySpecText() {
|
||
if (!currentSpec) return;
|
||
const s = currentSpec;
|
||
const lines = [
|
||
`SPEC: ${s.format}`,
|
||
`Retailer: ${s.retailer} (${s.country})`,
|
||
`Content Type: ${s.contentGrouping}`,
|
||
`Dimensions: ${s.dimensions}`,
|
||
`Max Weight: ${s.maxWeight}`,
|
||
`File Types: ${(s.fileTypes || []).join(', ')}`,
|
||
`Max Assets: ${s.maxAssets}`,
|
||
`Guidelines: ${s.guidelines}`,
|
||
`Delivery: ${s.deliveryDetail}`,
|
||
`Naming: ${s.namingConvention}`,
|
||
].filter(l => !l.endsWith(': ') && !l.includes('NO INFO')).join('\n');
|
||
|
||
navigator.clipboard.writeText(lines).then(() => {
|
||
showToast('Copied to clipboard', 'success');
|
||
});
|
||
}
|
||
|
||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||
function checkAdminPassword() {
|
||
const input = document.getElementById('adminPasswordInput').value;
|
||
if (input === ADMIN_PASSWORD) {
|
||
adminAuthenticated = true;
|
||
document.getElementById('adminPasswordOverlay').classList.add('hidden');
|
||
document.getElementById('adminPanel').classList.remove('hidden');
|
||
renderAdminTable();
|
||
} else {
|
||
document.getElementById('adminPasswordError').classList.remove('hidden');
|
||
document.getElementById('adminPasswordInput').value = '';
|
||
}
|
||
}
|
||
|
||
function toggleAddForm() {
|
||
const form = document.getElementById('addForm');
|
||
const btn = document.getElementById('toggleAddBtn');
|
||
const hidden = form.classList.contains('hidden');
|
||
form.classList.toggle('hidden', !hidden);
|
||
btn.textContent = hidden ? 'Hide form' : 'Show form';
|
||
}
|
||
|
||
function addSpec() {
|
||
const country = document.getElementById('newCountry').value.trim();
|
||
const retailer = document.getElementById('newRetailer').value.trim();
|
||
const format = document.getElementById('newFormat').value.trim();
|
||
|
||
if (!country || !retailer || !format) {
|
||
showToast('Country, Retailer and Format are required', 'error');
|
||
return;
|
||
}
|
||
|
||
const fileTypesRaw = document.getElementById('newFileTypes').value.trim();
|
||
const newSpec = {
|
||
id: crypto.randomUUID(),
|
||
country,
|
||
retailer,
|
||
contentGrouping: document.getElementById('newContentGrouping').value.trim(),
|
||
division: '',
|
||
format,
|
||
dimensions: document.getElementById('newDimensions').value.trim(),
|
||
maxWeight: document.getElementById('newMaxWeight').value.trim(),
|
||
fileTypes: fileTypesRaw ? fileTypesRaw.split(/[,\s]+/).map(s => s.toUpperCase()).filter(Boolean) : [],
|
||
fileTypesRaw,
|
||
maxAssets: document.getElementById('newMaxAssets').value.trim(),
|
||
guidelines: document.getElementById('newGuidelines').value.trim(),
|
||
deliveryMethod: 'MANUAL',
|
||
deliveryDetail: document.getElementById('newDeliveryDetail').value.trim(),
|
||
handledBy: '',
|
||
namingConvention:document.getElementById('newNamingConvention').value.trim(),
|
||
uploadLink: '',
|
||
imageExample: '',
|
||
notes: document.getElementById('newNotes').value.trim(),
|
||
updatedAt: new Date().toISOString().split('T')[0],
|
||
lastVerified: new Date().toISOString().split('T')[0],
|
||
};
|
||
|
||
allSpecs.push(newSpec);
|
||
saveSpecs(() => {
|
||
clearAddForm();
|
||
renderCards();
|
||
renderAdminTable();
|
||
showToast('Spec added', 'success');
|
||
});
|
||
}
|
||
|
||
function clearAddForm() {
|
||
['newCountry','newRetailer','newContentGrouping','newFormat','newDimensions',
|
||
'newMaxWeight','newFileTypes','newMaxAssets','newGuidelines','newDeliveryDetail',
|
||
'newNamingConvention','newNotes'].forEach(id => {
|
||
document.getElementById(id).value = '';
|
||
});
|
||
}
|
||
|
||
function renderAdminTable() {
|
||
if (!adminAuthenticated) return;
|
||
const search = (document.getElementById('adminSearch')?.value || '').toLowerCase();
|
||
const specs = search
|
||
? allSpecs.filter(s => [s.format, s.retailer, s.country, s.contentGrouping].join(' ').toLowerCase().includes(search))
|
||
: allSpecs;
|
||
|
||
document.getElementById('adminSpecCount').textContent = allSpecs.length;
|
||
|
||
document.getElementById('adminTableBody').innerHTML = specs.map(s => {
|
||
const rv = reviewStatus(s);
|
||
return `
|
||
<tr>
|
||
<td><span class="badge badge-country">${escHtml(s.country)}</span></td>
|
||
<td style="color:var(--text);">${escHtml(s.retailer)}</td>
|
||
<td><span class="badge ${contentBadgeClass(s.contentGrouping)}">${shortContentLabel(s.contentGrouping)}</span></td>
|
||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-sub);" title="${escHtml(s.format)}">${escHtml(s.format)}</td>
|
||
<td style="color:var(--text-muted);">${escHtml(s.dimensions) || '–'}</td>
|
||
<td>${(s.fileTypes || []).map(t => `<span class="file-type-pill">${t}</span>`).join('')}</td>
|
||
<td>
|
||
<span class="badge ${rv.badgeClass}" style="font-size:10px;">${rv.label}</span>
|
||
<span style="font-size:11px;color:var(--text-faint);display:block;margin-top:3px;">Due ${rv.nextReview}</span>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
<button class="btn-verify" onclick="markVerified('${s.id}')">✓ Verify</button>
|
||
<button class="btn-edit" onclick="openEditModal('${s.id}')">Edit</button>
|
||
<button class="btn-danger" onclick="deleteSpec('${s.id}')">Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`}).join('');
|
||
}
|
||
|
||
function deleteSpec(id) {
|
||
if (!confirm('Delete this spec? This cannot be undone.')) return;
|
||
allSpecs = allSpecs.filter(s => s.id !== id);
|
||
saveSpecs(() => {
|
||
renderCards();
|
||
renderAdminTable();
|
||
showToast('Spec deleted', 'success');
|
||
});
|
||
}
|
||
|
||
function markVerified(id) {
|
||
const idx = allSpecs.findIndex(s => s.id === id);
|
||
if (idx === -1) return;
|
||
const today = new Date().toISOString().split('T')[0];
|
||
allSpecs[idx] = { ...allSpecs[idx], lastVerified: today, updatedAt: today };
|
||
saveSpecs(() => {
|
||
renderCards();
|
||
renderAdminTable();
|
||
showToast('Spec marked as verified', 'success');
|
||
});
|
||
}
|
||
|
||
function openEditModal(id) {
|
||
const spec = allSpecs.find(s => s.id === id);
|
||
if (!spec) return;
|
||
|
||
document.getElementById('editSpecId').value = spec.id;
|
||
document.getElementById('editCountry').value = spec.country || '';
|
||
document.getElementById('editRetailer').value = spec.retailer || '';
|
||
document.getElementById('editContentGrouping').value = spec.contentGrouping || '';
|
||
document.getElementById('editFormat').value = spec.format || '';
|
||
document.getElementById('editDimensions').value = spec.dimensions || '';
|
||
document.getElementById('editMaxWeight').value = spec.maxWeight || '';
|
||
document.getElementById('editFileTypes').value = (spec.fileTypes || []).join(', ');
|
||
document.getElementById('editMaxAssets').value = spec.maxAssets || '';
|
||
document.getElementById('editGuidelines').value = spec.guidelines || '';
|
||
document.getElementById('editDeliveryDetail').value = spec.deliveryDetail || '';
|
||
document.getElementById('editNamingConvention').value= spec.namingConvention || '';
|
||
document.getElementById('editNotes').value = spec.notes || '';
|
||
|
||
document.getElementById('editModal').classList.remove('hidden');
|
||
}
|
||
|
||
function saveEditSpec() {
|
||
const id = document.getElementById('editSpecId').value;
|
||
const idx = allSpecs.findIndex(s => s.id === id);
|
||
if (idx === -1) return;
|
||
|
||
const fileTypesRaw = document.getElementById('editFileTypes').value.trim();
|
||
allSpecs[idx] = {
|
||
...allSpecs[idx],
|
||
country: document.getElementById('editCountry').value.trim(),
|
||
retailer: document.getElementById('editRetailer').value.trim(),
|
||
contentGrouping: document.getElementById('editContentGrouping').value.trim(),
|
||
format: document.getElementById('editFormat').value.trim(),
|
||
dimensions: document.getElementById('editDimensions').value.trim(),
|
||
maxWeight: document.getElementById('editMaxWeight').value.trim(),
|
||
fileTypes: fileTypesRaw ? fileTypesRaw.split(/[,\s]+/).map(s => s.toUpperCase()).filter(Boolean) : [],
|
||
fileTypesRaw,
|
||
maxAssets: document.getElementById('editMaxAssets').value.trim(),
|
||
guidelines: document.getElementById('editGuidelines').value.trim(),
|
||
deliveryDetail: document.getElementById('editDeliveryDetail').value.trim(),
|
||
namingConvention:document.getElementById('editNamingConvention').value.trim(),
|
||
notes: document.getElementById('editNotes').value.trim(),
|
||
updatedAt: new Date().toISOString().split('T')[0],
|
||
};
|
||
|
||
saveSpecs(() => {
|
||
document.getElementById('editModal').classList.add('hidden');
|
||
renderCards();
|
||
renderAdminTable();
|
||
showToast('Spec updated', 'success');
|
||
});
|
||
}
|
||
|
||
// ── Comparison ────────────────────────────────────────────────────────────────
|
||
const selectedForCompare = new Set();
|
||
|
||
function toggleCompare(specId, e) {
|
||
e.stopPropagation();
|
||
if (selectedForCompare.has(specId)) {
|
||
selectedForCompare.delete(specId);
|
||
} else {
|
||
if (selectedForCompare.size >= 3) {
|
||
showToast('Maximum 3 specs at once', 'error');
|
||
return;
|
||
}
|
||
selectedForCompare.add(specId);
|
||
}
|
||
updateCompareBar();
|
||
updateCardSelection();
|
||
}
|
||
|
||
function updateCardSelection() {
|
||
document.querySelectorAll('.spec-card').forEach(card => {
|
||
const id = card.dataset.specId;
|
||
const checkbox = card.querySelector('.card-checkbox');
|
||
const cmpBtn = card.querySelector('.card-compare-btn');
|
||
const selected = selectedForCompare.has(id);
|
||
card.classList.toggle('selected', selected);
|
||
if (checkbox) checkbox.classList.toggle('checked', selected);
|
||
if (cmpBtn) cmpBtn.classList.toggle('active', selected);
|
||
});
|
||
}
|
||
|
||
function updateCompareBar() {
|
||
const count = selectedForCompare.size;
|
||
const bar = document.getElementById('compareBar');
|
||
document.getElementById('compareCount').textContent = count;
|
||
document.getElementById('compareBtn').disabled = count < 2;
|
||
bar.classList.toggle('visible', count > 0);
|
||
|
||
// Chips
|
||
const specs = [...selectedForCompare].map(id => allSpecs.find(s => s.id === id)).filter(Boolean);
|
||
document.getElementById('compareChips').innerHTML = specs.map(s => `
|
||
<div class="compare-chip">
|
||
<span class="compare-chip-name" title="${escHtml(s.format)}">${escHtml(s.format)}</span>
|
||
<span class="compare-chip-remove" onclick="toggleCompare('${s.id}', event)">✕</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function clearComparison() {
|
||
selectedForCompare.clear();
|
||
updateCompareBar();
|
||
updateCardSelection();
|
||
}
|
||
|
||
function openComparison() {
|
||
if (selectedForCompare.size < 2) return;
|
||
const specs = [...selectedForCompare].map(id => allSpecs.find(s => s.id === id)).filter(Boolean);
|
||
|
||
const fields = [
|
||
{ label: 'Retailer', key: 'retailer' },
|
||
{ label: 'Country', key: 'country' },
|
||
{ label: 'Content Type', key: 'contentGrouping' },
|
||
{ label: 'Dimensions', key: 'dimensions' },
|
||
{ label: 'Max File Weight', key: 'maxWeight' },
|
||
{ label: 'File Types', key: null, fn: s => (s.fileTypes || []).join(', ') || s.fileTypesRaw || '' },
|
||
{ label: 'Max Assets', key: 'maxAssets' },
|
||
{ label: 'Asset Guidelines', key: 'guidelines' },
|
||
{ label: 'Delivery', key: 'deliveryDetail' },
|
||
{ label: 'Naming Convention', key: 'namingConvention' },
|
||
{ label: 'Review Status', key: null, fn: s => reviewStatus(s).label },
|
||
{ label: 'Last Verified', key: 'lastVerified' },
|
||
];
|
||
|
||
const isNoInfo = v => !v || ['NO INFO', 'N/A', '-', ''].includes(String(v).trim());
|
||
const display = v => isNoInfo(v) ? '–' : escHtml(String(v));
|
||
|
||
// Header row
|
||
const headerCells = specs.map(s => `
|
||
<th>
|
||
<div style="font-size:13px;font-weight:700;color:var(--text);text-transform:none;letter-spacing:0;">${escHtml(s.format)}</div>
|
||
<div style="font-size:11px;color:var(--text-muted);font-weight:400;margin-top:2px;">${escHtml(s.retailer)} · ${escHtml(s.country)}</div>
|
||
</th>
|
||
`).join('');
|
||
|
||
// Data rows
|
||
const dataRows = fields.map(f => {
|
||
const values = specs.map(s => {
|
||
const raw = f.fn ? f.fn(s) : (s[f.key] || '');
|
||
return isNoInfo(raw) ? '' : String(raw);
|
||
});
|
||
// Highlight if not all values are equal
|
||
const allSame = values.every(v => v === values[0]);
|
||
const cells = values.map(v => {
|
||
const isEmpty = !v;
|
||
return `<td class="${!allSame && !isEmpty ? 'diff' : ''}${isEmpty ? ' empty-val' : ''}">${isEmpty ? '–' : escHtml(v)}</td>`;
|
||
}).join('');
|
||
|
||
return `<tr>
|
||
<td class="row-label">${f.label}</td>
|
||
${cells}
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
document.getElementById('compareTable').innerHTML = `
|
||
<thead><tr><th style="width:140px;"></th>${headerCells}</tr></thead>
|
||
<tbody>${dataRows}</tbody>
|
||
`;
|
||
|
||
document.getElementById('compareModal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeCompareModal(e) {
|
||
if (e.target === document.getElementById('compareModal')) {
|
||
document.getElementById('compareModal').classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ── Checklist Export ──────────────────────────────────────────────────────────
|
||
function openChecklist() {
|
||
const { country, retailer, content } = getFilterValues();
|
||
const specs = filteredSpecs();
|
||
if (!specs.length) return;
|
||
|
||
const title = [retailer, country, content].filter(Boolean).join(' · ');
|
||
document.getElementById('checklistTitle').textContent = title;
|
||
|
||
// Group by content grouping
|
||
const groups = {};
|
||
specs.forEach(s => {
|
||
const g = s.contentGrouping || 'Other';
|
||
if (!groups[g]) groups[g] = [];
|
||
groups[g].push(s);
|
||
});
|
||
|
||
const today = new Date().toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' });
|
||
|
||
const html = `
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111;">
|
||
|
||
<!-- Header -->
|
||
<div style="border-bottom:3px solid #f59e0b;padding-bottom:20px;margin-bottom:28px;">
|
||
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#f59e0b;margin:0 0 8px;">L'Oréal Spec Tool — Production Checklist</p>
|
||
<h1 style="font-size:24px;font-weight:700;color:#111;margin:0 0 6px;line-height:1.2;">${escHtml(retailer || 'All Retailers')}</h1>
|
||
<div style="display:flex;flex-wrap:wrap;gap:16px;font-size:13px;color:#6b7280;">
|
||
${country ? `<span>Country: <strong style="color:#111;">${escHtml(country)}</strong></span>` : ''}
|
||
${content ? `<span>Type: <strong style="color:#111;">${escHtml(content)}</strong></span>` : ''}
|
||
<span>Total formats: <strong style="color:#111;">${specs.length}</strong></span>
|
||
<span>Generated: <strong style="color:#111;">${today}</strong></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Groups -->
|
||
${Object.entries(groups).map(([groupName, groupSpecs]) => `
|
||
<div style="margin-bottom:32px;page-break-inside:avoid;">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #e5e7eb;">
|
||
<h2 style="font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#f59e0b;margin:0;">${escHtml(groupName)}</h2>
|
||
<span style="font-size:11px;color:#9ca3af;">${groupSpecs.length} format${groupSpecs.length !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||
<thead>
|
||
<tr style="background:#f9fafb;">
|
||
<th style="width:24px;padding:8px 10px;"></th>
|
||
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Format</th>
|
||
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Dimensions</th>
|
||
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">File Types</th>
|
||
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Max Size</th>
|
||
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Delivery</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${groupSpecs.map((s, i) => `
|
||
<tr style="border-bottom:1px solid #f3f4f6;${i % 2 === 0 ? '' : 'background:#fafafa;'}">
|
||
<td style="padding:10px 10px;vertical-align:top;">
|
||
<div style="width:16px;height:16px;border:2px solid #d1d5db;border-radius:3px;"></div>
|
||
</td>
|
||
<td style="padding:10px 10px;vertical-align:top;">
|
||
<p style="font-weight:600;color:#111;margin:0 0 2px;line-height:1.4;">${escHtml(s.format)}</p>
|
||
${s.guidelines && s.guidelines !== 'NO INFO' ? `<p style="font-size:11px;color:#6b7280;margin:0;line-height:1.4;">${escHtml(s.guidelines.length > 120 ? s.guidelines.slice(0,120) + '…' : s.guidelines)}</p>` : ''}
|
||
</td>
|
||
<td style="padding:10px 10px;vertical-align:top;font-weight:600;color:${s.dimensions && s.dimensions !== 'NO INFO' ? '#f59e0b' : '#d1d5db'};">
|
||
${s.dimensions && s.dimensions !== 'NO INFO' ? escHtml(s.dimensions) : '–'}
|
||
</td>
|
||
<td style="padding:10px 10px;vertical-align:top;color:#374151;">
|
||
${(s.fileTypes || []).join(', ') || (s.fileTypesRaw && s.fileTypesRaw !== 'NO INFO' ? escHtml(s.fileTypesRaw) : '–')}
|
||
</td>
|
||
<td style="padding:10px 10px;vertical-align:top;color:#374151;">
|
||
${s.maxWeight && s.maxWeight !== 'NO INFO' ? escHtml(s.maxWeight) : '–'}
|
||
</td>
|
||
<td style="padding:10px 10px;vertical-align:top;font-size:11px;color:#6b7280;">
|
||
${s.deliveryDetail && s.deliveryDetail !== 'NO INFO' ? escHtml(s.deliveryDetail.length > 60 ? s.deliveryDetail.slice(0,60) + '…' : s.deliveryDetail) : '–'}
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`).join('')}
|
||
|
||
<!-- Footer -->
|
||
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;">
|
||
<p style="font-size:10px;color:#d1d5db;margin:0;">Generated by L'Oréal Spec Tool · ${today}</p>
|
||
<p style="font-size:10px;color:#d1d5db;margin:0;">□ = Not started ✓ = Complete — = N/A</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('checklistContent').innerHTML = html;
|
||
document.getElementById('checklistOverlay').classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeChecklist() {
|
||
document.getElementById('checklistOverlay').classList.remove('open');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
// ── Bulk Import ───────────────────────────────────────────────────────────────
|
||
let importParsed = [];
|
||
|
||
|
||
function handleImportFile(file) {
|
||
if (!file) return;
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
if (!['xlsx', 'xls', 'csv'].includes(ext)) {
|
||
showToast('Please upload an Excel (.xlsx) or CSV file', 'error');
|
||
return;
|
||
}
|
||
|
||
setImportStatus(`Reading ${file.name}…`);
|
||
document.getElementById('importPreview').classList.add('hidden');
|
||
document.getElementById('importFileInput').value = '';
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
try {
|
||
const data = new Uint8Array(e.target.result);
|
||
const wb = XLSX.read(data, { type: 'array' });
|
||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||
const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
|
||
|
||
// Find header row: must contain at least 2 of these column names as distinct cells
|
||
const HEADER_SIGNALS = ['COUNTRY','RETAILER','FORMAT','DIMENSION','GUIDELINES'];
|
||
let headerIdx = 0;
|
||
for (let i = 0; i < Math.min(6, raw.length); i++) {
|
||
const cells = raw[i].map(c => String(c).toUpperCase().trim());
|
||
const hits = HEADER_SIGNALS.filter(s => cells.some(c => c.includes(s)));
|
||
if (hits.length >= 2) { headerIdx = i; break; }
|
||
}
|
||
|
||
const headers = raw[headerIdx].map(h => String(h).trim());
|
||
const dataRows = raw.slice(headerIdx + 1).filter(r => r.some(c => c !== ''));
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
importParsed = dataRows
|
||
.map(r => Object.fromEntries(headers.map((h, i) => [h, String(r[i] || '').trim()])))
|
||
.filter(r => getImportField(r, ['RETAILER','retailer','Retailer']) ||
|
||
getImportField(r, ['FORMAT','format','Format','FORMAT NAME']))
|
||
.map(r => {
|
||
const fileTypesRaw = getImportField(r, ['FILE TYPE','file type','File Type','fileType']);
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
country: getImportField(r, ['COUNTRY','country','Country']),
|
||
retailer: getImportField(r, ['RETAILER','retailer','Retailer']),
|
||
contentGrouping: getImportField(r, ['ECOM CONTENT GROUPING','Content Grouping','contentGrouping','CONTENT GROUPING','category','Category']),
|
||
division: getImportField(r, ['DIVISION','division','Division']),
|
||
format: getImportField(r, ['FORMAT','format','Format','FORMAT NAME','format name','Format Name']),
|
||
dimensions: getImportField(r, ['ASSET DIMENSION','dimensions','Dimensions','dimension','size','Size']),
|
||
maxWeight: getImportField(r, ['ASSET WEIGHT','maxWeight','max weight','Max Weight','file size','File Size']),
|
||
fileTypes: splitImportFileTypes(fileTypesRaw),
|
||
fileTypesRaw,
|
||
maxAssets: getImportField(r, ['MAX NUMBER OF ASSETS','maxAssets','max assets','Max Assets']),
|
||
guidelines: getImportField(r, ['RETAILER ASSET GUIDELINES','guidelines','Guidelines','GUIDELINES']),
|
||
deliveryMethod: getImportField(r, ['DELIVERY METHOD FOR SYNDICATION','deliveryMethod','delivery method']),
|
||
deliveryDetail: getImportField(r, ['DETAILED','deliveryDetail','delivery detail','Delivery Detail']),
|
||
handledBy: getImportField(r, ['HANDLED BY (WIP)','handledBy','handled by']),
|
||
namingConvention:getImportField(r, ['FILE NAMING CONVENTION FOR RETAILER','namingConvention','naming convention','Naming Convention']),
|
||
uploadLink: getImportField(r, ['LINKS FOR UPLOAD','uploadLink','upload link']),
|
||
imageExample: getImportField(r, ['IMAGE EXAMPLE','imageExample','image example']),
|
||
notes: getImportField(r, ['notes','Notes','NOTES']),
|
||
updatedAt: today,
|
||
lastVerified: today,
|
||
};
|
||
});
|
||
|
||
if (!importParsed.length) {
|
||
setImportStatus('No valid specs found — check the file has RETAILER and FORMAT columns.');
|
||
return;
|
||
}
|
||
showImportPreview(importParsed);
|
||
setImportStatus(`Parsed ${importParsed.length} specs from ${file.name}`);
|
||
} catch (err) {
|
||
setImportStatus(`Could not read file: ${err.message}`);
|
||
showToast('Failed to parse file', 'error');
|
||
}
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
}
|
||
|
||
function getImportField(row, keys) {
|
||
for (const k of keys) {
|
||
const v = String(row[k] || '').trim();
|
||
if (v && !['NO INFO', 'N/A', '-'].includes(v)) return v;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function splitImportFileTypes(raw) {
|
||
if (!raw) return [];
|
||
const known = new Set(['JPG','JPEG','PNG','TIFF','TIF','PSD','WEBP','SVG','ZIP','PDF','GIF','MP4','MOV','AVI']);
|
||
return [...new Set(
|
||
raw.toUpperCase().split(/[\s,/|]+/)
|
||
.map(p => p.replace(/[^A-Z]/g, ''))
|
||
.filter(p => known.has(p))
|
||
)];
|
||
}
|
||
|
||
function showImportPreview(parsed) {
|
||
let newCount = 0, updatedCount = 0, unchangedCount = 0;
|
||
|
||
parsed.forEach(incoming => {
|
||
const existing = allSpecs.find(s =>
|
||
s.retailer?.toLowerCase() === incoming.retailer?.toLowerCase() &&
|
||
s.format?.toLowerCase() === incoming.format?.toLowerCase()
|
||
);
|
||
if (!existing) {
|
||
newCount++;
|
||
} else {
|
||
const changed = ['dimensions','maxWeight','fileTypesRaw','guidelines','deliveryDetail','namingConvention']
|
||
.some(k => (incoming[k] || '') !== (existing[k] || ''));
|
||
changed ? updatedCount++ : unchangedCount++;
|
||
}
|
||
});
|
||
|
||
document.getElementById('importCountNew').textContent = newCount;
|
||
document.getElementById('importCountUpdated').textContent = updatedCount;
|
||
document.getElementById('importCountUnchanged').textContent = unchangedCount;
|
||
document.getElementById('importPreview').classList.remove('hidden');
|
||
}
|
||
|
||
function confirmImport() {
|
||
if (!importParsed.length) return;
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const merged = [...allSpecs];
|
||
|
||
importParsed.forEach(incoming => {
|
||
const idx = merged.findIndex(s =>
|
||
s.retailer?.toLowerCase() === incoming.retailer?.toLowerCase() &&
|
||
s.format?.toLowerCase() === incoming.format?.toLowerCase()
|
||
);
|
||
if (idx === -1) {
|
||
merged.push(incoming);
|
||
} else {
|
||
// Update fields but preserve id and lastVerified
|
||
merged[idx] = { ...merged[idx], ...incoming, id: merged[idx].id, lastVerified: merged[idx].lastVerified, updatedAt: today };
|
||
}
|
||
});
|
||
|
||
allSpecs = merged;
|
||
saveSpecs(() => {
|
||
cancelImport();
|
||
renderCards();
|
||
renderAdminTable();
|
||
showToast(`Import complete — ${importParsed.length} specs processed`, 'success');
|
||
});
|
||
}
|
||
|
||
function cancelImport() {
|
||
importParsed = [];
|
||
document.getElementById('importPreview').classList.add('hidden');
|
||
setImportStatus('');
|
||
document.getElementById('importFileInput').value = '';
|
||
}
|
||
|
||
function setImportStatus(msg) {
|
||
document.getElementById('importStatus').textContent = msg;
|
||
}
|
||
|
||
// Drag-and-drop wiring (runs after DOM ready)
|
||
function initDropZone() {
|
||
const zone = document.getElementById('dropZone');
|
||
if (!zone) return;
|
||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
||
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
||
zone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
zone.classList.remove('drag-over');
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) handleImportFile(file);
|
||
});
|
||
}
|
||
|
||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||
async function saveSpecs(callback) {
|
||
const payload = {
|
||
specs: allSpecs,
|
||
meta: {
|
||
version: '1.0',
|
||
lastUpdated: new Date().toISOString().split('T')[0],
|
||
totalSpecs: allSpecs.length,
|
||
}
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/api/specs', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Admin-Token': ADMIN_PASSWORD,
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
if (callback) callback();
|
||
} catch (e) {
|
||
console.error('Save failed:', e);
|
||
// Still call callback for local UI update
|
||
if (callback) callback();
|
||
showToast('Saved locally (server unavailable)', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||
function escHtml(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function slugify(str) {
|
||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||
}
|
||
|
||
function showToast(msg, type = 'success') {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = `show ${type}`;
|
||
setTimeout(() => { t.className = ''; }, 3000);
|
||
}
|
||
|
||
// ── Permalinks ────────────────────────────────────────────────────────────────
|
||
function updateURL(specId) {
|
||
const url = new URL(window.location);
|
||
url.searchParams.set('spec', specId);
|
||
history.replaceState(null, '', url);
|
||
}
|
||
|
||
function clearURL() {
|
||
const url = new URL(window.location);
|
||
url.searchParams.delete('spec');
|
||
history.replaceState(null, '', url);
|
||
}
|
||
|
||
function copySpecLink() {
|
||
if (!currentSpec) return;
|
||
const url = new URL(window.location);
|
||
url.searchParams.set('spec', currentSpec.id);
|
||
navigator.clipboard.writeText(url.toString()).then(() => {
|
||
showToast('Link copied to clipboard', 'success');
|
||
});
|
||
}
|
||
|
||
function checkPermalink() {
|
||
const id = new URLSearchParams(window.location.search).get('spec');
|
||
if (!id) return;
|
||
const spec = allSpecs.find(s => s.id === id);
|
||
if (spec) openModal(spec);
|
||
}
|
||
|
||
// ── Recently viewed ───────────────────────────────────────────────────────────
|
||
const RECENT_KEY = 'specTool_recentlyViewed';
|
||
const RECENT_MAX = 5;
|
||
|
||
function addToRecentlyViewed(spec) {
|
||
let ids = getRecentIds();
|
||
ids = [spec.id, ...ids.filter(id => id !== spec.id)].slice(0, RECENT_MAX);
|
||
localStorage.setItem(RECENT_KEY, JSON.stringify(ids));
|
||
renderRecentlyViewed();
|
||
}
|
||
|
||
function getRecentIds() {
|
||
try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; }
|
||
catch { return []; }
|
||
}
|
||
|
||
function renderRecentlyViewed() {
|
||
const ids = getRecentIds();
|
||
const specs = ids.map(id => allSpecs.find(s => s.id === id)).filter(Boolean);
|
||
const section = document.getElementById('recentSection');
|
||
const strip = document.getElementById('recentStrip');
|
||
if (!specs.length) { section.classList.add('hidden'); return; }
|
||
section.classList.remove('hidden');
|
||
strip.innerHTML = specs.map(s => `
|
||
<div class="recent-chip" data-recent-id="${escHtml(s.id)}">
|
||
<div class="recent-chip-title">${escHtml(s.format)}</div>
|
||
<div class="recent-chip-sub">${escHtml(s.retailer)} · ${escHtml(s.country)}</div>
|
||
</div>
|
||
`).join('');
|
||
strip.querySelectorAll('.recent-chip').forEach(chip => {
|
||
const spec = specs.find(s => s.id === chip.dataset.recentId);
|
||
if (spec) chip.addEventListener('click', () => openModal(spec));
|
||
});
|
||
}
|
||
|
||
// ── Naming convention preview ─────────────────────────────────────────────────
|
||
const NAMING_SUBSTITUTIONS = {
|
||
ManufacturerName: 'LOreal',
|
||
manufacturername: 'loreal',
|
||
ProductName: 'Elvive',
|
||
productname: 'elvive',
|
||
BannerType: 'HomepageBanner',
|
||
bannertype: 'homepage-banner',
|
||
BrandName: 'LOreal',
|
||
brandname: 'loreal',
|
||
bannername: 'loreal-elvive-homepage',
|
||
Category: 'HairCare',
|
||
category: 'haircare',
|
||
Format: 'Banner',
|
||
Width: '1200',
|
||
width: '1200',
|
||
Height: '628',
|
||
height: '628',
|
||
Country: 'DE',
|
||
country: 'de',
|
||
Year: '2026',
|
||
year: '2026',
|
||
};
|
||
|
||
function buildNamingExample(convention) {
|
||
if (!convention || ['NO INFO', 'N/A', '-'].includes(convention.trim())) return null;
|
||
let example = convention;
|
||
Object.entries(NAMING_SUBSTITUTIONS).forEach(([placeholder, value]) => {
|
||
example = example.replace(new RegExp(placeholder, 'g'), value);
|
||
});
|
||
// If nothing changed the convention had no recognisable placeholders — still useful to show as-is
|
||
return example !== convention ? example : null;
|
||
}
|
||
|
||
// ── Theme ──────────────────────────────────────────────────────────────────────
|
||
function toggleTheme() {
|
||
const isLight = document.body.classList.toggle('light');
|
||
localStorage.setItem('specToolTheme', isLight ? 'light' : 'dark');
|
||
document.getElementById('iconDark').style.display = isLight ? 'none' : '';
|
||
document.getElementById('iconLight').style.display = isLight ? '' : 'none';
|
||
}
|
||
|
||
function applyStoredTheme() {
|
||
const stored = localStorage.getItem('specToolTheme');
|
||
if (stored === 'light') {
|
||
document.body.classList.add('light');
|
||
document.getElementById('iconDark').style.display = 'none';
|
||
document.getElementById('iconLight').style.display = '';
|
||
}
|
||
}
|
||
|
||
// Keyboard shortcut: Escape closes modals
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
document.getElementById('specModal').classList.add('hidden');
|
||
document.getElementById('editModal').classList.add('hidden');
|
||
clearURL();
|
||
}
|
||
});
|
||
|
||
applyStoredTheme();
|
||
init();
|