Add missing client-script.js for Brief Advisor page
The JS engine for client.html was never committed, causing the date picker and all form logic to fail on the deployed site. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
38c7c2a70a
commit
928750689c
1 changed files with 384 additions and 0 deletions
384
client-script.js
Normal file
384
client-script.js
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
// =============================================================================
|
||||
// SLA Brief Advisor — Client-Facing Form
|
||||
// Client answers practical questions → we determine the brief type
|
||||
// and calculate the submission deadline from their go-live date.
|
||||
// All business rules loaded from config.json.
|
||||
// =============================================================================
|
||||
|
||||
let CONFIG = null;
|
||||
|
||||
// ---- Bootstrap ----
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadConfig();
|
||||
initDarkMode();
|
||||
initDatePicker();
|
||||
bindEvents();
|
||||
});
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('config.json?v=2026031302');
|
||||
CONFIG = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Failed to load config.json:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Dark Mode ----
|
||||
function initDarkMode() {
|
||||
const saved = localStorage.getItem('sla-dark-mode');
|
||||
if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
updateDarkIcons();
|
||||
document.getElementById('darkToggle').addEventListener('click', () => {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark'));
|
||||
updateDarkIcons();
|
||||
});
|
||||
}
|
||||
|
||||
function updateDarkIcons() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('sunIcon').classList.toggle('hidden', !isDark);
|
||||
document.getElementById('moonIcon').classList.toggle('hidden', isDark);
|
||||
}
|
||||
|
||||
// ---- Date Picker ----
|
||||
function initDatePicker() {
|
||||
flatpickr('#golive', {
|
||||
dateFormat: 'Y-m-d',
|
||||
altInput: true,
|
||||
altFormat: 'd M Y',
|
||||
disableMobile: true,
|
||||
minDate: 'today',
|
||||
disable: [function(date) { return date.getDay() === 0 || date.getDay() === 6; }],
|
||||
onChange: () => validateForm()
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Event Bindings ----
|
||||
function bindEvents() {
|
||||
document.getElementById('contentType').addEventListener('change', validateForm);
|
||||
document.getElementById('golive').addEventListener('change', validateForm);
|
||||
document.getElementById('clientForm').addEventListener('submit', onSubmit);
|
||||
document.getElementById('resetBtn').addEventListener('click', resetForm);
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const contentType = document.getElementById('contentType').value;
|
||||
const golive = document.getElementById('golive').value;
|
||||
document.getElementById('calcBtn').disabled = !(contentType && golive);
|
||||
}
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
calculateAndRender();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('contentType').value = '';
|
||||
document.getElementById('assetVolume').value = '0_30';
|
||||
document.getElementById('needsVideo').checked = false;
|
||||
document.getElementById('needsHTML').checked = false;
|
||||
document.getElementById('needsTranslation').checked = false;
|
||||
document.getElementById('needsSyndication').checked = false;
|
||||
const goliveEl = document.getElementById('golive');
|
||||
if (goliveEl._flatpickr) goliveEl._flatpickr.clear();
|
||||
document.getElementById('calcBtn').disabled = true;
|
||||
document.getElementById('results').classList.add('hidden');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// ---- Determine Brief Type from Form Answers ----
|
||||
// Maps practical client needs → config brief type ID
|
||||
function determineBriefType() {
|
||||
const contentType = document.getElementById('contentType').value; // pdp | eventing
|
||||
const needsVideo = document.getElementById('needsVideo').checked;
|
||||
const needsHTML = document.getElementById('needsHTML').checked;
|
||||
const needsTranslation = document.getElementById('needsTranslation').checked;
|
||||
const needsSyndication = document.getElementById('needsSyndication').checked;
|
||||
|
||||
const needsProduction = needsVideo || needsHTML;
|
||||
|
||||
// Syndication only — no production, no translation
|
||||
if (needsSyndication && !needsProduction && !needsTranslation) {
|
||||
return 'country_retailer_request';
|
||||
}
|
||||
|
||||
// Has production needs
|
||||
if (needsProduction) {
|
||||
// Video/motion → Adaptation (master exists, motion content)
|
||||
return 'country_pull_adaptation';
|
||||
}
|
||||
|
||||
// Translation only or simple resize/crop
|
||||
if (needsTranslation) {
|
||||
return 'country_pull_simple';
|
||||
}
|
||||
|
||||
// Syndication + translation but no production
|
||||
if (needsSyndication) {
|
||||
if (contentType === 'pdp') return 'local_push_pdp';
|
||||
return 'local_push_eventing';
|
||||
}
|
||||
|
||||
// Fallback: simple pull
|
||||
return 'country_pull_simple';
|
||||
}
|
||||
|
||||
// ---- Business Day Arithmetic ----
|
||||
function addBusinessDays(startDate, days) {
|
||||
const result = new Date(startDate);
|
||||
let added = 0;
|
||||
while (added < days) {
|
||||
result.setDate(result.getDate() + 1);
|
||||
if (result.getDay() !== 0 && result.getDay() !== 6) added++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function subtractBusinessDays(endDate, days) {
|
||||
const result = new Date(endDate);
|
||||
let removed = 0;
|
||||
while (removed < days) {
|
||||
result.setDate(result.getDate() - 1);
|
||||
if (result.getDay() !== 0 && result.getDay() !== 6) removed++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function businessDaysBetween(start, end) {
|
||||
let count = 0;
|
||||
const d = new Date(start);
|
||||
while (d < end) {
|
||||
d.setDate(d.getDate() + 1);
|
||||
if (d.getDay() !== 0 && d.getDay() !== 6) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '--';
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return date.getDate() + ' ' + months[date.getMonth()] + ' ' + date.getFullYear();
|
||||
}
|
||||
|
||||
// ---- Calculation Engine ----
|
||||
function calculateAndRender() {
|
||||
const briefId = determineBriefType();
|
||||
const assetVolume = document.getElementById('assetVolume').value;
|
||||
const goliveStr = document.getElementById('golive').value;
|
||||
const needsTranslation = document.getElementById('needsTranslation').checked;
|
||||
const needsSyndication = document.getElementById('needsSyndication').checked;
|
||||
|
||||
if (!briefId || !goliveStr) return;
|
||||
|
||||
const goLive = new Date(goliveStr + 'T00:00:00');
|
||||
const cfg = CONFIG.stageConfigs;
|
||||
const handoverDays = CONFIG.meta.handoverDays;
|
||||
|
||||
// Get brief type info
|
||||
const bt = CONFIG.briefTypes.find(b => b.id === briefId);
|
||||
|
||||
// Start from the stage matrix, then overlay client toggles
|
||||
const matrix = [...CONFIG.stageMatrix[briefId]];
|
||||
|
||||
// Force always-active stages (Opera Upload)
|
||||
CONFIG.stages.forEach((stage, i) => {
|
||||
const sCfg = cfg['stage' + stage.number];
|
||||
if (sCfg && sCfg.alwaysActive) matrix[i] = true;
|
||||
});
|
||||
|
||||
// Client toggles can activate translation/syndication even if the base
|
||||
// brief type doesn't include them
|
||||
if (needsTranslation) {
|
||||
matrix[3] = true; // Translation (Salsify PDP)
|
||||
matrix[4] = true; // Translation (Asset)
|
||||
}
|
||||
if (needsSyndication && !matrix[7]) {
|
||||
matrix[7] = true; // Syndication
|
||||
}
|
||||
|
||||
// Calculate total project days using a temp start date
|
||||
const tempStart = new Date('2026-01-05T00:00:00'); // Arbitrary Monday
|
||||
const stages = [];
|
||||
let currentDate = new Date(tempStart);
|
||||
|
||||
// Stage 1: DMI Asset Creation
|
||||
const s1 = calcStage(0, matrix[0], currentDate, () => {
|
||||
const opt = cfg.stage1.fields.complexity.options.find(o => o.value === cfg.stage1.fields.complexity.default);
|
||||
return { handover: handoverDays, wip: opt ? opt.days : 0, feedback: cfg.stage1.fields.marketApprovalDays.default, revisions: cfg.stage1.fields.revisionRounds.default * cfg.stage1.fields.revisionDays.default };
|
||||
});
|
||||
stages.push(s1);
|
||||
currentDate = s1.end;
|
||||
|
||||
// Stage 2: Mastering / Copy
|
||||
const s2 = calcStage(1, matrix[1], currentDate, () => {
|
||||
const masterOpt = cfg.stage2.fields.masteringComplexity.options.find(o => o.value === cfg.stage2.fields.masteringComplexity.default);
|
||||
const copyOpt = cfg.stage2.fields.copyComplexity.options.find(o => o.value === cfg.stage2.fields.copyComplexity.default);
|
||||
return { handover: handoverDays, wip: (masterOpt ? masterOpt.days : 0) + (copyOpt ? copyOpt.days : 0), feedback: 0, revisions: 0 };
|
||||
});
|
||||
stages.push(s2);
|
||||
currentDate = s2.end;
|
||||
|
||||
// Stage 3: Global Rollout
|
||||
const s3 = calcStage(2, matrix[2], currentDate, () => {
|
||||
return { handover: handoverDays, wip: cfg.stage3.fields.rolloutDays.default, feedback: 0, revisions: 0 };
|
||||
});
|
||||
stages.push(s3);
|
||||
currentDate = s3.end;
|
||||
|
||||
// Stages 4 & 5: Translation (parallel)
|
||||
const translationActive = matrix[3] || matrix[4];
|
||||
const s45 = calcStage(3, translationActive, currentDate, () => {
|
||||
const wcOpt = cfg.stage4_5.fields.wordCount.options.find(o => o.value === cfg.stage4_5.fields.wordCount.default);
|
||||
return { handover: handoverDays, wip: wcOpt ? wcOpt.days : 0, feedback: cfg.stage4_5.fields.marketApprovalDays.default, revisions: cfg.stage4_5.fields.revisionRounds.default * cfg.stage4_5.fields.revisionDays.default };
|
||||
});
|
||||
stages.push({ ...s45, label: 'Translation' });
|
||||
currentDate = s45.end;
|
||||
|
||||
// Stage 6: Production
|
||||
const s6 = calcStage(5, matrix[5], currentDate, () => {
|
||||
const complexity = cfg.stage6.fields.complexity.default;
|
||||
const key = complexity + '_' + assetVolume;
|
||||
const table = cfg.stage6.fields.crossReferenceTable;
|
||||
return { handover: handoverDays, wip: table[key] || 0, feedback: cfg.stage6.fields.marketApprovalDays.default, revisions: cfg.stage6.fields.revisionRounds.default * cfg.stage6.fields.revisionDays.default };
|
||||
});
|
||||
stages.push(s6);
|
||||
currentDate = s6.end;
|
||||
|
||||
// Stage 7: Opera Upload
|
||||
const s7 = calcStage(6, matrix[6], currentDate, () => {
|
||||
const daysMap = cfg.stage7.daysByAssetVolume;
|
||||
return { handover: handoverDays, wip: daysMap[assetVolume] || cfg.stage7.defaultDays, feedback: 0, revisions: 0 };
|
||||
});
|
||||
stages.push(s7);
|
||||
currentDate = s7.end;
|
||||
|
||||
// Stage 8: Syndication
|
||||
const s8 = calcStage(7, matrix[7], currentDate, () => {
|
||||
const complexity = cfg.stage8.fields.complexity.default;
|
||||
const eanVolume = cfg.stage8.fields.eanVolume.default;
|
||||
const key = complexity + '_' + eanVolume;
|
||||
const table = cfg.stage8.fields.crossReferenceTable;
|
||||
return { handover: handoverDays, wip: table[key] || 0, feedback: 0, revisions: 0 };
|
||||
});
|
||||
stages.push(s8);
|
||||
|
||||
// Total business days
|
||||
const totalBizDays = businessDaysBetween(tempStart, s8.end);
|
||||
|
||||
// Add syndication buffer if applicable
|
||||
let totalWithBuffer = totalBizDays;
|
||||
if (matrix[7]) {
|
||||
totalWithBuffer += CONFIG.meta.syndicationBufferDays;
|
||||
}
|
||||
|
||||
// Work backwards from go-live
|
||||
const submitBy = subtractBusinessDays(goLive, totalWithBuffer);
|
||||
const estimatedCompletion = addBusinessDays(submitBy, totalBizDays);
|
||||
|
||||
renderResults({
|
||||
briefType: bt,
|
||||
stages: stages.filter(s => s.active),
|
||||
goLive,
|
||||
submitBy,
|
||||
estimatedCompletion,
|
||||
totalBizDays,
|
||||
totalWithBuffer,
|
||||
syndicationActive: matrix[7]
|
||||
});
|
||||
}
|
||||
|
||||
function calcStage(stageIndex, isActive, prevDate, getParams) {
|
||||
const stage = CONFIG.stages[stageIndex];
|
||||
if (!isActive) {
|
||||
return { index: stageIndex, label: stage ? stage.label : '', active: false, days: 0, end: new Date(prevDate) };
|
||||
}
|
||||
const p = getParams();
|
||||
const totalDays = p.handover + p.wip + p.feedback + p.revisions;
|
||||
return { index: stageIndex, label: stage ? stage.label : '', active: true, days: totalDays, end: addBusinessDays(prevDate, totalDays) };
|
||||
}
|
||||
|
||||
// ---- Render Results ----
|
||||
function renderResults(data) {
|
||||
const resultsEl = document.getElementById('results');
|
||||
resultsEl.classList.remove('hidden');
|
||||
|
||||
// Brief type
|
||||
document.getElementById('resultBriefType').textContent = data.briefType.label;
|
||||
const desc = Array.isArray(data.briefType.description) ? data.briefType.description.join(' · ') : data.briefType.description;
|
||||
document.getElementById('resultBriefDesc').textContent = desc;
|
||||
|
||||
// Deadline banner
|
||||
const banner = document.getElementById('deadlineBanner');
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const submitDate = new Date(data.submitBy); submitDate.setHours(0, 0, 0, 0);
|
||||
const isPast = submitDate < today;
|
||||
|
||||
if (isPast) {
|
||||
banner.className = 'rounded-xl p-5 mb-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-14 h-14 rounded-full bg-red-500 dark:bg-red-600 flex items-center justify-center flex-shrink-0 shadow">
|
||||
<svg class="w-8 h-8" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-red-800 dark:text-red-300 text-lg">The submission deadline has already passed</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 mt-0.5">To go live on <strong>${formatDate(data.goLive)}</strong>, the brief needed to be submitted by <strong>${formatDate(data.submitBy)}</strong>.</p>
|
||||
<p class="text-xs text-red-500 dark:text-red-400 mt-1">Please discuss an adjusted timeline with your project manager.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
banner.className = 'rounded-xl p-5 mb-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800';
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-14 h-14 rounded-full bg-green-500 dark:bg-green-600 flex items-center justify-center flex-shrink-0 shadow">
|
||||
<svg class="w-8 h-8" fill="none" stroke="white" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-green-800 dark:text-green-300 text-lg">Submit your brief by ${formatDate(data.submitBy)}</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400 mt-0.5">This gives us <strong>${data.totalBizDays} business days</strong> to deliver before your go-live on <strong>${formatDate(data.goLive)}</strong>.</p>
|
||||
${data.syndicationActive ? `<p class="text-xs text-green-500 dark:text-green-400 mt-1">Includes ${CONFIG.meta.syndicationBufferDays}-day syndication buffer for retailer requirements.</p>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Timeline summary
|
||||
const summary = document.getElementById('timelineSummary');
|
||||
summary.innerHTML = '<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">What we need to do</h3>';
|
||||
|
||||
data.stages.forEach(s => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between py-2.5 px-3 rounded-lg bg-gray-50 dark:bg-gray-700/40 text-sm';
|
||||
row.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-brand-500"></span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">${s.label}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">${s.days} business day${s.days !== 1 ? 's' : ''}</span>
|
||||
`;
|
||||
summary.appendChild(row);
|
||||
});
|
||||
|
||||
if (data.syndicationActive) {
|
||||
const bufferRow = document.createElement('div');
|
||||
bufferRow.className = 'flex items-center justify-between py-2.5 px-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-sm';
|
||||
bufferRow.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-amber-500"></span>
|
||||
<span class="font-medium text-amber-700 dark:text-amber-300">Syndication Buffer</span>
|
||||
</div>
|
||||
<span class="text-amber-600 dark:text-amber-400 text-xs">${CONFIG.meta.syndicationBufferDays} business days</span>
|
||||
`;
|
||||
summary.appendChild(bufferRow);
|
||||
}
|
||||
|
||||
// Summary cards
|
||||
document.getElementById('summarySubmitBy').textContent = formatDate(data.submitBy);
|
||||
document.getElementById('summaryCompletion').textContent = formatDate(data.estimatedCompletion);
|
||||
document.getElementById('summaryDays').textContent = data.totalWithBuffer;
|
||||
|
||||
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue