'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('')}
` : ''}
View full spec →
`}).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]) => ` `).join('')}
${label} ${escHtml(value)}

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' : ''}
${groupSpecs.map((s, i) => ` `).join('')}
Format Dimensions File Types Max Size Delivery

${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('')}

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();