html-report-template.js
1 /** 2 * HTML Report Template Generator 3 * 4 * Generates a self-contained HTML document for premium CRO audit reports. 5 * All CSS is inline, all images are base64 data URIs, all icons are inline SVG. 6 * Designed for Playwright page.pdf() rendering to A4. 7 * 8 * Brand: Audit&Fix (navy #1a365d / orange #e05d26) 9 * See docs/09-business/auditandfix-brand.md 10 */ 11 12 // Audit&Fix logo SVG (from brand website /assets/img/logo.svg) 13 const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 48" width="210" height="48" role="img" aria-label="Audit&Fix"> 14 <circle cx="18" cy="18" r="11" fill="none" stroke="#e05d26" stroke-width="3.5"/> 15 <line x1="26" y1="26" x2="34" y2="34" stroke="#e05d26" stroke-width="3.5" stroke-linecap="round"/> 16 <polyline points="12,18 16,22 24,13" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> 17 <text y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5"> 18 <tspan x="44" fill="#ffffff">Audit</tspan><tspan fill="#e05d26">&</tspan><tspan fill="#ffffff">Fix</tspan> 19 </text> 20 </svg>`; 21 22 // Logo variant for light backgrounds 23 const LOGO_LIGHT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 48" width="210" height="48" role="img" aria-label="Audit&Fix"> 24 <circle cx="18" cy="18" r="11" fill="none" stroke="#e05d26" stroke-width="3.5"/> 25 <line x1="26" y1="26" x2="34" y2="34" stroke="#e05d26" stroke-width="3.5" stroke-linecap="round"/> 26 <polyline points="12,18 16,22 24,13" fill="none" stroke="#1a365d" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> 27 <text y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5"> 28 <tspan x="44" fill="#1a365d">Audit</tspan><tspan fill="#e05d26">&</tspan><tspan fill="#1a365d">Fix</tspan> 29 </text> 30 </svg>`; 31 32 // Small logo variant for page headers 33 const LOGO_LIGHT_SVG_SMALL = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 48" width="250" height="58" role="img" aria-label="Audit&Fix"> 34 <circle cx="18" cy="18" r="11" fill="none" stroke="#e05d26" stroke-width="3.5"/> 35 <line x1="26" y1="26" x2="34" y2="34" stroke="#e05d26" stroke-width="3.5" stroke-linecap="round"/> 36 <polyline points="12,18 16,22 24,13" fill="none" stroke="#1a365d" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> 37 <text y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5"> 38 <tspan x="44" fill="#1a365d">Audit</tspan><tspan fill="#e05d26">&</tspan><tspan fill="#1a365d">Fix</tspan> 39 </text> 40 </svg>`; 41 42 // SVG icons for each scoring factor 43 const FACTOR_ICONS = { 44 headline_quality: 45 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="8" x2="14" y2="8"/><line x1="8" y1="12" x2="12" y2="12"/></svg>', 46 value_proposition: 47 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>', 48 unique_selling_proposition: 49 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 9 7 12 7s5-3 7.5-3a2.5 2.5 0 0 1 0 5H18"/><path d="M12 7v14"/><path d="M8 21h8"/></svg>', 50 call_to_action: 51 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 0 0 0 12h3"/></svg>', 52 urgency_messaging: 53 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', 54 hook_engagement: 55 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>', 56 trust_signals: 57 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>', 58 imagery_design: 59 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>', 60 offer_clarity: 61 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>', 62 contextual_appropriateness: 63 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>', 64 }; 65 66 const FACTOR_LABELS = { 67 headline_quality: 'Headline Quality', 68 value_proposition: 'Value Proposition', 69 unique_selling_proposition: 'What Makes You Different (USP)', 70 call_to_action: 'Call to Action Button (CTA)', 71 urgency_messaging: 'Urgency & Scarcity', 72 hook_engagement: 'Hook & Engagement', 73 trust_signals: 'Trust & Credibility', 74 imagery_design: 'Imagery & Design', 75 offer_clarity: 'Offer Clarity', 76 contextual_appropriateness: 'Industry Context', 77 }; 78 79 const FACTOR_WEIGHTS = { 80 headline_quality: 15, 81 value_proposition: 14, 82 unique_selling_proposition: 13, 83 call_to_action: 13, 84 urgency_messaging: 10, 85 hook_engagement: 9, 86 trust_signals: 11, 87 imagery_design: 8, 88 offer_clarity: 4, 89 contextual_appropriateness: 3, 90 }; 91 92 const TECH_EXPLANATIONS = { 93 HSTS: "Without this, browsers won't enforce secure connections — leaving visitors open to interception.", 94 CSP: 'Content Security Policy blocks attackers from injecting malicious code into your pages.', 95 'X-Frame-Options': 96 'Prevents your site being embedded inside another page — a trick used to hijack clicks.', 97 'X-Content-Type-Options': 98 'Stops browsers misinterpreting uploaded files, reducing a class of injection attacks.', 99 'Referrer-Policy': 100 'Controls what information is shared with other sites when a visitor follows a link.', 101 'Permissions-Policy': 102 'Limits whether your site can access camera, location, or microphone — reducing exposure.', 103 }; 104 const HTTPS_FAIL_EXPLANATION = 105 'Your site is not using a secure (HTTPS) connection. Visitors see a browser warning, which damages trust and reduces conversions.'; 106 const MOBILE_FAIL_EXPLANATION = 107 "Your site doesn't adapt to mobile screens. Most visitors are on phones — a poor mobile experience directly reduces enquiries."; 108 109 function getGradeColor(grade) { 110 if (!grade) return '#718096'; 111 const letter = grade.charAt(0).toUpperCase(); 112 switch (letter) { 113 case 'A': 114 return '#38a169'; 115 case 'B': 116 return '#3182ce'; 117 case 'C': 118 return '#d69e2e'; 119 case 'D': 120 return '#dd6b20'; 121 default: 122 return '#e53e3e'; 123 } 124 } 125 126 function getScoreColor(score) { 127 if (score >= 90) return '#38a169'; 128 if (score >= 80) return '#3182ce'; 129 if (score >= 70) return '#d69e2e'; 130 if (score >= 60) return '#dd6b20'; 131 return '#e53e3e'; 132 } 133 134 function getSeverityColor(severity) { 135 switch (severity) { 136 case 'high': 137 return '#e53e3e'; 138 case 'medium': 139 return '#d69e2e'; 140 case 'low': 141 return '#3182ce'; 142 default: 143 return '#718096'; 144 } 145 } 146 147 function escapeHtml(str) { 148 if (!str) return ''; 149 return String(str) 150 .replace(/&/g, '&') 151 .replace(/</g, '<') 152 .replace(/>/g, '>') 153 .replace(/"/g, '"'); 154 } 155 156 /** 157 * Generate SVG score ring 158 */ 159 function scoreRing(score, size = 120, strokeWidth = 8) { 160 const radius = (size - strokeWidth) / 2; 161 const circumference = 2 * Math.PI * radius; 162 const offset = circumference - (score / 100) * circumference; 163 const color = getScoreColor(score); 164 const cx = size / 2; 165 const cy = size / 2; 166 167 return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> 168 <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="#e2e8f0" stroke-width="${strokeWidth}"/> 169 <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" 170 stroke-dasharray="${circumference}" stroke-dashoffset="${offset}" 171 stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})" 172 style="transition: stroke-dashoffset 0.5s ease;"/> 173 <text x="${cx}" y="${cy - 6}" text-anchor="middle" font-size="${size * 0.28}" font-weight="700" fill="${color}">${Math.round(score)}</text> 174 <text x="${cx}" y="${cy + 14}" text-anchor="middle" font-size="${size * 0.12}" fill="#718096">out of 100</text> 175 </svg>`; 176 } 177 178 /** 179 * Generate small factor score ring 180 */ 181 function factorRing(score, size = 48) { 182 const strokeWidth = 4; 183 const radius = (size - strokeWidth) / 2; 184 const circumference = 2 * Math.PI * radius; 185 const offset = circumference - (score / 10) * circumference; 186 const color = getScoreColor(score * 10); 187 const cx = size / 2; 188 const cy = size / 2; 189 190 return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> 191 <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="#e2e8f0" stroke-width="${strokeWidth}"/> 192 <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" 193 stroke-dasharray="${circumference}" stroke-dashoffset="${offset}" 194 stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/> 195 <text x="${cx}" y="${cy + 5}" text-anchor="middle" font-size="14" font-weight="700" fill="${color}">${score}</text> 196 </svg>`; 197 } 198 199 /** 200 * Render the per-page header with section name and small logo 201 */ 202 function renderPageHeader(sectionName) { 203 return `<div class="page-header"> 204 <div class="page-header-section">${escapeHtml(sectionName)}</div> 205 <div>${LOGO_LIGHT_SVG_SMALL}</div> 206 </div>`; 207 } 208 209 /** 210 * Render numbered action items with large coloured circles, or plain paragraphs 211 */ 212 function renderActionItems(text, color) { 213 if (!text) return ''; 214 const lines = text 215 .split('\n') 216 .map(l => l.trim()) 217 .filter(Boolean); 218 return lines 219 .map(line => { 220 const m = line.match(/^(\d+)\.\s*(.*)/); 221 if (!m) return `<p class="action-plain">${escapeHtml(line)}</p>`; 222 return `<div class="action-item"> 223 <div class="action-num" style="background:${color};">${m[1]}</div> 224 <div class="action-text">${escapeHtml(m[2])}</div> 225 </div>`; 226 }) 227 .join(''); 228 } 229 230 /** 231 * Generate the full HTML report 232 * @param {Object} params 233 * @param {string} params.domain - Website domain 234 * @param {string} params.url - Full URL 235 * @param {Object} params.scoreJson - Full score JSON from Opus 236 * @param {Buffer|string} params.aboveFoldBuffer - Above-fold screenshot (Buffer or base64 string) 237 * @param {Array} params.problemCrops - Array of { factor, imageBuffer, description, recommendation, severity, current_text, suggested_text } 238 * @param {Object} [params.narrativeSections] - Opus-generated { executiveSummary, actionPlanWeek, actionPlanMonth, actionPlanQuarter, factorNarratives } 239 * @param {Date} [params.reportDate] - Report date 240 * @param {boolean} [params.isSample] - Whether this is a sample/demo report 241 * @param {boolean} [params.visionUsed] - Whether computer vision was used in scoring 242 * @returns {string} Complete HTML document 243 */ 244 export function generateReportHTML({ 245 domain, 246 url: _url, 247 scoreJson, 248 aboveFoldBuffer, 249 problemCrops, 250 narrativeSections, 251 reportDate, 252 isSample = false, 253 visionUsed = false, 254 variant = 'full', 255 }) { 256 const isQuickFixes = variant === 'quick-fixes'; 257 const score = scoreJson.overall_calculation?.conversion_score || 0; 258 const grade = scoreJson.overall_calculation?.letter_grade || 'N/A'; 259 const gradeColor = getGradeColor(grade); 260 const factors = scoreJson.factor_scores || {}; 261 const strengths = scoreJson.key_strengths || []; 262 const weaknesses = scoreJson.critical_weaknesses || []; 263 const quickWins = scoreJson.quick_improvement_opportunities || []; 264 const techAssess = scoreJson.technical_assessment || {}; 265 const recommendations = scoreJson.strategic_recommendations || []; 266 const narratives = narrativeSections || scoreJson.report_narratives || {}; 267 const dateStr = (reportDate || new Date()).toLocaleDateString('en-GB', { 268 year: 'numeric', 269 month: 'long', 270 day: 'numeric', 271 }); 272 273 // Pagination counter 274 let _pg = 0; 275 const P = () => ++_pg; 276 const footer = (dom, extra = '') => 277 `<div class="page-footer"><span>Confidential — Prepared by Audit&Fix for ${escapeHtml(dom)}</span><span>${extra ? `${extra} · ` : ''}Page ${_pg}</span></div>`; 278 279 // Convert above-fold buffer to base64 if needed 280 let aboveFoldBase64 = ''; 281 if (aboveFoldBuffer) { 282 if (Buffer.isBuffer(aboveFoldBuffer)) { 283 aboveFoldBase64 = `data:image/jpeg;base64,${aboveFoldBuffer.toString('base64')}`; 284 } else if (typeof aboveFoldBuffer === 'string') { 285 aboveFoldBase64 = aboveFoldBuffer.startsWith('data:') 286 ? aboveFoldBuffer 287 : `data:image/jpeg;base64,${aboveFoldBuffer}`; 288 } 289 } 290 291 const watermarkCSS = isSample 292 ? `.watermark{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-35deg);font-size:100px;font-weight:800;color:#e2e8f0;opacity:0.08;z-index:999;pointer-events:none;white-space:nowrap;letter-spacing:8px;}` 293 : ''; 294 const watermarkHTML = isSample ? '<div class="watermark">SAMPLE REPORT</div>' : ''; 295 296 return `<!DOCTYPE html> 297 <html lang="en"> 298 <head> 299 <meta charset="utf-8"> 300 <title>${isQuickFixes ? 'Quick Fixes Report' : 'CRO Audit Report'} — ${escapeHtml(domain)}</title> 301 <style> 302 @page { size: A4 portrait; margin: 12mm 15mm 12mm; } 303 * { box-sizing: border-box; margin: 0; padding: 0; } 304 html, body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; color: #2d3748; font-size: 13px; line-height: 1.5; } 305 306 .page { width: 100%; min-height: calc(297mm - 24mm); padding-bottom: 22px; page-break-after: always; position: relative; } 307 .page:last-child { page-break-after: auto; } 308 .page-footer { position: absolute; bottom: 4mm; left: 0; right: 0; font-size: 9px; color: #a0aec0; display: flex; justify-content: space-between; border-top: 1px solid #e2e8f0; padding-top: 4px; } 309 310 /* Cover page */ 311 .cover { background: linear-gradient(160deg, #1a365d 0%, #0f2440 100%); color: #fff; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 30mm 25mm; } 312 .cover-logo { margin-bottom: 40px; } 313 .cover-title { font-size: 32px; font-weight: 800; margin-bottom: 8px; letter-spacing: -0.5px; } 314 .cover-subtitle { font-size: 16px; font-weight: 300; color: #a0aec0; margin-bottom: 4px; } 315 .cover-domain { font-size: 18px; color: #e2e8f0; margin-top: 16px; margin-bottom: 6px; } 316 .cover-date { font-size: 12px; color: #718096; margin-bottom: 40px; } 317 .cover-accent { width: 80px; height: 4px; background: #e05d26; border-radius: 2px; margin: 20px auto; } 318 .cover-score { margin-top: 20px; } 319 .cover-grade { display: inline-block; padding: 6px 20px; border-radius: 20px; font-size: 16px; font-weight: 700; margin-top: 12px; } 320 .cover-footer { position: absolute; bottom: 20mm; left: 0; right: 0; text-align: center; font-size: 10px; color: #718096; } 321 322 /* Section headers */ 323 .section-title { font-size: 24px; font-weight: 700; color: #1a365d; margin-bottom: 4px; } 324 .section-accent { width: 60px; height: 3px; background: #e05d26; border-radius: 2px; margin-bottom: 16px; } 325 326 /* Cards */ 327 .card { background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); padding: 14px 16px; margin-bottom: 10px; page-break-inside: avoid; } 328 .card-green { border-left: 4px solid #38a169; } 329 .card-red { border-left: 4px solid #e53e3e; } 330 .card-orange { border-left: 4px solid #e05d26; } 331 .card-navy { border-left: 4px solid #1a365d; } 332 .card-gray { border-left: 4px solid #a0aec0; } 333 334 /* Score box */ 335 .score-box { background: #f7fafc; border-radius: 8px; padding: 16px 20px; display: flex; align-items: center; gap: 20px; margin-bottom: 16px; } 336 .score-details { flex: 1; } 337 .score-bar-track { height: 12px; background: #e2e8f0; border-radius: 6px; overflow: hidden; margin-top: 6px; } 338 .score-bar-fill { height: 100%; border-radius: 6px; } 339 340 /* Factor grid */ 341 .factor-card { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; } 342 .factor-icon { width: 28px; height: 28px; color: #1a365d; flex-shrink: 0; margin-top: 2px; } 343 .factor-body { flex: 1; min-width: 0; } 344 .factor-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } 345 .factor-name { font-size: 14px; font-weight: 700; color: #1a365d; } 346 .factor-weight { font-size: 9px; color: #a0aec0; background: #f7fafc; padding: 1px 6px; border-radius: 10px; } 347 .factor-reasoning { font-size: 12px; color: #4a5568; margin-bottom: 3px; } 348 .factor-evidence { font-size: 11px; color: #718096; font-style: italic; background: #f7fafc; padding: 4px 8px; border-radius: 4px; margin-top: 3px; } 349 .factor-fix { font-size: 10px; color: #2d3748; margin-top: 4px; padding: 6px 8px; background: #f0fff4; border-left: 3px solid #38a169; border-radius: 0 4px 4px 0; } 350 .factor-fix-label { font-weight: 700; color: #38a169; } 351 .factor-copy-change { margin-top: 3px; font-size: 9.5px; } 352 .factor-copy-change .old { text-decoration: line-through; color: #e53e3e; } 353 .factor-copy-change .new { color: #38a169; font-weight: 600; } 354 355 /* Problem areas */ 356 .problem-card { margin-bottom: 14px; page-break-inside: avoid; } 357 .severity-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 9px; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 0.5px; } 358 .problem-img { width: 100%; max-height: 200px; object-fit: cover; border-radius: 6px; margin: 8px 0; border: 1px solid #e2e8f0; } 359 .problem-desc { font-size: 10px; color: #4a5568; margin-bottom: 6px; } 360 .problem-fix { font-size: 10px; padding: 8px 10px; background: #f0fff4; border-left: 3px solid #38a169; border-radius: 0 4px 4px 0; } 361 362 /* Tech assessment */ 363 .tech-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } 364 .tech-item { display: flex; align-items: center; gap: 6px; font-size: 10px; padding: 4px 0; } 365 .tech-check { color: #38a169; font-weight: 700; font-size: 14px; } 366 .tech-cross { color: #e53e3e; font-weight: 700; font-size: 14px; } 367 368 /* Recommendations */ 369 .rec-card { display: flex; gap: 12px; align-items: flex-start; padding: 10px 14px; } 370 .rec-priority { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; color: #fff; flex-shrink: 0; } 371 .rec-body { flex: 1; } 372 .rec-title { font-size: 13px; font-weight: 700; color: #1a365d; margin-bottom: 2px; } 373 .rec-desc { font-size: 12px; color: #4a5568; } 374 .rec-meta { display: flex; gap: 8px; margin-top: 4px; } 375 .rec-pill { font-size: 8px; padding: 1px 8px; border-radius: 10px; background: #f7fafc; color: #718096; } 376 .rec-pill-impact { background: #fed7d7; color: #c53030; } 377 .rec-pill-effort { background: #bee3f8; color: #2b6cb0; } 378 379 /* Action plan */ 380 .action-section { margin-bottom: 14px; } 381 .action-label { font-size: 13px; font-weight: 700; color: #1a365d; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 2px solid #e2e8f0; } 382 383 /* Narrative text */ 384 .narrative { font-size: 13px; color: #2d3748; line-height: 1.65; margin-bottom: 12px; white-space: pre-line; } 385 386 /* Strengths / weaknesses */ 387 .finding-item { display: flex; gap: 6px; margin-bottom: 4px; font-size: 10px; } 388 .finding-icon { flex-shrink: 0; font-size: 12px; } 389 390 /* Grade scale */ 391 .grade-table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 8px; } 392 .grade-table th, .grade-table td { padding: 3px 8px; text-align: left; border-bottom: 1px solid #e2e8f0; } 393 .grade-table th { background: #f7fafc; font-weight: 600; color: #1a365d; } 394 .grade-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } 395 396 /* About page */ 397 .about-logo { margin-bottom: 12px; } 398 .about-text { font-size: 12px; color: #4a5568; line-height: 1.6; margin-bottom: 10px; } 399 .cta-box { background: linear-gradient(135deg, #1a365d, #2a4a7f); color: #fff; border-radius: 8px; padding: 16px 20px; text-align: center; margin-top: 16px; } 400 .cta-box h3 { font-size: 14px; margin-bottom: 4px; } 401 .cta-box p { font-size: 10px; color: #a0aec0; } 402 403 /* Page header with logo */ 404 .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; } 405 .page-header-section { font-size: 11px; color: #a0aec0; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; } 406 407 /* Action plan with large numbered circles */ 408 .action-item { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; } 409 .action-num { width: 42px; height: 42px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; color: #fff; flex-shrink: 0; margin-top: 2px; } 410 .action-text { font-size: 13px; color: #2d3748; line-height: 1.6; padding-top: 10px; } 411 .action-plain { font-size: 12px; color: #4a5568; margin-bottom: 6px; } 412 413 /* Glossary box */ 414 .glossary-box { background: #f7fafc; border-left: 3px solid #a0aec0; padding: 8px 12px; font-size: 11px; color: #4a5568; border-radius: 0 4px 4px 0; margin-bottom: 14px; } 415 416 /* Tech explanation for failures */ 417 .tech-explanation { font-size: 11px; color: #718096; margin-left: 22px; margin-top: 2px; margin-bottom: 4px; font-style: italic; } 418 419 /* Problem area annotation */ 420 .problem-annotation { font-size: 11px; color: #e05d26; font-weight: 600; text-align: center; margin: -4px 0 8px; } 421 422 /* CRO intro sentence */ 423 .report-intro { font-size: 12px; color: #718096; font-style: italic; margin-bottom: 10px; } 424 425 ${watermarkCSS} 426 </style> 427 </head> 428 <body> 429 ${watermarkHTML} 430 431 <!-- PAGE 1: COVER --> 432 <div class="page cover" id="page-cover">${(() => { 433 P(); 434 return ''; 435 })()} 436 <div class="cover-logo">${LOGO_SVG}</div> 437 ${isQuickFixes 438 ? '<div class="cover-title">Quick Fixes</div><div class="cover-title">Report</div>' 439 : '<div class="cover-title">Website CRO</div><div class="cover-title">Audit Report</div>'} 440 441 <div class="cover-accent"></div> 442 <div class="cover-domain">${escapeHtml(domain)}</div> 443 <div class="cover-date">${escapeHtml(dateStr)}</div> 444 <div class="cover-score">${scoreRing(score, 140, 10)}</div> 445 <div class="cover-grade" style="background:${gradeColor};color:#fff;">Grade: ${escapeHtml(grade)}</div> 446 <div class="cover-footer">Confidential — Prepared by Audit&Fix</div> 447 </div> 448 449 <!-- PAGE 2: EXECUTIVE SUMMARY --> 450 <div class="page" id="page-summary">${(P(), '')} 451 ${renderPageHeader('Executive Summary')} 452 <h1 class="section-title">Executive Summary</h1> 453 <div class="section-accent"></div> 454 455 <p class="report-intro">This report evaluates how well your website converts visitors into customers — a practice called Conversion Rate Optimisation (CRO).</p> 456 457 <div class="score-box"> 458 <div>${scoreRing(score, 90, 6)}</div> 459 <div class="score-details"> 460 <div style="font-size:14px;font-weight:700;color:#1a365d;">Overall Conversion Score: ${Math.round(score)}/100 <span style="color:${gradeColor};margin-left:8px;">${escapeHtml(grade)}</span></div> 461 ${scoreJson.overall_calculation?.grade_interpretation ? `<div style="font-size:10px;color:#4a5568;margin-top:4px;">${escapeHtml(scoreJson.overall_calculation.grade_interpretation)}</div>` : ''} 462 <div class="score-bar-track" style="margin-top:8px;"> 463 <div class="score-bar-fill" style="width:${score}%;background:${getScoreColor(score)};"></div> 464 </div> 465 </div> 466 </div> 467 468 ${narratives.executive_summary ? `<div class="narrative">${escapeHtml(narratives.executive_summary)}</div>` : ''} 469 470 ${aboveFoldBase64 ? `<img src="${aboveFoldBase64}" alt="Above-fold screenshot" style="width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid #e2e8f0;margin:10px 0;">` : ''} 471 472 ${ 473 strengths.length > 0 474 ? ` 475 <div class="card card-green" style="margin-top:10px;"> 476 <div style="font-size:12px;font-weight:700;color:#38a169;margin-bottom:6px;">Key Strengths</div> 477 ${strengths.map(s => `<div class="finding-item"><span class="finding-icon" style="color:#38a169;">+</span><span>${escapeHtml(s)}</span></div>`).join('')} 478 </div>` 479 : '' 480 } 481 482 ${ 483 weaknesses.length > 0 484 ? ` 485 <div class="card card-red"> 486 <div style="font-size:12px;font-weight:700;color:#e53e3e;margin-bottom:6px;">Areas for Improvement</div> 487 ${weaknesses.map(w => `<div class="finding-item"><span class="finding-icon" style="color:#e53e3e;">−</span><span>${escapeHtml(w)}</span></div>`).join('')} 488 </div>` 489 : '' 490 } 491 492 ${footer(domain)} 493 </div> 494 495 <!-- PAGES 3-4: FACTOR ANALYSIS --> 496 <div class="page" id="page-factors">${(P(), '')} 497 ${renderPageHeader('Factor Analysis')} 498 <h1 class="section-title">Factor Analysis</h1> 499 <div class="section-accent"></div> 500 <p style="font-size:10px;color:#718096;margin-bottom:8px;">${isQuickFixes 501 ? 'Your 5 worst-scoring conversion factors, each scored 0–10 and ranked by impact on visitor-to-customer conversion rates.' 502 : 'Each of the 10 conversion factors is scored 0–10 and weighted by its impact on visitor-to-customer conversion rates.'}</p> 503 504 <div class="glossary-box"><strong>Quick glossary:</strong> CTA (Call to Action) = a button or link that tells visitors what to do. USP (Unique Selling Point) = what makes you different from competitors. "Above the fold" = visible without scrolling. "Friction" = anything that makes visitors hesitant or slows them down.</div> 505 506 ${Object.entries(factors) 507 .slice(0, 5) 508 .map(([key, data]) => renderFactorCard(key, data, narratives.factor_narratives?.[key])) 509 .join('')} 510 511 ${footer(domain)} 512 </div> 513 514 ${isQuickFixes ? '' : `<div class="page">${(P(), '')} 515 ${Object.entries(factors) 516 .slice(5) 517 .map(([key, data]) => renderFactorCard(key, data, narratives.factor_narratives?.[key])) 518 .join('')} 519 520 ${footer(domain)} 521 </div>`} 522 523 <!-- PAGES 5-6: PROBLEM AREAS --> 524 ${ 525 problemCrops && problemCrops.length > 0 526 ? ` 527 <div class="page" id="page-screenshots">${(P(), '')} 528 ${renderPageHeader('Problem Areas')} 529 <h1 class="section-title">Problem Areas</h1> 530 <div class="section-accent"></div> 531 <p style="font-size:10px;color:#718096;margin-bottom:12px;">Zoomed-in screenshots showing specific conversion issues on your site, with exact recommendations for each.</p> 532 533 ${problemCrops 534 .map((crop, i) => { 535 // Support both old format (imageBuffer) and new format (beforeBuffer/afterBuffer) 536 const toBase64 = buf => { 537 if (!buf) return ''; 538 if (Buffer.isBuffer(buf)) return `data:image/jpeg;base64,${buf.toString('base64')}`; 539 if (typeof buf === 'string') 540 return buf.startsWith('data:') ? buf : `data:image/jpeg;base64,${buf}`; 541 return ''; 542 }; 543 544 const beforeSrc = toBase64(crop.beforeBuffer || crop.imageBuffer); 545 const afterSrc = toBase64(crop.afterBuffer); 546 const hasBeforeAfter = beforeSrc && afterSrc; 547 548 // Natural-size images: max-width capped, never stretched beyond actual pixels 549 const imgStyle = 550 'max-width:100%;max-height:220px;object-fit:contain;border-radius:6px;border:1px solid #e2e8f0;'; 551 552 return ` 553 <div class="card problem-card"> 554 <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"> 555 <span class="severity-badge" style="background:${getSeverityColor(crop.severity)};">${escapeHtml((crop.severity || 'medium').toUpperCase())}</span> 556 <span style="font-size:12px;font-weight:700;color:#1a365d;">${escapeHtml(FACTOR_LABELS[crop.factor] || crop.factor)}</span> 557 </div> 558 ${ 559 hasBeforeAfter 560 ? `<div style="display:flex;gap:10px;margin:8px 0;align-items:flex-start;"> 561 <div style="flex:1;text-align:center;"> 562 <div style="font-size:9px;font-weight:700;color:#e53e3e;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Current</div> 563 <img src="${beforeSrc}" alt="Current: ${escapeHtml(crop.factor)}" style="${imgStyle}"> 564 </div> 565 <div style="flex:1;text-align:center;"> 566 <div style="font-size:9px;font-weight:700;color:#38a169;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Recommended</div> 567 <img src="${afterSrc}" alt="Recommended: ${escapeHtml(crop.factor)}" style="${imgStyle}"> 568 </div> 569 </div>` 570 : beforeSrc 571 ? `<img src="${beforeSrc}" alt="Problem area: ${escapeHtml(crop.factor)}" class="problem-img" style="${imgStyle}margin:8px 0;">` 572 : '' 573 } 574 <div class="problem-desc">${escapeHtml(crop.description)}</div> 575 <div class="problem-fix"> 576 <div style="font-weight:700;color:#38a169;margin-bottom:2px;">Recommendation:</div> 577 <div>${escapeHtml(crop.recommendation)}</div> 578 ${ 579 crop.current_text && crop.suggested_text 580 ? ` 581 <div class="factor-copy-change" style="margin-top:4px;"> 582 <span class="old">${escapeHtml(crop.current_text)}</span> → <span class="new">${escapeHtml(crop.suggested_text)}</span> 583 </div>` 584 : '' 585 } 586 </div> 587 </div> 588 ${i === 2 ? `${footer(domain)}</div><div class="page">${(P(), '')}` : ''}`; 589 }) 590 .join('')} 591 592 ${footer(domain)} 593 </div>` 594 : '' 595 } 596 597 <!-- PAGE: TECHNICAL ASSESSMENT --> 598 <div class="page">${(P(), '')} 599 ${renderPageHeader('Technical Assessment')} 600 <h1 class="section-title">Technical Assessment</h1> 601 <div class="section-accent"></div> 602 603 <div class="card" style="margin-bottom:14px;"> 604 <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">SSL / HTTPS</div> 605 <div class="tech-item"> 606 <span class="${techAssess.ssl_status === 'https' || (techAssess.security_headers_present || []).includes('hsts') ? 'tech-check' : 'tech-cross'}">${techAssess.ssl_status === 'https' ? '✓' : '✗'}</span> 607 <span>${techAssess.ssl_status === 'https' ? 'Secure connection (HTTPS)' : 'Not secure — site is served over HTTP'}</span> 608 </div> 609 ${techAssess.ssl_status !== 'https' ? `<div class="tech-explanation">${escapeHtml(HTTPS_FAIL_EXPLANATION)}</div>` : ''} 610 </div> 611 612 <div class="card" style="margin-bottom:14px;"> 613 <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">Security Headers</div> 614 ${[ 615 'HSTS', 616 'CSP', 617 'X-Frame-Options', 618 'X-Content-Type-Options', 619 'Referrer-Policy', 620 'Permissions-Policy', 621 ] 622 .map(header => { 623 const present = (techAssess.security_headers_present || []).some(h => 624 h.toLowerCase().includes(header.toLowerCase()) 625 ); 626 const explanation = !present ? TECH_EXPLANATIONS[header] : ''; 627 return `<div class="tech-item"><span class="tech-${present ? 'check' : 'cross'}">${present ? '✓' : '✗'}</span><span>${header}</span></div>${explanation ? `<div class="tech-explanation">${escapeHtml(explanation)}</div>` : ''}`; 628 }) 629 .join('')} 630 </div> 631 632 ${ 633 techAssess.mobile_responsive !== undefined 634 ? ` 635 <div class="card" style="margin-bottom:14px;"> 636 <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">Mobile Responsiveness</div> 637 <div class="tech-item"> 638 <span class="${techAssess.mobile_responsive ? 'tech-check' : 'tech-cross'}">${techAssess.mobile_responsive ? '✓' : '✗'}</span> 639 <span>${techAssess.mobile_responsive ? 'Site appears mobile-responsive' : 'Site does not appear mobile-responsive'}</span> 640 </div> 641 ${!techAssess.mobile_responsive ? `<div class="tech-explanation">${escapeHtml(MOBILE_FAIL_EXPLANATION)}</div>` : ''} 642 </div>` 643 : '' 644 } 645 646 ${ 647 (techAssess.performance_indicators || []).length > 0 648 ? ` 649 <div class="card"> 650 <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">Performance</div> 651 ${(techAssess.performance_indicators || []).map(p => `<div class="tech-item"><span class="tech-check">✓</span><span>${escapeHtml(p)}</span></div>`).join('')} 652 </div>` 653 : '' 654 } 655 656 ${footer(domain)} 657 </div> 658 659 <!-- PAGE: PRIORITISED RECOMMENDATIONS --> 660 <div class="page" id="page-recommendations">${(P(), '')} 661 ${renderPageHeader('Recommendations')} 662 <h1 class="section-title">Prioritised Recommendations</h1> 663 <div class="section-accent"></div> 664 665 ${ 666 quickWins.length > 0 667 ? ` 668 <div style="font-size:14px;font-weight:700;color:#e05d26;margin-bottom:8px;">Quick Wins — Do This Week</div> 669 ${quickWins 670 .map( 671 (qw, i) => ` 672 <div class="card card-orange" style="padding:8px 12px;"> 673 <div style="font-size:10px;color:#2d3748;">${i + 1}. ${escapeHtml(typeof qw === 'string' ? qw : qw.description || '')}</div> 674 </div>` 675 ) 676 .join('')} 677 ` 678 : '' 679 } 680 681 ${ 682 recommendations.length > 0 683 ? ` 684 <div style="font-size:14px;font-weight:700;color:#1a365d;margin:14px 0 8px;">All Recommendations by Priority</div> 685 ${recommendations 686 .map(rec => { 687 const catColor = 688 rec.category === 'quick_win' 689 ? '#e05d26' 690 : rec.category === 'strategic' 691 ? '#1a365d' 692 : '#a0aec0'; 693 return ` 694 <div class="card rec-card" style="border-left:4px solid ${catColor};"> 695 <div class="rec-priority" style="background:${catColor};">${rec.priority || ''}</div> 696 <div class="rec-body"> 697 <div class="rec-title">${escapeHtml(rec.title || rec.description || '')}</div> 698 ${rec.title && rec.description ? `<div class="rec-desc">${escapeHtml(rec.description)}</div>` : ''} 699 <div class="rec-meta"> 700 ${rec.category ? `<span class="rec-pill">${escapeHtml(rec.category.replace('_', ' '))}</span>` : ''} 701 ${rec.expected_impact ? `<span class="rec-pill rec-pill-impact">${escapeHtml(rec.expected_impact)} impact</span>` : ''} 702 ${rec.estimated_effort ? `<span class="rec-pill rec-pill-effort">${escapeHtml(rec.estimated_effort)}</span>` : ''} 703 ${rec.who_should_do_it ? `<span class="rec-pill">${escapeHtml(rec.who_should_do_it)}</span>` : ''} 704 </div> 705 </div> 706 </div>`; 707 }) 708 .join('')} 709 ` 710 : '' 711 } 712 713 ${footer(domain)} 714 </div> 715 716 <!-- PAGE: ACTION PLAN --> 717 ${ 718 narratives.action_plan_week || narratives.action_plan_month || narratives.action_plan_quarter 719 ? ` 720 <div class="page">${(P(), '')} 721 ${renderPageHeader('Action Plan')} 722 <h1 class="section-title">Your Action Plan</h1> 723 <div class="section-accent"></div> 724 <p style="font-size:10px;color:#718096;margin-bottom:14px;">A practical roadmap for improving your website's conversion performance — broken into manageable timeframes.</p> 725 726 ${ 727 narratives.action_plan_week 728 ? ` 729 <div class="action-section"> 730 <div class="action-label" style="color:#e05d26;border-color:#e05d26;">This Week</div> 731 ${renderActionItems(narratives.action_plan_week, '#e05d26')} 732 </div>` 733 : '' 734 } 735 736 ${ 737 narratives.action_plan_month 738 ? ` 739 <div class="action-section"> 740 <div class="action-label">This Month</div> 741 ${renderActionItems(narratives.action_plan_month, '#1a365d')} 742 </div>` 743 : '' 744 } 745 746 ${ 747 narratives.action_plan_quarter 748 ? ` 749 <div class="action-section"> 750 <div class="action-label" style="color:#718096;border-color:#718096;">Next 3 Months</div> 751 ${renderActionItems(narratives.action_plan_quarter, '#718096')} 752 </div>` 753 : '' 754 } 755 756 ${footer(domain)} 757 </div>` 758 : '' 759 } 760 761 <!-- PAGE: ABOUT --> 762 <div class="page">${(P(), '')} 763 <h1 class="section-title">About This Report</h1> 764 <div class="section-accent"></div> 765 766 <div class="about-logo">${LOGO_LIGHT_SVG}</div> 767 768 <div class="about-text">This report was prepared by Audit&Fix using AI-powered analysis of your website's conversion optimisation factors. Our system captures full-page screenshots, analyses HTML structure, checks security headers, and evaluates 10 key factors that influence whether a visitor becomes a customer.</div> 769 770 <div class="about-text">The scoring methodology is based on industry-standard CRO best practices, with each factor weighted by its relative importance to conversion outcomes. Every recommendation includes specific, actionable changes — not generic advice.</div> 771 772 ${visionUsed ? `<div class="about-text" style="color:#718096;font-style:italic;border-left:3px solid #e2e8f0;padding-left:12px;">Note: Your score may differ from any previous assessment. This report uses computer vision to evaluate your website's visual design and user experience — factors that affect how real visitors perceive your site. This provides a more complete picture than HTML analysis alone.</div>` : ''} 773 774 <div style="font-size:13px;font-weight:700;color:#1a365d;margin:14px 0 6px;">Grade Scale</div> 775 <table class="grade-table"> 776 <thead><tr><th>Grade</th><th>Score</th><th>Meaning</th></tr></thead> 777 <tbody> 778 <tr><td><span class="grade-dot" style="background:#38a169;"></span>A+</td><td>97–100</td><td>Exceptional — industry-leading</td></tr> 779 <tr><td><span class="grade-dot" style="background:#38a169;"></span>A</td><td>93–96</td><td>Excellent — strong conversion potential</td></tr> 780 <tr><td><span class="grade-dot" style="background:#38a169;"></span>A−</td><td>90–92</td><td>Very good — minor improvements possible</td></tr> 781 <tr><td><span class="grade-dot" style="background:#3182ce;"></span>B+</td><td>87–89</td><td>Good — a few clear opportunities</td></tr> 782 <tr><td><span class="grade-dot" style="background:#3182ce;"></span>B</td><td>83–86</td><td>Above average — room for improvement</td></tr> 783 <tr><td><span class="grade-dot" style="background:#3182ce;"></span>B−</td><td>80–82</td><td>Borderline — several barriers present</td></tr> 784 <tr><td><span class="grade-dot" style="background:#d69e2e;"></span>C+</td><td>77–79</td><td>Below average — meaningful work needed</td></tr> 785 <tr><td><span class="grade-dot" style="background:#d69e2e;"></span>C</td><td>73–76</td><td>Fair — significant issues across factors</td></tr> 786 <tr><td><span class="grade-dot" style="background:#d69e2e;"></span>C−</td><td>70–72</td><td>Weak — substantial work required</td></tr> 787 <tr><td><span class="grade-dot" style="background:#dd6b20;"></span>D+</td><td>67–69</td><td>Poor — major barriers across most factors</td></tr> 788 <tr><td><span class="grade-dot" style="background:#dd6b20;"></span>D</td><td>63–66</td><td>Very poor — fundamental issues</td></tr> 789 <tr><td><span class="grade-dot" style="background:#dd6b20;"></span>D−</td><td>60–62</td><td>Critical — losing most potential customers</td></tr> 790 <tr><td><span class="grade-dot" style="background:#e53e3e;"></span>F</td><td>0–59</td><td>Failing — comprehensive overhaul required</td></tr> 791 </tbody> 792 </table> 793 794 ${isQuickFixes 795 ? `<div class="cta-box" style="margin-top:20px;"> 796 <h3>Want the full picture?</h3> 797 <p>This report covers your 5 worst-scoring factors. Upgrade to the <strong>Full Audit</strong> for all 10 factors, zoomed screenshots, a technical assessment, and a prioritised roadmap. We'll credit what you paid for Quick Fixes toward the upgrade.</p> 798 <p style="margin-top:6px;color:#e05d26;font-weight:600;">Reply to this email to upgrade — ${process.env.BRAND_DOMAIN}</p> 799 </div>` 800 : `<div class="cta-box" style="margin-top:20px;"> 801 <h3>Made changes? Measure the improvement.</h3> 802 <p>Order a follow-up benchmarking report at 50% of the original price to see how much your score improved.</p> 803 <p style="margin-top:6px;color:#e05d26;font-weight:600;">reports@${process.env.BRAND_DOMAIN}</p> 804 </div>`} 805 806 ${footer(domain)} 807 </div> 808 809 </body> 810 </html>`; 811 } 812 813 /** 814 * Render a single factor analysis card 815 */ 816 function renderFactorCard(key, data, narrative) { 817 const label = FACTOR_LABELS[key] || key; 818 const weight = FACTOR_WEIGHTS[key] || 0; 819 const factorScore = data?.score ?? 0; 820 const icon = FACTOR_ICONS[key] || ''; 821 // Support both data.evidence and data.current_text_context as evidence fallback 822 const evidence = data?.evidence || data?.current_text_context || ''; 823 824 return ` 825 <div class="card factor-card"> 826 <div class="factor-icon">${icon}</div> 827 <div style="flex-shrink:0;">${factorRing(factorScore)}</div> 828 <div class="factor-body"> 829 <div class="factor-header"> 830 <span class="factor-name">${escapeHtml(label)}</span> 831 <span class="factor-weight">${weight}%</span> 832 </div> 833 ${data?.reasoning ? `<div class="factor-reasoning">${escapeHtml(data.reasoning)}</div>` : ''} 834 ${evidence ? `<div class="factor-evidence">"${escapeHtml(evidence)}"</div>` : ''} 835 ${narrative ? `<div class="factor-fix"><span class="factor-fix-label">Plain English: </span>${escapeHtml(narrative)}</div>` : ''} 836 ${ 837 data?.current_text && data?.suggested_text 838 ? ` 839 <div class="factor-fix" style="background:#fff5f5;border-color:#e53e3e;"> 840 <div class="factor-copy-change"> 841 <span class="old">${escapeHtml(data.current_text)}</span> → <span class="new">${escapeHtml(data.suggested_text)}</span> 842 </div> 843 ${data.fix_detail ? `<div style="margin-top:3px;font-size:9.5px;color:#4a5568;">${escapeHtml(data.fix_detail)}</div>` : ''} 844 </div>` 845 : '' 846 } 847 </div> 848 </div>`; 849 }