'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 = ``;
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 `
${shortContentLabel(s.contentGrouping)}
${rv.status !== 'ok' ? `${rv.label}` : ''}
${s.country || '–'}
${escHtml(s.format) || '–'}
${escHtml(s.retailer) || '–'}
${s.dimensions && s.dimensions !== 'NO INFO' ? `
${escHtml(s.dimensions)}` : ''}
${s.maxWeight && s.maxWeight !== 'NO INFO' ? `
${escHtml(s.maxWeight)}` : ''}
${s.fileTypes && s.fileTypes.length ? `
${s.fileTypes.map(t => `${t}`).join('')}
` : ''}
`}).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 = `
${shortContentLabel(spec.contentGrouping)}
${spec.country || '–'}
${rv.label}
`;
// 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 => `
${s.label}
${escHtml(s.value)}
`).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 `
${f.label}
${displayVal}
`;
}).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 = `Example filename
${escHtml(namingExample)}
`;
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'
? `Verified`
: rv.status === 'due-soon'
? `Review Soon`
: `Needs Review`;
return `
L'Oréal Spec Tool
${escHtml(s.format)}
${escHtml(s.retailer)} · ${escHtml(s.country)} · ${escHtml(shortContentLabel(s.contentGrouping))}
${reviewBadge}
${fields.map(([label, value]) => `
| ${label} |
${escHtml(value)} |
`).join('')}
Generated by L'Oréal Spec Tool · ${new Date().toLocaleDateString('en-GB', { day:'2-digit', month:'long', year:'numeric' })}
`;
}
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 `
| ${escHtml(s.country)} |
${escHtml(s.retailer)} |
${shortContentLabel(s.contentGrouping)} |
${escHtml(s.format)} |
${escHtml(s.dimensions) || '–'} |
${(s.fileTypes || []).map(t => `${t}`).join('')} |
${rv.label}
Due ${rv.nextReview}
|
|
`}).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 => `
${escHtml(s.format)}
✕
`).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 => `
${escHtml(s.format)}
${escHtml(s.retailer)} · ${escHtml(s.country)}
|
`).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 `${isEmpty ? '–' : escHtml(v)} | `;
}).join('');
return `
| ${f.label} |
${cells}
`;
}).join('');
document.getElementById('compareTable').innerHTML = `
| ${headerCells}
${dataRows}
`;
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 = `
L'Oréal Spec Tool — Production Checklist
${escHtml(retailer || 'All Retailers')}
${country ? `Country: ${escHtml(country)}` : ''}
${content ? `Type: ${escHtml(content)}` : ''}
Total formats: ${specs.length}
Generated: ${today}
${Object.entries(groups).map(([groupName, groupSpecs]) => `
${escHtml(groupName)}
${groupSpecs.length} format${groupSpecs.length !== 1 ? 's' : ''}
|
Format |
Dimensions |
File Types |
Max Size |
Delivery |
${groupSpecs.map((s, i) => `
|
|
${escHtml(s.format)}
${s.guidelines && s.guidelines !== 'NO INFO' ? `${escHtml(s.guidelines.length > 120 ? s.guidelines.slice(0,120) + '…' : s.guidelines)} ` : ''}
|
${s.dimensions && s.dimensions !== 'NO INFO' ? escHtml(s.dimensions) : '–'}
|
${(s.fileTypes || []).join(', ') || (s.fileTypesRaw && s.fileTypesRaw !== 'NO INFO' ? escHtml(s.fileTypesRaw) : '–')}
|
${s.maxWeight && s.maxWeight !== 'NO INFO' ? escHtml(s.maxWeight) : '–'}
|
${s.deliveryDetail && s.deliveryDetail !== 'NO INFO' ? escHtml(s.deliveryDetail.length > 60 ? s.deliveryDetail.slice(0,60) + '…' : s.deliveryDetail) : '–'}
|
`).join('')}
`).join('')}
Generated by L'Oréal Spec Tool · ${today}
□ = Not started ✓ = Complete — = N/A
`;
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, ''');
}
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 => `
${escHtml(s.format)}
${escHtml(s.retailer)} · ${escHtml(s.country)}
`).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();