1/** 2 * src/public/js/course-config.js â the course page's three CONFIGURATION 3 * tabs: **Customize**, **Certificates** and **Settings**. 4 * 5 * They share a file because they share a shape: each is a small form or a 6 * doorway into an editor that already exists, and none of them is big enough 7 * to justify its own module. The three tabs that AUTHOR (Outline), SELL 8 * (Offers) and REPORT (Members) each get their own file, because those are 9 * the ones that grow. 10 * 11 * ââ CUSTOMIZE â change, not first choice (D5) ââââââââââââââââââââââââ 12 * The photo-led 31-template gallery lives INSIDE onboarding, deliberately: 13 * "people love templates and it gives such a lift" is a first-run moment, 14 * not a settings screen. So this tab is for CHANGING an applied look, and it 15 * is a compact list plus ONE live preview rather than a second gallery â the 16 * preview engine's whole design (send the facts once, swap the template per 17 * card) makes `setSlug` on a single frame the cheap, correct move, and a 18 * wall of 31 previews on a 390px phone is not a gallery, it is a scroll. 19 * 20 * D7/D49 hold absolutely: applying a template BAKES a `pages` row and the 21 * existing page editor edits it. No second editor, and this file does not 22 * touch the editor at all â it links to it. 23 * 24 * ââ CERTIFICATES â ours already beats theirs, so say so ââââââââââââââ 25 * Kajabi's "verification" is a shareable URL. We issue a QR + a public 26 * verification page. That is surfaced here rather than buried â and since 27 * D19 landed (migration 390) it is surfaced with a link the coach can 28 * actually click, because a claim you can test is worth more than a 29 * paragraph. 30 * 31 * The tab owns what is true of THIS CREDENTIAL â on/off, plain vs branded, 32 * and how long it stays valid. What the certificate LOOKS like (which 33 * elements print, the fine print, logo/colour/signature) is one design 34 * shared by the whole community and lives one click away in 35 * course-certificates-editor.js. Splitting it that way means a coach never 36 * sets the same brand twice, and never has to wonder which course a design 37 * change applied to. 38 * 39 * Email-on-completion is the exception that sits here rather than in the 40 * design screen: it is delivery, not design, and it is the first question a 41 * coach asks on this tab. It is community-wide and the control says so. 42 * 43 * ââ SETTINGS â including the switch that could never be flipped ââââââ 44 * `assistant_enabled` (migration 378) has been ENFORCED by 45 * course-assistant-service since the day it shipped and had no UI and no 46 * route that could set it. A kill switch nobody can pull is not a kill 47 * switch; it is now here. D30's tenant-visible conversation log is recorded 48 * (`community_course_assistant_turns`) but has no read surface at all, so it 49 * gets an honest empty state and not a fake screen. 50 * 51 * Loaded by app.html; window.App.CoursePage dispatches to all three. 52 */ 53(function () { 54 'use strict'; 55 56 var App = (window.App = window.App || {}); 57 var P = App.CoursePage; 58 59 // ââ CUSTOMIZE ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 60 61 var _preview = null; // the single mounted preview frame 62 63 64 /** 65 * Speak It on this tab's open text boxes (Alex, 2026-08-01: "every open text 66 * box"). Swept over the container after paint rather than wired field by 67 * field â attachVoicePill is idempotent per element, so a sweep cannot 68 * double-attach and cannot miss a field somebody adds later. 69 */ 70 function _speakIt(root) { 71 // window.VoiceInput (js/voice-input.js) â the platform's one dictation 72 // control since the 2026-07-30 rebuild. Not a rename of attachVoicePill: 73 // the pill floated on the field and covered the text being typed, so the 74 // control now sits in its own row beneath it. 75 var V = window.VoiceInput; 76 if (!V || typeof V.attach !== 'function' || !root || !root.querySelectorAll) return; 77 Array.prototype.forEach.call(root.querySelectorAll('textarea:not([data-no-voice])'), function (f) {
78 try { V.attach(f); } catch (_) {} 79 }); 80 } 81 82 /* ââ Colours and fonts ââââââââââââââââââââââââââââââââââââââââââââââââ 83 * 84 * Alex, 2026-08-01: "I think we definitely need a better way to customise 85 * the preview of the course page and we can use the tools from there⦠86 * allowing them to pick the colours and the fonts." 87 * 88 * The tools from there, literally: window.LookPickers is published by 89 * launch-studio.js and carries the same six palettes, the same four 90 * pairings and the same two card renderers the website builder's Look step 91 * uses. The CSS is css/look-pickers.css, worn by putting `.lk-pickers` on 92 * this tab. Nothing about the controls is written here â only where they 93 * sit and which page they land on. 94 * 95 * Returns '' when the module has not loaded, so the template picker (which 96 * has always worked on its own) is never taken down by an absent optional. 97 */ 98 var _lookSel = { pal: 0, font: 0 }; 99 100 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 101 THEIR OWN COLOURS, HERE TOO 102 103 The wizard's Look step puts a "Your brand" card first and pre-selects it 104 (Alex, 2026-08-02: "if we've got brand colours already, I'd already 105 pre-selected those brand colours on the course and that would be the first 106 palette that is selected"). This is the SAME screen after the build â the 107 one a coach comes back to â and until now it showed the plain six. 108 109 Alex, confirming: "once you've built it and you go back to the look panel, 110 it will still show blank palettes. Yeah." 111 112 Same deriver (LookPickers.brandPalette), same card renderer. The colour 113 comes from the community's own theme, fetched once when this tab paints. 114 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 115 var _brandColour = null; 116 117 function _brandPal() { 118 var LP = window.LookPickers; 119 if (!LP || !LP.brandPalette) return null; 120 return LP.brandPalette({ brand: _brandColour, name: 'Your brand', tag: 'Your own colours' }); 121 } 122 123 /** 124 * The palettes on offer, in the order they are painted. 125 * 126 * `_lookSel.pal` indexes THIS, not LookPickers.PALETTES â the grid's click 127 * handler works off DOM position, so a reader counting from the other list 128 * hands the coach a different colour from the one they tapped. 129 */ 130 function _pals() { 131 var LP = window.LookPickers; 132 if (!LP) return []; 133 var b = _brandPal(); 134 return b ? [b].concat(LP.PALETTES) : LP.PALETTES.slice(); 135 } 136 function _palIdx() { 137 var n = _pals().length; 138 if (!n) return -1; 139 return (_lookSel.pal >= 0 && _lookSel.pal < n) ? _lookSel.pal : 0; 140 } 141 function _curPal() { var i = _palIdx(); return i < 0 ? null : _pals()[i]; } 142 143 /** Fetch the community's brand colour once, before the rail is painted. */ 144 async function _loadBrandColour() { 145 if (_brandColour !== null) return _brandColour; 146 try { 147 var r = await P.api('/community-hosting/settings'); 148 var c = ((r && (r.settings || r)) || {}); 149 var hex = ((c.theme || {}).accent_colour) || null; 150 _brandColour = /^#[0-9a-fA-F]{6}$/.test(String(hex || '')) ? hex : ''; 151 } catch (_) { _brandColour = ''; } 152 return _brandColour; 153 } 154 155 function _lookRailHtml() { 156 var LP = window.LookPickers; 157 if (!LP) return ''; 158 return '<div class="lk-rail" style="margin-top:16px">' + 159 '<section class="lk-card"><div class="card-h">' + 160 '<h3><span class="n">A</span> Colour palette</h3>' + 161 '<div class="hint">Tap any palette: the preview re-colours as you look at it.</div>' + 162 '</div><div class="card-b">' + 163 '<div class="lk-palgrid" data-cz-pals>' + (function () { 164 var list = _pals(), cur = _palIdx(), brand = !!_brandPal(); 165 return list.map(function (p, i) { 166 return LP.palCardHtml(p, i === cur, i === 0 && brand ? 'recommended' : ''); 167 }).join(''); 168 })() + '</div>' + 169 '</div></section>' + 170 '<section class="lk-card"><div class="card-h">' + 171 '<h3><span class="n">B</span> Typeface</h3>' + 172 '<div class="hint">A pair that works together: headings & body.</div>' + 173 '</div><div class="card-b">' + 174 '<div class="lk-fontgrid" data-cz-fonts>' + LP.fontCardsHtml(_lookSel.font) + '</div>' + 175 '<button class="btn btn-dark" type="button" data-cz-look-save style="width:100%;margin-top:12px">Apply to my course page</button>' + 176 '</div></section>' + 177 '</div>'; 178 } 179 180 /** The 4-role model the website builder's apply-styles speaks â same keys, 181 * so the same palette produces the same page. */ 182 function _lookVars(p) { 183 return { bg: p.bg, surface: p.surface, text: p.text, text2: p.text2, 184 acc: p.acc, acc2: p.acc2, border: p.border }; 185 } 186 187 function _wireLookRail(el, ctx) { 188 var LP = window.LookPickers; 189 if (!LP || !el.querySelector('[data-cz-pals]')) return; 190 191 function look() { 192 var f = LP.FONTS[_lookSel.font]; 193 var p = _curPal() || LP.PALETTES[0]; 194 return { cssVars: _lookVars(p), fonts: LP.buildFonts(f.d, f.b, f.hWeight) }; 195 } 196 function paint() { 197 // In place, not a repaint: a repaint flashes white while the document 198 // rebuilds, which reads as a page reloading rather than a colour 199 // changing. setLook writes the same :root properties and the same 200 // #brand-fonts block the server writes, so this preview is the save. 201 if (_preview && _preview.setLook) _preview.setLook(look()); 202 }
203 paint(); 204 205 function pick(sel, key) { 206 Array.prototype.forEach.call(el.querySelectorAll(sel + ' > button'), function (b, i) { 207 b.addEventListener('click', function () { 208 Array.prototype.forEach.call(el.querySelectorAll(sel + ' > button'), function (x) { x.classList.remove('on'); }); 209 b.classList.add('on'); 210 _lookSel[key] = i; 211 paint(); 212 }); 213 }); 214 } 215 pick('[data-cz-pals]', 'pal'); 216 pick('[data-cz-fonts]', 'font'); 217 218 var save = el.querySelector('[data-cz-look-save]'); 219 if (save) save.addEventListener('click', async function () { 220 var label = save.textContent; 221 save.disabled = true; save.textContent = 'Applyingâ¦'; 222 try { 223 await P.api('/templates/courses/look', { 224 method: 'POST', 225 body: Object.assign({ course_id: ctx.courseId }, look()), 226 }); 227 P.toast('â Your course page is in ' + ((_curPal() || {}).name || 'your colours')); 228 } catch (e) { 229 // The one refusal worth explaining rather than reporting: colours need 230 // a page to land on, and the picker above is how you get one. 231 P.toast(e && e.code === 'no_course_page' 232 ? 'Pick a look above first. Then the colours have a page to land on.' 233 : 'Couldnât apply that: ' + ((e && e.code) || 'unknown')); 234 } finally { 235 save.disabled = false; save.textContent = label; 236 } 237 }); 238 } 239 240 async function renderCustomize(el, ctx) { 241 var course = ctx.course; 242 el.classList.add('lk-pickers'); // the website builder's own controls 243 el.innerHTML = '<div class="empty-block" style="opacity:0.6">Loading your course pageâ¦</div>'; 244 245 if (!window.CourseCoverPreview) { 246 el.innerHTML = '<div class="panel cpg-panel"><div class="panel-body">' + 247 P.emptyBlock('â ', 'The preview engine didnât load', 'Refresh the page and try again.') + 248 '</div></div>'; 249 return; 250 } 251 252 var roster = null, facts = null; 253 var out = await Promise.all([ 254 window.CourseCoverPreview.roster().catch(function () { return null; }), 255 P.api('/community-hosting/courses/' + ctx.courseId + '/guide/cover-context', { method: 'POST', body: {} }) 256 .catch(function () { return null; }), 257 // Their brand colour, so the palette grid can lead with it â fetched 258 // alongside the others rather than after, because the rail is painted 259 // from this same render and a late answer would paint the plain six. 260 _loadBrandColour(), 261 ]); 262 roster = out[0]; facts = out[1]; 263 264 if (!roster || !Array.isArray(roster.templates)) { 265 el.innerHTML = '<div class="panel cpg-panel"><div class="panel-body">' + 266 P.emptyBlock('â ', "Couldn't load the looks", 'Refresh to retry.') + '</div></div>'; 267 return; 268 } 269 270 var applied = course.template_slug || null; 271 var sel = applied || (roster.templates[0] && roster.templates[0].slug) || null; 272 var catLabel = {}; 273 (roster.categories || []).forEach(function (c) { catLabel[c.id] = c.label; }); 274 275 var appliedRow = applied 276 ? (roster.templates.filter(function (t) { return t.slug === applied; })[0] || null) 277 : null; 278 279 el.innerHTML = 280 '<div class="panel cpg-panel">' + 281 '<div class="panel-head"><div class="cpg-row-end">' + 282 '<div><div class="cpg-section-title">' + 283 (appliedRow ? 'Your course page uses ' + P.esc(appliedRow.name) : 'No look applied yet') + 284 '</div>' + 285 '<div class="cpg-section-sub">' + (appliedRow 286 ? P.esc(appliedRow.feel || '') 287 : 'Your course has no course page of its own yet. Pick a look below and weâll build one you can edit.') + 288 '</div></div>' + 289 (course.cover_page_id 290 ? '<button class="btn btn-secondary" type="button" data-cz-edit>âï¸ Edit this page</button>' 291 : '') + 292 '</div></div>' + 293 '<div class="panel-body">' + 294 '<div class="cpg-tpl-layout">' + 295 '<div class="cpg-tpl-picker">' + 296 '<div class="cpg-row-end" style="margin-bottom:8px">' + 297 '<div class="cpg-section-title" style="font-size:14px">Change the look</div>' + 298 '</div>' + 299 '<div class="cpg-tpl-list" data-cz-list>' + _tplListHtml(roster, catLabel, sel, applied) + '</div>' + 300 '<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">' + 301 '<button class="btn btn-dark" type="button" data-cz-apply>Use this look</button>' + 302 '<span class="cpg-section-sub" style="margin:0">
302Rebuilds your course page from your real course.</span>' + 303 '</div>' + 304 '<p class="cpg-msg" data-cz-msg style="display:none"></p>' + 305 _lookRailHtml() + 306 '</div>' + 307 '<div class="cpg-tpl-preview">' + 308 '<div class="cpg-row-end" style="margin-bottom:8px">' + 309 '<div class="cpg-section-title" style="font-size:14px">Preview</div>' + 310 '<div class="cpg-vp-chips" data-cz-vps>' + 311 window.CourseCoverPreview.viewportChipsHtml('mobile', 'data-cz-vp') + 312 '</div>' + 313 '</div>' + 314 '<div data-cz-frame></div>' + 315 (facts ? '' : '<div class="cpg-section-sub">Couldnât load your courseâs details, so the preview is showing the look only.</div>') + 316 '</div>' + 317 '</div>' + 318 '</div>' + 319 '</div>' + 320 '<div class="panel cpg-panel"><div class="panel-body">' + 321 P.soonBlock('âï¸', 'Editing happens in the page editor you already know', 322 'Applying a look builds a normal page and hands it to the same editor you use for every other page: ' + 323 'there is no second editor to learn, and your edits are yours. Re-applying a look asks first and keeps ' + 324 'your current version in the pageâs revision history.') + 325 '</div></div>'; 326 327 // ONE frame. `setSlug` repaints it â the preview engine was built for 328 // exactly this (see its header note about 31 cards vs 31 renders). 329 if (_preview) { try { _preview.destroy(); } catch (_) {} _preview = null; } 330 if (facts) {
331 // No `height`. In device mode the component reads it INSTEAD of the 332 // viewport's own height, so `height: 520` drew a 390x760 phone as 333 // 390x520 â and the same 520 for the 1280x860 desktop chip, which is a 334 // letterbox strip rather than a browser window. The wizard's gallery 335 // already carried this warning in a comment; this surface had not read 336 // it. Left out, the frame is the device and the wrap scales it to fit 337 // whatever width this column happens to have. 338 _preview = window.CourseCoverPreview.mount(el.querySelector('[data-cz-frame]'), { 339 slug: sel, facts: facts, viewport: 'mobile', mode: 'device', 340 }); 341 } 342 343 var msg = el.querySelector('[data-cz-msg]'); 344 var edit = el.querySelector('[data-cz-edit]'); 345 if (edit) edit.addEventListener('click', function () { 346 var pid = course.cover_page_id; 347 if (typeof window.builderOpenQuickEdit === 'function') window.builderOpenQuickEdit(pid); 348 else window.open((window.__BASE_PATH || '') + '/builder/review/review.html?page_id=' + pid, '_blank'); 349 }); 350 351 _wireLookRail(el, ctx); 352 353 Array.prototype.forEach.call(el.querySelectorAll('[data-cz-tpl]'), function (b) { 354 b.addEventListener('click', function () { 355 sel = b.getAttribute('data-cz-tpl'); 356 Array.prototype.forEach.call(el.querySelectorAll('[data-cz-tpl]'), function (x) { 357 x.classList.toggle('on', x.getAttribute('data-cz-tpl') === sel); 358 }); 359 if (_preview) _preview.setSlug(sel); 360 }); 361 }); 362 Array.prototype.forEach.call(el.querySelectorAll('[data-cz-vp]'), function (b) { 363 b.addEventListener('click', function () { 364 Array.prototype.forEach.call(el.querySelectorAll('[data-cz-vp]'), function (x) { 365 x.classList.toggle('on', x === b); 366 }); 367 if (_preview) _preview.setViewport(b.getAttribute('data-cz-vp')); 368 }); 369 }); 370 371 el.querySelector('[data-cz-apply]').addEventListener('click', async function () { 372 var btn = this; 373 if (!sel) return; 374 // Design audit F1: CBSheet, never the browser's dialog (ui-core.js:1845). 375 if (sel === applied && !(await window.CBSheet.confirm({ 376 title: 'Rebuild this page?', 377 message: 'That look is already applied. Rebuild the page from your current course?', 378 confirmLabel: 'Rebuild', 379 }))) return; 380 btn.disabled = true; 381 P.setMsg(msg, 'Building your course pageâ¦'); 382 try { 383 await _apply(ctx.courseId, sel, false); 384 P.setMsg(msg, 'â Course page rebuilt.', 'good'); 385 ctx.reload(); 386 } catch (err) { 387 btn.disabled = false; 388 if (err && err.code === 'template_page_edited') { 389 if (await window.CBSheet.confirm({ 390 title: 'Replace your edited page?', 391 message: 'Youâve edited this course page since the look was applied. ' + 392 'Replace it? Your current version is kept in the pageâs revision history, so you can restore it.', 393 confirmLabel: 'Replace it', 394 })) { 395 btn.disabled = true; 396 try { 397 await _apply(ctx.courseId, sel, true); 398 P.setMsg(msg, 'â Rebuilt: your previous version is in the pageâs history.', 'good'); 399 ctx.reload(); 400 return; 401 } catch (e2) { 402 btn.disabled = false; 403 P.setMsg(msg, 'Couldnât apply that look: ' + ((e2 && e2.code) || 'unknown'), 'bad'); 404 return; 405 } 406 } 407 P.setMsg(msg, ''); 408 return; 409 } 410 P.setMsg(msg, 'Couldnât apply that look: ' + ((err && err.code) || 'unknown'), 'bad'); 411 } 412 }); 413 } 414 415 function _apply(courseId, slug, confirmFlag) { 416 return P.api('/templates/courses/apply', { 417 method: 'POST', body: { course_id: courseId, slug: slug, confirm: !!confirmFlag }, 418 }); 419 } 420 421 function _tplListHtml(roster, catLabel, sel, applied) { 422 var byCat = {}; 423 var order = [];
424 roster.templates.forEach(function (t) { 425 if (!byCat[t.category]) { byCat[t.category] = []; order.push(t.category); } 426 byCat[t.category].push(t); 427 }); 428 return order.map(function (cat) { 429 return '<div class="cpg-tpl-cat">' + P.esc(catLabel[cat] || cat) + '</div>' + 430 byCat[cat].map(function (t) { 431 return '<button type="button" class="cpg-tpl' + (t.slug === sel ? ' on' : '') + '" data-cz-tpl="' + P.esc(t.slug) + '">' + 432 '<span class="nm">' + P.esc(t.name) + 433 (t.slug === applied ? '<span class="badge badge-success">Applied</span>' : '') + '</span>' + 434 '<span class="fl">' + P.esc(t.niche || '') + (t.feel ? ' · ' + P.esc(t.feel) : '') + '</span>' + 435 '</button>'; 436 }).join(''); 437 }).join(''); 438 } 439 440 // ââ CERTIFICATES âââââââââââââââââââââââââââââââââââââââââââââââââââ 441 442 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 443 U11 â THE CERTIFICATES SCREEN IS THE LOOK STEP'S LAYOUT 444 445 Alex, 2026-08-05: *"the certificates need to be set up very similar to how 446 the preview is done. Use the same sort of layout. The certificate should be 447 in the middle of the page so you preview it and then the controls should be 448 done in the same way."* 449 450 So this screen is `_renderLook`'s stage, mounted rather than redrawn: 451 `.pho-stage.pho-stage3` (css/photo-stage.css:132) â a pane SELECTOR on the 452 left, the artefact in the middle, and a rail on the right that holds 453 EXACTLY ONE card, because `_certCard()` returns '' for every pane that is 454 not the open one. That last part is the mechanic, not a detail
454: hiding six 455 cards with CSS is a different screen from having one. 456 457 â ï¸ WHAT IS *NOT* MOUNTED: `window.CourseCoverPreview`. That is the course 458 PAGE engine â it loads /templates/courses/_render.js and draws HTML in an 459 iframe, client-side. A certificate is a server-rendered PDF and nothing 460 else, so the middle frame here is its own small mount 461 (`_certPreviewMount`), sharing the layout and not the renderer. 462 463 ââ Why the two screens became one ââââââââââââââââââââââââââââââââââ 464 "Preview plain", "Preview branded" and "Design the certificate" were three 465 buttons that each replaced the screen you were on with something you then 466 had to come back from. They are now the preview itself (it is on the page), 467 pane B (Style) and panes D + E. The design editor 468 (course-certificates-editor.js) is still loaded and still works; nothing 469 here deletes it, because Alex's own sequencing rule is to cover everything 470 first and retire the old surface after. 471 472 ââ The scope split is stated FOUR times, deliberately âââââââââââââââ 473 A coach must not change their logo for every course believing they changed 474 it for one. So: the left list is grouped under THIS COURSE / EVERY COURSE, 475 every card's hint opens with its scope, the retrospective truths sit in the 476 hints that own them, and â the one that catches the mistake at the moment 477 it is made â every save confirmation NAMES the scope it saved to. 478 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 479 480 // Whole months a coach is actually likely to want. "Never expires" is 481 // first because it is the right answer for almost every course, and 482 // there is deliberately no fixed-date option (D19): a fixed date is 483 // wrong for every student who finishes after it. 484 var CERT_VALIDITY_CHOICES = [ 485 { m: 0, label: 'Never expires' }, 486 { m: 6, label: '6 months from the day they finish' }, 487 { m: 12, label: '1 year from the day they finish' }, 488 { m: 24, label: '2 years from the day they finish' }, 489 { m: 36, label: '3 years from the day they finish' }, 490 ]; 491 492 // The elements a coach can stop printing. Each one genuinely disappears 493 // from the rendered PDF (course-certificate-service `_certificateHtml`) â 494 // there are no cosmetic entries, because a control that does nothing is 495 // worse than no control at all. What is deliberately NOT here: the 496 // member's name, the course title, the certificate number and the frame. 497 var CERT_ELEMENTS = [ 498 { key: 'community_name', label: 'Your communityâs name', 499 hint: 'The line in small capitals above âCertificate of Completionâ.' }, 500 { key: 'logo', label: 'Your logo', 501 hint: 'Branded certificates only: a plain certificate never shows a logo.' }, 502 { key: 'date', label: 'The completion date', 503 hint: 'The dated line at the bottom left.' }, 504 { key: 'signature', label: 'The signature line', 505 hint: 'Your signature image or name at the bottom right.' }, 506 { key: 'seal', label: 'The seal', 507 hint: 'The round laurel medallion between the date and the signature.' }, 508 { key: 'qr', label: 'The verification QR code', 509 hint: 'Turning this off removes the scannable square, not the verification. The certificate ' +
510 'number and the âverify atâ line stay, so it can still be checked by hand. It just canât ' + 511 'be checked with a phone camera.' }, 512 ]; 513 514 // Which card the rail is holding. Module scope, like `_lookPane` in the 515 // wizard, so the pane switch and the rail builder are the same two 516 // functions no matter which repaint asked. 517 var _certPane = 'on'; 518 var CERT_PANES = ['on', 'style', 'validity', 'brand', 'prints', 'email']; 519 520 function _certToken() { try { return localStorage.getItem('cb_token'); } catch (_) { return null; } } 521 522 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 523 THE PDF IN THE MIDDLE â and why it is not "updates as you tap" 524 525 Measured on the dev box, 2026-08-05, through the real service: 526 plain 3,404 ms · branded 4,358 ms, because `_htmlToPdf` launches a fresh 527 headless Chrome per call. The Look step repaints per keystroke because it 528 renders in the browser; this cannot, and pretending otherwise would put a 529 Chrome process on a shared multi-tenant box behind every key press. 530 531 So the cadence is the blast-radius control, not polish: 532 · a refresh is only ever asked for AFTER a save that the PDF renders, 533 · debounced 500 ms so a burst of toggles is one render, 534 · single-flight with a GENERATION counter â a slow `plain` render must 535 not land on top of a fast `branded` one and show the wrong certificate, 536 · a 1,500 ms floor between the STARTS of two renders, with at most one 537 trailing refresh queued behind the one in flight, 538 · aborted at 20,000 ms (the server's own setContent timeout is 15,000). 539 540 â ï¸ AND THE CANVAS IS NEVER BLANKED. Four seconds of white is 541 indistinguishable from broken, and this screen will do it dozens of times 542 in a session â so the PREVIOUS certificate stays up, dimmed, while the next 543 one draws. Same for every failure: the last good render stays, and the 544 message separates *saved* from *drawn*, because they are two different 545 things and a coach must never be left thinking their change was lost. 546 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 547 var CERT_PV_DEBOUNCE = 500; 548 var CERT_PV_FLOOR = 1500; 549 var CERT_PV_TIMEOUT = 20000; 550 551 function _certPreviewMount(host, opts) { 552 opts = opts || {}; 553 var gen = 0, inFlight = false, queued = false, lastStart = 0, busyRetries = 0; 554 var timer = null, ctrl = null, abortT = null, blobUrl = null, dead = false, painted = false; 555 556 var note = document.createElement('div'); 557 note.className = 'pho-loading'; 558 note.setAttribute('data-ct-pv-note', ''); 559 note.textContent = 'Drawing your certificate⦠this takes a few seconds.'; 560 561 /* §CERT-PREVIEW.1b â an <img>, NOT an <iframe>. The iframe handed the PDF 562 to the browser's own viewer, and Safari's drew a broken-image glyph 563 where Chrome drew the page â Alex hit it twice while the endpoint was 564 returning a valid PDF both times. The server now screenshots the same 565 render to a PNG, and an image renders identically everywhere. The box 566 is `.pho-page.pho-cert`, already the certificate's own 1122Ã793 shape, 567 so `contain` fills it edge to edge. */ 568 var frame = document.createElement('img'); 569 frame.setAttribute('alt', 'Certificate preview'); 570 frame.setAttribute('data-ct-pv-frame', ''); 571 frame.style.width = '100%'; 572 frame.style.height = '100%'; 573 frame.style.objectFit = 'contain'; 574 frame.style.border = '0'; 575 frame.style.display = 'none'; 576 frame.style.background = '#fff'; 577 578 host.innerHTML = ''; 579 host.appendChild(note); 580 host.appendChild(frame); 581 582 function say(text) { if (typeof opts.onSubline === 'function') opts.onSubline(text); } 583 584 function dim(on) { 585 frame.style.opacity = on ? '0.55' : ''; 586 frame.style.pointerEvents = on ? 'none' : ''; 587 } 588 589 function show(blob) { 590 /* The Manage panel repaints by replacing its body, so a render that was 591 in flight when the coach left comes back to a frame nobody can see. 592 Painting it is harmless but pointless; checking is one line. */ 593 if (!host.isConnected) return; 594 var next = null;
595 try { next = URL.createObjectURL(blob); } catch (_) { next = null; } 596 if (!next) return; 597 // The old object URL is revoked on replacement â a long editing session 598 // otherwise leaks one blob per render. 599 if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch (_) {} } 600 blobUrl = next; 601 frame.src = blobUrl; 602 frame.style.display = 'block'; 603 painted = true; 604 busyRetries = 0; 605 note.style.display = 'none'; 606 dim(false); 607 say(opts.styleLine ? opts.styleLine() : ''); 608 } 609 610 function fail(err) { 611 dim(false); 612 /* `renderer_busy`: the server's headless pool is full (a 503 like 613 `pdf_unavailable`, but it passes in seconds). Say so, and try again by 614 itself â twice at most, so a busy box is never hammered by this tab. */ 615 var busy = err && err.code === 'renderer_busy'; 616 var willRetry = busy && busyRetries < 2 && !dead; 617 if (willRetry) { 618 busyRetries++; 619 if (timer) clearTimeout(timer); 620 timer = setTimeout(kick, 5000); 621 } 622 var unavailable = !busy && err && (err.code === 'pdf_unavailable' || err.status === 503); 623 var text = busy 624 ? 'Your change is saved. The preview is busy right now' + (willRetry ? ', so it will try again in a few seconds.' : '. Try again in a minute.') 625 : unavailable 626 ? 'Your change is saved: the preview engine isnât available on this server right now.' 627 : 'Couldnât redraw the preview just now. Your change is saved.'; 628 if (painted) { 629 // The last good certificate stays exactly where it is. Only the line 630 // above it changes, because the change WAS saved. 631 say(text); 632 } else { 633 note.style.display = ''; 634 note.textContent = text; 635 } 636 } 637 638 function start() { 639 if (dead) return; 640 inFlight = true; 641 lastStart = Date.now(); 642 var mine = ++gen; 643 if (ctrl) { try { ctrl.abort(); } catch (_) {} } 644 try { ctrl = new AbortController(); } catch (_) { ctrl = null; } 645 if (painted) { dim(true); say('Redrawingâ¦'); } 646 abortT = setTimeout(function () { if (ctrl) { try { ctrl.abort(); } catch (_) {} } }, CERT_PV_TIMEOUT); 647 var t = _certToken(); 648 // The token stays OUT of the URL (this is a fetch, not a window.open), a 649 // non-OK response can be read as JSON so `pdf_unavailable` gets its own 650 // sentence, and `_=<generation>` stops the browser handing back the 651 // previous render of an identical URL after a settings change. 652 var url = '/api/community-hosting/courses/' + opts.courseId + 653 '/certificate-preview.png?style=' + encodeURIComponent(opts.style()) + '&_=' + mine; 654 var init = { credentials: 'same-origin', headers: t ? { Authorization: 'Bearer ' + t } : {} }; 655 if (ctrl) init.signal = ctrl.signal; 656 fetch(url, init) 657 .then(function (res) { 658 if (res.ok) return res.blob(); 659 return res.json().then(function (j) { 660 var e = new Error('preview_failed'); 661 e.status = res.status; e.code = j && j.error; 662 throw e; 663 }, function () { 664 var e = new Error('preview_failed'); e.status = res.status; throw e; 665 }); 666 }) 667 .then(function (blob) { 668 // â ï¸ A superseded response is DISCARDED, not painted. Without this a 669 // slow plain render lands after a fast branded one and the screen 670 // shows a certificate the coach did not ask for. 671 if (dead || mine !== gen) return; 672 show(blob); 673 }) 674 .catch(function (err) { 675 if (dead || mine !== gen) return; 676 fail(err); 677 }) 678 .then(function () { 679 if (abortT) { clearTimeout(abortT); abortT = null; } 680 if (mine !== gen) return; 681 inFlight = false; 682 if (queued) { queued = false; kick(); } 683 }); 684 } 685 686 function kick() { 687 if (dead) return; 688 if (inFlight) { queued = true; return; } 689 var wait = CERT_PV_FLOOR - (Date.now() - lastStart); 690 if (lastStart && wait > 0) { timer = setTimeout(kick, wait); return; } 691 start(); 692 } 693 694 return { 695 /** Ask for a redraw. Debounced; the cadence rules above do the rest. */ 696 refresh: function () { 697 if (dead) return; 698 if (timer) clearTimeout(timer); 699 timer = setTimeout(kick, CERT_PV_DEBOUNCE); 700 }, 701 /** The first draw, with no debounce â the coach is waiting on it. */ 702 first: function () { if (!dead) start(); }, 703 destroy: function () { 704 dead = true; 705 if (timer) clearTimeout(timer); 706 if (abortT) clearTimeout(abortT); 707 if (ctrl) { try { ctrl.abort(); } catch (_) {} } 708 if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch (_) {} } 709 }, 710 }; 711 } 712 713 async function renderCertificates(el, ctx) { 714 var course = ctx.course; 715 el.innerHTML = '<div class="empty-block" style="opacity:0.6">Loading certificatesâ¦</div>'; 716 var out = await Promise.all([ 717 P.api('/community-hosting/courses/' + ctx.courseId + '/certificates').catch(function () { return null; }), 718 P.api('/community-hosting/settings').catch(function () { return null; }), 719 ]); 720 var issued = (out[0] && out[0].certificates) || []; 721 var settings = (out[1] && out[1].settings) || {}; 722 var cert = settings.certificates || {}; 723 var theme = settings.theme || {}; 724 725 // The screen's own copy of what it is showing. Every control writes here 726 // when its save succeeds, so a pane repaint draws the truth without 727 // re-fetching â and without ctx.reload(), which would tear the preview 728 // frame down and put four seconds of Chrome between a coach and a tick box. 729 var S = { 730 on: course.certificates_enabled !== false, 731 style: course.certificate_style === 'branded' ? 'branded' : 'plain', 732 months: Number(course.certificate_validity_months) || 0, 733 // Default-true, matching the server's merge â an older community whose 734 // settings blob predates D19 has no key here and must read as ON. 735 email: cert.email_on_completion !== false, 736 cert: cert, 737 theme: theme, 738 }; 739 if (!CERT_PANES.some(function (p) { return p === _certPane; }
739)) _certPane = 'on'; 740 741 // A coach who has set a term we don't offer as a preset (via the API, or a 742 // preset we later drop) must still see their real value, not be silently 743 // reset to "never". Show it rather than lose it. 744 function choices() { 745 var list = CERT_VALIDITY_CHOICES.slice(); 746 if (S.months && !list.some(function (c) { return c.m === S.months; })) { 747 list.push({ m: S.months, label: S.months + ' months from the day they finish' }); 748 } 749 return list; 750 } 751 function validityLabel() { 752 var hit = choices().filter(function (c) { return c.m === S.months; })[0]; 753 return hit ? hit.label.replace(' from the day they finish', '') : 'Never expires'; 754 } 755 function styleLine() { 756 return (S.style === 'branded' ? 'Branded' : 'Plain') + ' · a preview for this course'; 757 } 758 759 // The most recent issued certificate is the live proof. A coach can click 760 // it and land exactly where an employer lands. 761 var sample = issued.filter(function (c) { return !c.pending && c.verify_url; })[0] || null; 762 763 el.innerHTML = 764 // â ï¸ `pho` opts this subtree into css/photo-stage.css and `lk-pickers` 765 // into css/look-pickers.css. Forgetting either leaves the markup correct 766 // and completely unstyled â the same two classes _renderLook adds. 767 '<div class="pho lk-pickers">' + 768 '<div class="pho-stage pho-stage3">' + 769 // `pho-pages-all`: six short rows, so every one shows when the columns 770 // stack on a phone (the shared cap clipped two of them, sweep R4). 771 '<aside class="pho-pages pho-pages-all" data-ct-panes></aside>' + 772 '<section class="pho-main">' + 773 '<div class="pho-ctx">' + 774 // `stack`: the name over its line, so the button beside them keeps 775 // its width (it wrapped onto four lines in a narrow column). 776 '<div class="where stack"><b>The certificate</b>' + 777 '<span class="s" data-ct-sub>' + P.esc(styleLine()) + '</span></div>' + 778 '<div class="ctx-right">' + 779 // The escape hatch for any browser that will not render a PDF 780 // inline â today's window.open preview, kept verbatim. 781 '<button type="button" class="btn btn-secondary" data-ct-open>ð Open in a new tab</button>' + 782 '</div>' + 783 '</div>' + 784 /* §CERT-PREVIEW.2 (2026-08-06) â `pho-cert` shapes this box to the 785 certificate itself. Alex: *"the certificate is a landscape image 786 so you need to make the preview match that."* The PDF is drawn 787 at 1122Ã793 (course-certificate-service _htmlToPdf), so the CSS 788 carries that exact ratio rather than a guessed one. */ 789 '<div class="pho-canvas"><div class="pho-page pho-cert" data-ct-preview-mount></div></div>' + 790 '</section>' + 791 '<aside class="pho-rail" data-ct-rail></aside>' + 792 '</div>' + 793 794 // ââ Below the stage: the RECORD, full width ââââââââââââââââââââ 795 // Records, not controls â and a twenty-row list does not belong in a 796 // 330px rail. 797 '<div class="panel cpg-panel" style="margin-top:18px">' + 798 '<div class="panel-head">' + 799 '<div class="cpg-section-title">Anyone can check these are real</div>' + 800 '<div class="cpg-section-sub">Most course platforms hand out a PDF and a shareable link. Yours are verifiable.</div>' + 801 '</div>' + 802 '<div class="panel-body">' + 803 '<div class="cpg-verify-note">' + 804 '<p>Every certificate you issue carries a <b>QR code</b> and its own <b>certificate number</b>, ' + 805 'both pointing at a public verification page. A hiring manager can scan it from a printed CV, ' + 806 'or click it from a LinkedIn profile, and see the memberâs name, your course and the date. ' + 807 'They need no account and never have to contact you, and nothing on that page can be edited ' + 808 'after the fact.</p>' + 809 '<p>The page keeps answering for good. It does not depend on the course still being published, ' +
810 'or on the member still being in your community.</p>' + 811 '</div>' + 812 (sample 813 ? '<div class="cpg-verify-live">' + 814 '<div class="cpg-item-meta">See exactly what they see. This is a real certificate you have issued:</div>' + 815 '<code class="cpg-code" data-ct-verify-url>' + P.esc(sample.verify_url) + '</code>' + 816 '<div class="cpg-item-actions">' + 817 '<a class="btn btn-secondary" href="' + P.esc(sample.verify_url) + '" target="_blank" rel="noopener">Open the verification page</a>' + 818 '<button class="btn btn-secondary" type="button" data-ct-copy="' + P.esc(sample.verify_url) + '">Copy link</button>' + 819 '</div>' + 820 '</div>' 821 /* Round 6: this said "The first certificate you issue will appear 822 here" right above "2 earned for this course so far". Earned is 823 not yet made: a finisher's certificate is made the first time 824 they open it, so say THAT when people have earned one. */ 825 : (issued.length 826 ? '<div class="cpg-item-meta" style="margin-top:12px" data-ct-verify-wait>' + 827 (issued.length === 1 ? 'One student has' : issued.length + ' students have') + 828 ' earned a certificate. Each one is made the first time that student opens it, ' + 829 'and its live verification link then appears here.</div>' 830 : '<div class="cpg-item-meta" style="margin-top:12px" data-ct-verify-wait>The first certificate you issue will appear here with its live verification link.</div>')) + 831 '</div>' + 832 '</div>' + 833 834 '<div class="panel cpg-panel">' + 835 '<div class="panel-head">' + 836 '<div class="cpg-section-title">Issued</div>' + 837 '<div class="cpg-section-sub">' + issued.length + ' earned for this course so far.</div>' + 838 '</div>' + 839 '<div class="panel-body">' + 840 (issued.length 841 ? '<div class="cpg-list">' + issued.slice(0, 20).map(_issuedRowHtml).join('') + '</div>' + 842 (issued.length > 20 ? '<div class="cpg-section-sub" style="margin-top:10px">Showing the 20 most recent.</div>' : '') 843 : '<div class="cpg-section-sub">None yet: the first one appears the moment somebody finishes.</div>') + 844 '</div>' + 845 '</div>' + 846 '</div>'; 847 848 var subEl = el.querySelector('[data-ct-sub]'); 849 var railHost = el.querySelector('[data-ct-rail]'); 850 var panesHost = el.querySelector('[data-ct-panes]'); 851 852 // ââ The middle. Mounted ONCE, and never rebuilt by a pane change ââ 853 var preview = _certPreviewMount(el.querySelector('[data-ct-preview-mount]'), { 854 courseId: ctx.courseId, 855 style: function () { return S.style; }, 856 styleLine: styleLine, 857 onSubline: function (t) { if (subEl) subEl.textContent = t; }, 858 }); 859 preview.first(); 860 861 el.querySelector('[data-ct-open]').addEventListener('click', function () { 862 var t = _certToken(); 863 window.open('/api/community-hosting/courses/' + ctx.courseId + 864 '/certificate-preview.pdf?style=' + S.style + (t ? '&token=' + encodeURIComponent(t) : ''), '_blank'); 865 }); 866 867 // ââ The left list: two groups, each under its own header ââââââââââ 868 // `.pho-pages` is a flex column, so header/list/header/list stacks with no 869 // new CSS. The split is visible before anything is clicked â the first of 870 // the four things that stop a coach rebranding every course by accident. 871 function panesHtml() { 872 function group(label, rows) { 873 return '<div class="pho-pagesh"><span class="l">' + P.esc(label) + '</span></div>' + 874 '<div class="pho-pageslist">' + rows.map(function (r) { 875 return '<button type="button" class="ccw-jump' + (_certPane === r[0] ? ' on' : '') + 876 '" data-ct-jump="' + r[0] + '"' + (_certPane === r[0] ? ' aria-current="true"' : '') + '>' + 877 '<span class="t">' + P.esc(r[1]) + '</span>' + 878 '<span class="v">' + P.esc(r[2]) + '</span></button>'; 879 }).join('') + '</div>'; 880 } 881 var shown = CERT_ELEMENTS.filter(function (e) { return (S.cert.elements || {})[e.key] !== false; }).length; 882 return group('This course', [ 883 ['on', 'Certificate', S.on ? 'On' : 'Off'], 884 ['style', 'Style', S.style === 'branded' ? 'Branded' : 'Plain'], 885 ['validity', 'How long itâs valid', validityLabel()], 886 ]) + group('Every course · one brand, shared', [ 887 ['brand', 'Your brand', S.cert.logo_url ? 'Logo set' : 'No logo yet'], 888 ['prints', 'What prints', shown + ' of ' + CERT_ELEMENTS.length + ' showing'], 889 ['email', 'Emailing', S.email ? 'On' : 'Off'], 890 ]); 891 } 892 893 /* One card, and `return ''` is the mechanic â copied from the Look step's 894 own `card()`. Hiding five cards with CSS would look the same and behave 895 differently: the coach would tab into controls they cannot see. */ 896 function certCard(id, n, title, hint, bodyHtml) { 897 if (_certPane !== id) return ''; 898 return '<section class="lk-card"><div class="card-h">' + 899 '<h3><span class="n">' + n + '</span> ' + title + '</h3>' + 900 '<div class="hint">' + hint + '</div>' + 901 '</div><div class="card-b">' + bodyHtml + '</div></section>'; 902 } 903 904 function imageFieldHtml(kind, label, curUrl) { 905 var thumb = curUrl 906 ? '<img src="' + P.esc(curUrl) + '" alt="" style="width:72px;height:52px;object-fit:contain;background:#fff;border:1px solid var(--border);border-radius:8px;display:block">' 907 : '<div style="width:72px;height:52px;border-radius:8px;border:1px dashed var(--border-strong);display:flex;align-items:center;justify-content:center;color:var(--text-3);font-size:11px">None</div>'; 908 return '<div class="field" style="margin-bottom:18px">' + 909 '<label>' + P.esc(label) + '</label>' + 910 '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' + 911 '<div data-ct-thumb="' + kind + '">' + thumb + '</div>' + 912 '<button class="btn btn-secondary" type="button" data-ct-pick="' + kind + '">Choose / upload</button>' + 913 '<button class="btn btn-secondary" type="button" data-ct-clear="' + kind + '"' + (curUrl ? '' : ' style="display:none"') + '>Remove</button>' + 914 '</div>' + 915 '</div>'; 916 } 917 918 /* â ï¸ A refusal WITH THE DOOR ATTACHED â the same pattern as the Look step's
919 `_noDesignMsg`. Most brand fields only reach a BRANDED certificate 920 (`_certificateHtml` in course-certificate-service), so on a plain course 921 this whole card can be edited to no visible effect â and a preview that 922 does not move reads as a broken preview. Say so, and offer the switch. */ 923 function plainNoteHtml() { 924 if (S.style === 'branded') return ''; 925 return '<div class="ccw-note" style="margin-bottom:12px"><div class="d">' + 926 'This course is set to <b>Plain</b>, so nothing here shows on its certificate. Switch it to ' + 927 '<button type="button" class="btn btn-secondary cpg-btn-inline" data-ct-go="style">Branded</button> ' + 928 'to see these. (The fine print prints on both.)</div></div>'; 929 } 930 931 function railHtml() { 932 var accentPlaceholder = S.theme.accent_colour || '#6366f1'; 933 var els = S.cert.elements || {}; 934 return '<div class="lk-rail">' + 935 certCard('on', 'A', 'Certificate', 936 'This course only. Issued automatically the moment a member finishes every lesson.', 937 '<label class="cpg-check"><input type="checkbox" data-ct-on' + (S.on ? ' checked' : '') + '> ' + 938 'Give members a certificate when they finish</label>') + 939 940 certCard('style', 'B', 'Style', 941 'This course only. Branding is shared. See <b>Your brand</b>.', 942 '<div class="ccw-opts">' + 943 /* §CERT-SIGNATURE.2 â DIVs, not spans. The wizard's own option 944 cards (`_glMountCert`) use block <div class="t">/<div class="d"> 945 and read as label-over-description; these were <span>s, which 946 are inline, so "Plain" and "Clean and formalâ¦" rendered as ONE 947 run-together line â his screenshot. Same markup as the wizard 948 now, and the shared stylesheet needs no change. */ 949 '<button type="button" class="ccw-opt' + (S.style === 'plain' ? ' on' : '') + '" data-ct-style="plain">' + 950 '<div class="t">Plain</div><div class="d">Clean and formal. Nothing to set up.</div></button>' + 951 '<button type="button" class="ccw-opt' + (S.style === 'branded' ? ' on' : '') + '" data-ct-style="branded">' + 952 '<div class="t">Branded</div><div class="d">The same, with your logo, colour and signature.</div></button>' + 953 '</div>') + 954 955 certCard('validity', 'C', 'How long itâs valid', 956 'This course only. Counted from each memberâs own completion date.', 957 '<select class="select" data-ct-validity>' + 958 choices().map(function (c) { 959 return '<option value="' + c.m + '"' + (c.m === S.months ? ' selected' : '') + '>' + P.esc(c.label) + '</option>'; 960 }).join('') + 961 '</select>' + 962 '<div class="hint">Changing it affects certificates issued from now on. Anything already ' + 963 'issued keeps the term it was awarded under.</div>') + 964 965 certCard('brand', 'D', 'Your brand', 966 'Every course in your community: one brand, set once. Changing it changes certificates you have already issued.', 967 plainNoteHtml() + 968 imageFieldHtml('logo', 'Logo', S.cert.logo_url) + 969 '<div class="field" style="margin-bottom:18px"><label>Accent colour</label>' + 970 '<div class="color-row"><span class="color-preview" data-ct-accent-preview style="background:' + 971 P.esc(S.cert.accent_colour || accentPlaceholder) + '"></span>' + 972 '<input class="input color-hex-input" type="text" data-ct-accent placeholder="' + 973 P.esc(accentPlaceholder) + '" value="' + P.esc(S.cert.accent_colour || '') + '"></div>' + 974 '<div class="hint">Leave blank to use your community themeâs accent colour.</div></div>' + 975 imageFieldHtml('signature', 'Signature image', S.cert.signature_image_url) + 976 /* §CERT-SIGNATURE.1 â Alex: *"If you can't upload a file⦠You can 977 either draw a signature on the screen with your mouse or you can 978 just get some nice sort of handwriting text that they type in and 979 it turns it into handwriting text."* Both produce a PNG through 980 the SAME store the upload uses (posts/media data_url â 981 signature_image_url) â three doors, one field. */ 982 '<div class="field" style="margin-top:-10px;margin-bottom:18px">' + 983 '<div style="display:flex;gap:8px;flex-wrap:wrap">' + 984 '<button class="btn btn-secondary cpg-btn-tight" type="button" data-ct-sig-draw>âï¸ Draw it</button>' + 985 '<button class="btn btn-secondary cpg-btn-tight" type="button" data-ct-sig-type>â¨ï¸ Type it</button>' + 986 '</div>' + 987 '<div data-ct-sig-box hidden style="margin-top:10px"></div>' + 988 '</div>' + 989 '<div class="field"><label>Signature name</label>' + 990 '<input class="input" data-ct-field="signature_name" value="' + P.esc(S.cert.signature_name || '') + '" placeholder="e.g. Alex Braganza"></div>' + 991 '<div class="field"><label>Signature title</label>' + 992 '<input class="input" data-ct-field="signature_title" value="' + P.esc(S.cert.signature_title || '') + '" placeholder="e.g. Founder, Acme Coaching"></div>' + 993 '<div class="field"><label>Custom congratulation line <span class="hint">(optional)</span></label>' + 994 '<textarea class="textarea" data-ct-field="custom_message" maxlength="300" placeholder="A personal note that appears on every branded certificate.">' + 995 P.esc(S.cert.custom_message || '') + '</textarea></div>' + 996 '<div class="field"><label>
996Fine print <span class="hint">(optional)</span></label>' + 997 '<textarea class="textarea" data-ct-field="fine_print" maxlength="200" placeholder="e.g. This certificate confirms course completion and is not a professional licence.">' + 998 P.esc(S.cert.fine_print || '') + '</textarea>' + 999 '<div class="hint">The smallest line on the certificate, under the issuer line. It prints on ' + 1000 'plain certificates too.</div></div>') + 1001 1002 certCard('prints', 'E', 'What prints', 1003 'Every course. These apply retrospectively: the certificate is drawn fresh every time it is opened.', 1004 '<div class="form-stack">' + CERT_ELEMENTS.map(function (e) { 1005 return '<div class="field cert-el-row">' + 1006 '<label class="cert-el-label"><input type="checkbox" data-ct-el="' + P.esc(e.key) + '"' + 1007 (els[e.key] !== false ? ' checked' : '') + '> <span>' + P.esc(e.label) + '</span></label>' + 1008 '<div class="hint">' + P.esc(e.hint) + '</div></div>'; 1009 }).join('') + '</div>') + 1010 1011 certCard('email', 'F', 'Emailing', 1012 'Every course in your community. Delivery, not design. It does not change the certificate.', 1013 '<label class="cpg-check"><input type="checkbox" data-ct-email' + (S.email ? ' checked' : '') + '> ' + 1014 'Email members their certificate when they finish</label>' + 1015 '<div class="hint">The email links to the certificate rather than attaching it, so it always shows ' + 1016 'your current branding and carries the verification link. Members who have unsubscribed from ' + 1017 'your emails are never sent one.</div>') + 1018 1019 '<p class="cpg-msg" data-ct-msg style="display:none"></p>' + 1020 '</div>'; 1021 } 1022 1023 function paintPanes() { panesHost.innerHTML = panesHtml(); wirePanes(); } 1024 1025 function paintRail() { 1026 railHost.innerHTML = railHtml(); 1027 // Dictation attaches itself after every repaint â VoiceInput.attach is 1028 // idempotent per element, so a sweep cannot double-attach. 1029 _speakIt(railHost); 1030 wireRail(); 1031 } 1032 1033 /* The pane switch â a copy of the Look step's `_goLookPane`, with its three 1034 behaviours: set the pane, rebuild the RAIL only, re-sync the chips. It 1035 does not touch `.pho-main`, which is the whole point: changing what you 1036 are controlling must never throw away the four seconds of Chrome that 1037 drew the certificate you are looking at. */ 1038 function goCertPane(to) { 1039 if (!to || to === _certPane) return; 1040 if (!CERT_PANES.some(function (p) { return p === to; })) return; 1041 _certPane = to; 1042 paintRail(); 1043 Array.prototype.forEach.call(el.querySelectorAll('[data-ct-jump]'), function (b) { 1044 var on = b.getAttribute('data-ct-jump') === _certPane; 1045 b.classList.toggle('on', on); 1046 if (on) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current'); 1047 }); 1048 } 1049 1050 function msgEl() { return el.querySelector('[data-ct-msg]'); } 1051 // â ï¸ THE CONFIRMATION NAMES THE SCOPE. This is the one of the four scope 1052 // signals that catches the coach at the exact moment of the mistake, and it 1053 // is the one that must not be dropped for brevity. 1054 function savedThisCourse() { P.setMsg(msgEl(), 'â Saved for this course.', 'good'); } 1055 function savedEveryCourse() { P.setMsg(msgEl(), 'â Saved for every course in your community.', 'good'); } 1056 /* â ï¸ THE ONE PLACE THIS SCREEN TELLS A COACH SOMETHING FAILED â so it is 1057 also the one place that must tell US. Nine call sites funnel through 1058 here, which is why the report goes in the choke point rather than in 1059 nine catch blocks that would drift apart within a month. 1060 1061 ð¨ Nicola, 2026-08-18, setting up Deborah Wilder's course: *"Couldn't 1062 save photo: HTTP 404 ⦠community_not_setup."* The coach saw it, the 1063 screenshot reached Alex, and NOTHING WAS RECORDED â a caught error shown 1064 as a message is invisible to a tracker that only watches uncaught ones. 1065 We learned of it because a human happened to be watching over her 1066 shoulder. 1067 1068 The message shown is unchanged. This only adds the telling. */ 1069 function saveFailed(err) { 1070 P.setMsg(msgEl(), 'Couldnât save: ' + ((err && err.code) || 'unknown'), 'bad');
1071 try { 1072 window.cbReportHandled && window.cbReportHandled( 1073 'course look save failed: ' + ((err && (err.code || err.message)) || 'unknown'), 1074 { action: 'course.look.save', status: err && err.status, error: err }); 1075 } catch (_) { /* never let reporting break the screen it reports on */ } 1076 } 1077 1078 async function patchCourse(body) { 1079 await P.api('/community-hosting/courses/' + ctx.courseId, { method: 'PATCH', body: body }); 1080 } 1081 async function patchCommunity(certPatch) { 1082 // The server merges key-by-key, so a one-field patch never blanks the 1083 // others â and a `{certificates:â¦}` body does NOT re-bake the published 1084 // community shell (community-hosting-routes only regenerates for 1085 // branding | theme | welcome | gamification | vault). 1086 await P.api('/community-hosting/settings', { method: 'PATCH', body: { certificates: certPatch } }); 1087 S.cert = Object.assign({}, S.cert, certPatch); 1088 } 1089 1090 function wirePanes() { 1091 Array.prototype.forEach.call(panesHost.querySelectorAll('[data-ct-jump]'), function (b) { 1092 b.addEventListener('click', function () { goCertPane(b.getAttribute('data-ct-jump')); }); 1093 }); 1094 } 1095 1096 function wireRail() { 1097 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-go]'), function (b) { 1098 b.addEventListener('click', function () { goCertPane(b.getAttribute('data-ct-go')); }); 1099 }); 1100 1101 // A â on/off. NOT a redraw: this decides whether a certificate is ever 1102 // issued, and it is not drawn on the certificate. 1103 var onChk = railHost.querySelector('[data-ct-on]'); 1104 if (onChk) onChk.addEventListener('change', async function () { 1105 var want = !!onChk.checked; 1106 P.setMsg(msgEl(), 'Savingâ¦'); 1107 try { 1108 await patchCourse({ certificates_enabled: want }); 1109 S.on = want; savedThisCourse(); paintPanes(); 1110 } catch (e) { onChk.checked = !want; saveFailed(e); } 1111 }); 1112 1113 // B â style. Two cards, not a select (U11.2): these ARE the old 1114 // "Preview plain" / "Preview branded" buttons, now that the preview is 1115 // on the page. 1116 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-style]'), function (b) { 1117 b.addEventListener('click', async function () { 1118 var want = b.getAttribute('data-ct-style'); 1119 if (want === S.style) return; 1120 var was = S.style; 1121 P.setMsg(msgEl(), 'Savingâ¦'); 1122 try { 1123 await patchCourse({ certificate_style: want }); 1124 /* â ï¸ REPAINT FIRST, THEN SAY IT. `[data-ct-msg]` lives IN the rail, 1125 so setting the message before rebuilding the rail throws the 1126 confirmation away â and the confirmation is the one of the four 1127 scope signals that catches the coach at the moment of the 1128 mistake. Caught by tests/course-certificates-layout.test.js. */ 1129 S.style = want; paintPanes(); paintRail(); savedThisCourse(); 1130 preview.refresh(); 1131 } catch (e) { S.style = was; saveFailed(e); } 1132 }); 1133 }); 1134 1135 // C â validity. It derives `expires_at`, so it changes the PDF. 1136 var val = railHost.querySelector('[data-ct-validity]'); 1137 if (val) val.addEventListener('change', async function () { 1138 var want = parseInt(val.value, 10) || 0; 1139 var was = S.months; 1140 P.setMsg(msgEl(), 'Savingâ¦'); 1141 try { 1142 await patchCourse({ certificate_validity_months: want }); 1143 S.months = want; savedThisCourse(); paintPanes(); 1144 preview.refresh(); 1145 } catch (e) { S.months = was; val.value = String(was); saveFailed(e); } 1146 }); 1147 1148 // D â the brand. Text on blur, colour on change; never on keystroke, 1149 // because every save asks for a Chrome process. 1150 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-field]'), function (f) { 1151 f.addEventListener('blur', async function () { 1152 var key = f.getAttribute('data-ct-field'); 1153 var want = f.value.trim() || null; 1154 if ((S.cert[key] || null) === want) return; 1155 P.setMsg(msgEl(), 'Savingâ¦'); 1156 var patch = {}; patch[key] = want; 1157 try { await patchCommunity(patch); savedEveryCourse(); preview.refresh(); } 1158 catch (e) { saveFailed(e); } 1159 }); 1160 }); 1161 var accent = railHost.querySelector('[data-ct-accent]'); 1162 if (accent) accent.addEventListener('change', async function () { 1163 var want = accent.value.trim() || null; 1164 if ((S.cert.accent_colour || null) === want) return; 1165 P.setMsg(msgEl(), 'Savingâ¦'); 1166 try { 1167 await patchCommunity({ accent_colour: want }); 1168 var sw = railHost.querySelector('[data-ct-accent-preview]'); 1169 if (sw) sw.style.background = want || (S.theme.accent_colour || '#6366f1'); 1170 savedEveryCourse(); preview.refresh(); 1171 } catch (e) {
1172 P.setMsg(msgEl(), (e && e.code === 'invalid_colour') 1173 ? 'That doesnât look like a colour. Use a hex code like #6366f1.' 1174 : 'Couldnât save. Please try again.', 'bad'); 1175 } 1176 }); 1177 1178 // Image picking keeps its existing path exactly: the community media 1179 // rail, then a one-field settings patch. 1180 async function saveImage(kind, pickedUrl) { 1181 P.setMsg(msgEl(), pickedUrl ? 'Uploadingâ¦' : 'Removingâ¦'); 1182 try { 1183 var finalUrl = ''; 1184 if (pickedUrl) { 1185 var ing = await P.api('/community-hosting/posts/media', { method: 'POST', body: { source_url: pickedUrl } }); 1186 finalUrl = (ing && ing.url) || ''; 1187 if (!finalUrl) throw new Error('upload_failed'); 1188 } 1189 var field = kind === 'logo' ? 'logo_url' : 'signature_image_url'; 1190 var patch = {}; patch[field] = finalUrl || null; 1191 await patchCommunity(patch); 1192 // Repaint before the confirmation â `[data-ct-msg]` is in the rail. 1193 paintPanes(); paintRail(); savedEveryCourse(); 1194 preview.refresh(); 1195 } catch (e) { saveFailed(e); } 1196 } 1197 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-pick]'), function (b) { 1198 b.addEventListener('click', function () { 1199 var kind = b.getAttribute('data-ct-pick'); 1200 if (typeof window.openMediaLibrary !== 'function') { 1201 P.setMsg(msgEl(), 'Media library isnât loaded. Refresh the page and retry.', 'bad'); 1202 return; 1203 } 1204 window.openMediaLibrary({ type: 'asset-callback', callback: function (url) { if (url) saveImage(kind, url); } }); 1205 }); 1206 }); 1207 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-clear]'), function (b) { 1208 b.addEventListener('click', function () { saveImage(b.getAttribute('data-ct-clear'), ''); }); 1209 }); 1210 1211 /* ââ §CERT-SIGNATURE.1 â draw it, or type it ââââââââââââââââââââââ 1212 Both end in the SAME place the upload ends: a PNG in the media store 1213 (posts/media accepts a data_url â measured, not assumed) and its URL 1214 in signature_image_url. The canvas is transparent, so the signature 1215 sits on the certificate's paper rather than in a white box. */ 1216 function _sigBox() { return railHost.querySelector('[data-ct-sig-box]'); } 1217 1218 async function saveSignatureDataUrl(dataUrl, btn) { 1219 if (btn) btn.disabled = true; 1220 P.setMsg(msgEl(), 'Saving your signatureâ¦'); 1221 try { 1222 var ing = await P.api('/community-hosting/posts/media', { 1223 method: 'POST', body: { data_url: dataUrl }, 1224 }); 1225 var url = (ing && ing.url) || ''; 1226 if (!url) throw new Error('upload_failed'); 1227 await patchCommunity({ signature_image_url: url }); 1228 paintPanes(); paintRail(); savedEveryCourse(); 1229 preview.refresh(); 1230 } catch (e) { if (btn) btn.disabled = false; saveFailed(e); } 1231 } 1232 1233 /* One handwriting face, loaded on demand. Rasterised into the PNG, so 1234 nothing downstream (the PDF's headless Chrome included) ever needs 1235 the font â the image is the signature. */ 1236 function _sigFontReady() { 1237 if (!document.getElementById('cert-sig-font')) { 1238 var l = document.createElement('link'); 1239 l.id = 'cert-sig-font'; l.rel = 'stylesheet'; 1240 l.href = 'https://fonts.googleapis.com/css2?family=Caveat:wght@600&display=swap'; 1241 document.head.appendChild(l); 1242 } 1243 try { return document.fonts.load('600 72px Caveat').then(function () {}); } 1244 catch (_) { return Promise.resolve(); } 1245 } 1246 1247 function _sigCanvas() { 1248 var cv = document.createElement('canvas'); 1249 cv.width = 1200; cv.height = 360; // 2à for a crisp print 1250 cv.style.width = '100%'; cv.style.maxWidth = '600px'; cv.style.height = 'auto'; 1251 cv.style.border = '1px dashed var(--border-strong)'; cv.style.borderRadius = '10px'; 1252 cv.style.background = 'repeating-linear-gradient(0deg, transparent, transparent 8px, transparent 8px)';
1253 cv.style.touchAction = 'none'; 1254 return cv; 1255 } 1256 1257 function _openDraw() { 1258 var box = _sigBox(); if (!box) return; 1259 box.hidden = false; 1260 box.innerHTML = '<div class="hint" style="margin-bottom:6px">Sign in the box: mouse or finger.</div>'; 1261 var cv = _sigCanvas(); 1262 box.appendChild(cv); 1263 var g = cv.getContext('2d'); 1264 g.lineWidth = 7; g.lineCap = 'round'; g.lineJoin = 'round'; g.strokeStyle = '#1a1a2e'; 1265 var drawing = false, drew = false; 1266 function pt(ev) { 1267 var r = cv.getBoundingClientRect(); 1268 return { x: (ev.clientX - r.left) * (cv.width / r.width), y: (ev.clientY - r.top) * (cv.height / r.height) }; 1269 } 1270 cv.addEventListener('pointerdown', function (ev) { 1271 drawing = true; drew = true; cv.setPointerCapture(ev.pointerId); 1272 var p = pt(ev); g.beginPath(); g.moveTo(p.x, p.y); g.lineTo(p.x + 0.1, p.y + 0.1); g.stroke(); 1273 }); 1274 cv.addEventListener('pointermove', function (ev) { 1275 if (!drawing) return; 1276 var p = pt(ev); g.lineTo(p.x, p.y); g.stroke(); 1277 }); 1278 cv.addEventListener('pointerup', function () { drawing = false; g.beginPath(); }); 1279 var row = document.createElement('div'); 1280 row.className = 'cpg-item-actions'; row.style.marginTop = '8px'; 1281 row.innerHTML = 1282 '<button class="btn btn-primary cpg-btn-tight" type="button" data-sig-use>Use this signature</button>' + 1283 '<button class="btn btn-secondary cpg-btn-tight" type="button" data-sig-clear>Clear</button>' + 1284 '<button class="btn btn-secondary cpg-btn-tight" type="button" data-sig-cancel>Cancel</button>'; 1285 box.appendChild(row); 1286 row.querySelector('[data-sig-clear]').addEventListener('click', function () { 1287 g.clearRect(0, 0, cv.width, cv.height); drew = false; 1288 }); 1289 row.querySelector('[data-sig-cancel]').addEventListener('click', function () { box.hidden = true; box.innerHTML = ''; }); 1290 row.querySelector('[data-sig-use]').addEventListener('click', function () { 1291 if (!drew) { P.setMsg(msgEl(), 'Nothing drawn yet. Sign in the box first.', 'bad'); return; } 1292 saveSignatureDataUrl(cv.toDataURL('image/png'), this); 1293 }); 1294 } 1295 1296 function _openType() { 1297 var box = _sigBox(); if (!box) return; 1298 box.hidden = false; 1299 box.innerHTML = 1300 '<div class="hint" style="margin-bottom:6px">Type your name and it becomes handwriting.</div>' + 1301 '<input class="input" data-sig-text maxlength="60" placeholder="' + 1302 P.esc(S.cert.signature_name || 'Your name') + '" style="max-width:340px">'; 1303 var cv = _sigCanvas(); 1304 cv.style.marginTop = '8px'; 1305 box.appendChild(cv); 1306 var g = cv.getContext('2d'); 1307 var input = box.querySelector('[data-sig-text]'); 1308 function paintText() { 1309 g.clearRect(0, 0, cv.width, cv.height); 1310 var text = (input.value || '').trim(); 1311 if (!text) return; 1312 g.fillStyle = '#1a1a2e'; g.textAlign = 'center'; g.textBaseline = 'middle'; 1313 // Fit: shrink from 150px until it fits with margin. 1314 var size = 150; 1315 do { g.font = '600 ' + size + 'px Caveat, cursive'; size -= 8; } 1316 while (g.measureText(text).width > cv.width - 120 && size > 40); 1317 g.fillText(text, cv.width / 2, cv.height / 2); 1318 } 1319 _sigFontReady().then(paintText); 1320 input.addEventListener('input', paintText); 1321 var row = document.createElement('div'); 1322 row.className = 'cpg-item-actions'; row.style.marginTop = '8px'; 1323 row.innerHTML = 1324 '<button class="btn btn-primary cpg-btn-tight" type="button" data-sig-use>Use this signature</button>' + 1325 '<button class="btn btn-secondary cpg-btn-tight" type="button" data-sig-cancel>Cancel</button>'; 1326 box.appendChild(row); 1327 row.querySelector('[data-sig-cancel]').addEventListener('click', function () { box.hidden = true; box.innerHTML = ''; }); 1328 row.querySelector('[data-sig-use]').addEventListener('click', function () { 1329 if (!(input.value || '').trim()) { P.setMsg(msgEl(), 'Type your name first.', 'bad'); return; } 1330 saveSignatureDataUrl(cv.toDataURL('image/png'), this); 1331 }); 1332 input.focus(); 1333 } 1334 1335 var sigDraw = railHost.querySelector('[data-ct-sig-draw]'); 1336 if (sigDraw) sigDraw.addEventListener('click', _openDraw); 1337 var sigType = railHost.querySelector('[data-ct-sig-type]'); 1338 if (sigType) sigType.addEventListener('click', _openType); 1339 1340 // E â what prints. `show` in _certificateHtml, so every one redraws. 1341 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-el]'), function (box) { 1342 box.addEventListener('change', async function () { 1343 var want = !!box.checked; 1344 /* â ï¸ BUILT FROM ALL SIX BOXES, NOT FROM THE ONE THAT MOVED. Five taps 1345 land as five change events before the first PATCH has resolved, so 1346 a patch merged against `S.cert.elements` carries the state from 1347 before the burst â and the last one to arrive wins, silently 1348 undoing the other four. Reading the boxes is reading the truth the 1349 coach is looking at. Same shape as the Look step's footer switches. 1350 Caught by tests/course-certificates-layout.test.js. */ 1351 var els = {}; 1352 Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-el]'), function (x) { 1353 els[x.getAttribute('data-ct-el')] = !!x.checked; 1354 }); 1355 P.setMsg(msgEl(), 'Savingâ¦'); 1356 try { 1357 await patchCommunity({ elements: Object.assign({}, S.cert.elements, els) }); 1358 savedEveryCourse(); paintPanes(); 1359 preview.refresh(); 1360 } catch (e) { box.checked = !want; saveFailed(e); } 1361 }); 1362 }); 1363 1364 // F â emailing. Delivery, not design: no redraw. 1365 var emailChk = railHost.querySelector('[data-ct-email]'); 1366 if (emailChk) emailChk.addEventListener('change', async function () { 1367 var want = !!emailChk.checked; 1368 P.setMsg(msgEl(), 'Savingâ¦'); 1369 try { 1370 await patchCommunity({ email_on_completion: want }); 1371 S.email = want; savedEveryCourse(); paintPanes(); 1372 } catch (e) { emailChk.checked = !want; saveFailed(e); } 1373 }); 1374 } 1375 1376 paintPanes(); 1377 paintRail(); 1378 1379 Array.prototype.forEach.call(el.querySelectorAll('[data-ct-copy]'), function (b) { 1380 b.addEventListener('click', function () { 1381 var url = b.getAttribute('data-ct-copy'); 1382 if (navigator.clipboard && navigator.clipboard.writeText) { 1383 navigator.clipboard.writeText(url).then(function () { P.toast('â Verification link copied'); }, 1384 function () { P.toast('Couldnât copy. Select the link above instead'); }); 1385 } else { 1386 P.toast('Couldnât copy. Select the link above instead'); 1387 } 1388 }); 1389 }); 1390 1391 // Handed back for a caller that wants to tear the mount down explicitly. 1392 // `_manageOpen` does not await this, so the mount ALSO defends itself: a 1393 // response that lands after the panel has been replaced is dropped by the 1394 // `isConnected` check in show(), rather than painting into a detached frame. 1395 return { destroy: function () { preview.destroy(); } }; 1396 } 1397 1398 // One issued certificate. The verification link is on the ROW, not just 1399 // in the panel above, because the coach's real use for it is "send this 1400 // person's proof to someone" â one certificate at a time. 1401 function _issuedRowHtml(c) {
1402 /* Finished, certificate not opened yet (the one list in 1403 course-certificate-service): theirs, made the first time they open it. */ 1404 if (c.pending) { 1405 return '<div class="cpg-item">' + 1406 '<div class="cpg-item-ico">ð</div>' + 1407 '<div class="cpg-item-body">' + 1408 '<div class="cpg-item-title">' + P.esc(c.member_name || 'Member') + '</div>' + 1409 '<div class="cpg-item-meta">Finished' + (c.finished_at ? ' ' + P.esc(P.fmtDate(c.finished_at)) : '') + 1410 '. Their certificate is made the first time they open it.</div>' + 1411 '</div>' + 1412 '</div>'; 1413 } 1414 var expiryBit = ''; 1415 if (c.expires_at) { 1416 var past = new Date(c.expires_at).getTime() <= Date.now(); 1417 expiryBit = ' · ' + (past ? 'expired ' : 'valid until ') + P.esc(P.fmtDate(c.expires_at)); 1418 } 1419 // Whether the email actually went is worth showing: it is the one part 1420 // of this that leaves the platform and can fail. 1421 var emailBit = c.completion_email_sent_at 1422 ? ' · emailed ' + P.esc(P.fmtDate(c.completion_email_sent_at)) 1423 : ''; 1424 // The actions live INSIDE the body, not as a third flex child of 1425 // .cpg-item. `.cpg-item` is a non-wrapping flex row, so a sibling 1426 // action block never drops below on a phone â it just crushes the 1427 // meta text into a two-word column. Nested, it wraps under the title 1428 // exactly as the .cpg-item-actions comment in course-page.css intends. 1429 return '<div class="cpg-item">' + 1430 '<div class="cpg-item-ico">ð</div>' + 1431 '<div class="cpg-item-body">' + 1432 '<div class="cpg-item-title">' + P.esc(c.member_name || 'Member') + '</div>' + 1433 '<div class="cpg-item-meta">' + P.esc(P.fmtDate(c.issued_at)) + ' · ' + 1434 (c.style === 'branded' ? 'Branded' : 'Plain') + expiryBit + emailBit + '</div>' + 1435 '<div class="cpg-item-meta">No. ' + P.esc(c.certificate_no || '') + '</div>' + 1436 '<div class="cpg-item-actions">' + 1437 '<a class="btn btn-secondary" href="' + P.esc(c.verify_url) + '" target="_blank" rel="noopener">Verify</a>' + 1438 '<button class="btn btn-secondary" type="button" data-ct-copy="' + P.esc(c.verify_url) + '">Copy link</button>' + 1439 '</div>' + 1440 '</div>' + 1441 '</div>'; 1442 } 1443 1444 // ââ SETTINGS â DOMAIN + EMAIL, AND NOTHING ELSE ââââââââââââââââââââ 1445 // 1446 // U16.4/U16.5. Alex: *"Really what we should probably have only in the 1447 // settings is: Domain settings · Domain and email settings. The domain 1448 // settings would include the favicon settings and email settings will just 1449 // show what emails are set to go out."* 1450 // 1451 // WHAT LEFT THIS SCREEN, AND WHERE EACH THING WENT. Every row below was 1452 // traced to a second writer before it was cut, because a control deleted 1453 // from the only screen that could set it is a feature deleted 1454 // (SPEC-U16-settings-and-emails.md §2): 1455 // · Cover image URL â GONE. The Look step's cover pane is a real picker 1456 // with a focal point (`PATCH { cover_image_url }`, wizard:3145); this 1457 // was a bare text box that could silently desync the crop. 1458 // · Title + description â the Plan editor's Overview box, reachable from 1459 // Manage â Structure. 1460 // · Access, level, product â â ï¸ CORRECTED BY THE DOOR AUDIT (F2, 1461 // 2026-08-05). This line used to send people to "the Go-live 'Who gets 1462 // in' card" for access changes â but the board card opens the go-live 1463 // SETUP (product, web address, publish), which by design never re-asks 1464 // the access answer; access is only ASKED in the guide's "Who gets in" 1465 // step, and its writer PUBLISHES (publishFromGuide, 1466 // course-creation-service:2652). The card is now labelled "Selling & 1467 // publishing"; a standing access editor reachable from Manage is parked 1468 // as a build. 1469 // · Drip + discussion â kept reachable from the Structure editor. 1470 // · Cohorts button â GONE. Manage already lists "Community & cohorts" and 1471 // "1:1 sessions" as their own rows. A doorway to a doorway. 1472 // · The assistant, its sources, its log, and nudges â the Manage board's 1473 // AI block (`App.CourseAiTab` below), which is now the ONE control panel 1474 // for every AI tool on the course. ð¨ They MOVED, they were not deleted. 1475 // â ï¸ This comment used to say `classroom.tools.tutor` "is read by 1476 // nothing". That is no longer true â course-assistant-service:865 throws 1477 // `assistant_disabled` on it, alongside the `assistant_enabled` column at 1478 // :854, so the Tutor has TWO enforced switches and the gate is their AND. 1479 // The AI panel writes both from one tick; see its header. 1480 // 1481 // ð¨ THE FACT THAT HAS TO BE ON THE SCREEN. `ensureCourseSite` resolves the 1482 // site by `type='course_site' AND settings->>'course_design' = <design>` 1483 // (course-template-bridge:683-740). The site is per DESIGN, not per course, 1484 // so an address or a favicon set here applies to every course on this 1485 // design. The Look step already says exactly this about the footer, so the
1486 // domain panel says it in the same words rather than inventing new ones. 1487 1488 async function renderSettings(el, ctx) { 1489 el.innerHTML = 1490 /* §AUDIT-GAPS.1 â the page says what it is (the Live-sessions grammar, 1491 his "this page needs the same treatment"). */ 1492 '<div class="cs-edu"><span class="ei" aria-hidden="true">ð</span><div>' + 1493 '<b>This is the course site\u2019s plumbing.</b> Its web address, how its emails look, ' + 1494 'and the credit line: each one set once, for every course on this design.' + 1495 '</div></div>' + 1496 '<div class="panel cpg-panel">' + 1497 '<div class="panel-head">' + 1498 '<div class="cpg-section-title">Web address</div>' + 1499 '<div class="cpg-section-sub">Where this course lives on the internet, and the little icon in the browser tab. ' + 1500 'Like the footer, the answer sticks for the whole course <b>site</b> (every course on this design).</div>' + 1501 '</div>' + 1502 '<div class="panel-body"><div data-st-domain>' + 1503 '<div class="cpg-section-sub">Loading your addressâ¦</div>' + 1504 '</div></div>' + 1505 '</div>' + 1506 1507 /* â ï¸ A POINTER, NOT A SECOND MOUNT (door audit F1, 2026-08-05). This panel 1508 used to mount the full CourseEmailsTab inline, while the Manage rail's 1509 own "Emails" row mounted the exact same tool â two differently-labelled 1510 doors to two copies of one screen, and Alex hit it. Settings keeps 1511 NAMING emails (his U16 spec: settings "will just show what emails are 1512 set to go out") but the one full mount now lives behind the rail row, 1513 and this button goes there. 1514 1515 §CORE-SETTINGS (2026-08-06) â named "Email design", his words: *"the 1516 email settings should be called email design or email set. Email 1517 design is more the main thing here."* */ 1518 /* §AUDIT-GAPS.1 â his words: *"I don't like the fact that you have to 1519 click on a button and the email opens up in another page. The email 1520 shouldn't be just an add-on."* So the DESIGN CHOOSER now embeds HERE, 1521 in place. â ï¸ This does NOT reopen door-audit F1: F1's fault was two 1522 full copies of the whole Emails screen behind different labels; this 1523 is the single-purpose design tool with two hosts (the sessions 1524 calendar's own sanctioned pattern), and the full catalogue still 1525 lives only on the Emails screen â the quiet door below goes there. */ 1526 '<div class="panel cpg-panel">' + 1527 '<div class="panel-head">' + 1528 '<div class="cpg-section-title">Email design</div>' + 1529 '<div class="cpg-section-sub">The shell every email from this course renders in: logo, colours, layout. ' + 1530 'Which emails go out lives on the <b>Emails</b> screen: <a href="#" data-st-emails-go>see every email this course sends â</a></div>' + 1531 '</div>' + 1532 '<div class="panel-body"><div data-st-emaildesign>' + 1533 '<div class="cpg-section-sub">Loading the email design toolâ¦</div>' + 1534 '</div></div>' + 1535 '</div>' + 1536 1537 /* â ï¸ "POWERED BY REPLICANTS" LIVES HERE NOW (2026-08-06). Alex, cutting 1538 the footer pane to a single panel: *"The 'powered by replicants' I 1539 think we should put in the settings area not in the footer. In the core 1540 settings area we remove the 'powered by replicants' but we take it off 1541 from here."* 1542 1543 It is the ONE footer switch that survived the cut â the footer itself, 1544 the business name, the address and the terms are all mandatory now 1545 (`footerShow`, services/site-chrome/course-footer.js). It writes 1546 through the SAME route the footer pane used, so there is one store and 1547 one meaning for the answer; only the door moved. 1548 1549 Like everything else on this screen it is per SITE, not per course â 1550 every course on this design shares one footer â and the copy says so 1551 rather than letting a coach discover it. */ 1552 '<div class="panel cpg-panel">' + 1553 '<div class="panel-head">' + 1554 '<div class="cpg-section-title">The credit line</div>' + 1555 '<div class="cpg-section-sub">A quiet <b>Powered by Replicants</b> at the end of your footer. ' + 1556 'Like the web address, the answer sticks for the whole course <b>site</b>.</div>' + 1557 '</div>' + 1558 '<div class="panel-body"><div data-st-powered>' + 1559 '<div class="cpg-section-sub">Loadingâ¦</div>' + 1560 '</div></div>' + 1561 '</div>' + 1562 1563 /* U24.5's dictation-history panel is GONE FROM HERE â §CORE-SETTINGS 1564 (2026-08-06), Alex: *"Your dictation history, this tool that we built, 1565 is a dictation history for the entire platform. That should have its 1566 own settings in the settings area."* It now lives at Settings â 1567 Dictation history (js/dictation-settings.js) â the right door, since 1568 /voice-history never took a course id. The "looking for" panel below
1569 carries the pointer so a coach who remembers it here still finds it. */ 1570 1571 // Everything that used to be here still has a home, and the screen names 1572 // it. A coach who opens Settings looking for the title should not have to 1573 // guess; "it moved" with no destination is how a control becomes lost. 1574 // 1575 // â ï¸ DOORS, NOT PROSE (door audit F7). This panel used to NAME four 1576 // destinations in plain text â inside a screen that has HIDDEN the board 1577 // those destinations live on, so the coach had to back out and hunt. The 1578 // Structure / Look / Selling & publishing cards are on the board behind 1579 // this panel, so those links are `ctx.back()`; the AI panel is a Manage 1580 // row, so that one is `ctx.goTab('ai')`. ("Who gets in" became "your 1581 // product and web address ⦠Selling & publishing" with the F2 relabel â 1582 // that card opens the go-live setup, not an access editor.) 1583 /* b17b (C42, round 10 b job 6): on the course home there is no board, 1584 and "Structure", "Look" and "the AI section" are not tabs. There the 1585 doors name the home's own tabs and cards. */ 1586 (ctx && ctx.inHome 1587 ? '<div class="panel cpg-panel"><div class="panel-body">' + 1588 '<div class="cpg-section-sub">Looking for something else? ' + 1589 'The <b>name and one line about it</b> are on the <a href="#" data-st-go="home:settings">Settings</a> tab under About the course, ' + 1590 'the <b>cover photo</b> and the rest of the look are under Look on the same tab, ' + 1591 '<b>your price and ways to pay</b> are on the <a href="#" data-st-go="home:selling">Selling</a> tab, ' + 1592 'the <b>AI tutor</b> is under Extras on the Settings tab, ' + 1593 'and <b>your dictation history</b> is in <a href="#" data-st-go="dictation">Mayaâs Brain â Her sources</a>.</div>' + 1594 '</div></div>' 1595 : '<div class="panel cpg-panel"><div class="panel-body">' + 1596 '<div class="cpg-section-sub">Looking for something else? ' + 1597 'The <b>title and description</b> are in <a href="#" data-st-go="back">Structure</a>, ' + 1598 'the <b>cover photo</b> and the rest of the look are in <a href="#" data-st-go="back">Look</a>, ' + 1599 '<b>your product and web address</b> are in <a href="#" data-st-go="back">Selling & publishing</a>, ' + 1600 'the <b>AI tutor and nudges</b> are in the <a href="#" data-st-go="ai">AI section</a>, ' + 1601 'and <b>your dictation history</b> is in <a href="#" data-st-go="dictation">Mayaâs Brain â Her sources</a>.</div>' + 1602 '</div></div>'); 1603 1604 _speakIt(el); 1605 _mountDomain(el.querySelector('[data-st-domain]'), ctx); 1606 _mountPowered(el.querySelector('[data-st-powered]'), ctx); 1607 1608 // The Emails door (F1's pointer, now a quiet link in the panel sub). 1609 // `ctx.goTab('emails')` opens the rail row that holds the full catalogue; 1610 // guarded because some hosts hand a ctx without goTab. 1611 var emailsGo = el.querySelector('[data-st-emails-go]'); 1612 if (emailsGo) emailsGo.addEventListener('click', function (e) { 1613 e.preventDefault(); 1614 if (ctx && typeof ctx.goTab === 'function') ctx.goTab('emails'); 1615 }); 1616 1617 // §AUDIT-GAPS.1 â the embedded design chooser: the same community tool 1618 // the Emails screen's tab mounts, hosted here in place. Course context 1619 // rides so its live preview wears THIS course's branding. 1620 var edHost = el.querySelector('[data-st-emaildesign]'); 1621 if (edHost && typeof window.wireCommunityEmails === 'function') { 1622 try { 1623 window.wireCommunityEmails(edHost, { 1624 tabs: ['design'], 1625 course: { id: (ctx && ctx.courseId) || null, title: (ctx && ctx.course && ctx.course.title) || '' }, 1626 heading: 'Email design', 1627 // The course page already says where this is (round 6: two breadcrumbs). 1628 crumbs: false, 1629 sub: 'One design for every email your community and this course send. The live preview shows it with this course\u2019s branding.', 1630 }); 1631 } catch (err) { 1632 edHost.innerHTML = '<div class="cpg-section-sub">Could not open the email design tool just now. Reload and try again.</div>'; 1633 } 1634 } else if (edHost) {
1635 edHost.innerHTML = '<div class="cpg-section-sub">The email tool has not loaded on this page. Reload and try again.</div>'; 1636 } 1637 1638 // The "Looking for something else?" doors (F7): back() reveals the board 1639 // the cards live on; 'ai' opens the AI panel row. Guarded the same way as 1640 // the emails button above. 1641 Array.prototype.forEach.call(el.querySelectorAll('[data-st-go]'), function (a) { 1642 a.addEventListener('click', function (e) { 1643 e.preventDefault(); 1644 var t = a.getAttribute('data-st-go'); 1645 if (t.indexOf('home:') === 0) { 1646 if (ctx && typeof ctx.homeTab === 'function') ctx.homeTab(t.slice(5)); 1647 return; 1648 } 1649 if (t === 'ai') { 1650 if (ctx && typeof ctx.goTab === 'function') ctx.goTab('ai'); 1651 return; 1652 } 1653 // §CORE-SETTINGS â the moved dictation history. A real door to the 1654 // platform Settings page it now lives on, not prose about one. 1655 if (t === 'dictation') { 1656 if (typeof window.navigateToSettings === 'function') window.navigateToSettings('settings-dictation'); 1657 return; 1658 } 1659 if (ctx && typeof ctx.back === 'function') ctx.back(); 1660 }); 1661 }); 1662 } 1663 1664 /** 1665 * The credit line â the one footer switch that survived the 2026-08-06 cut. 1666 * 1667 * â ï¸ READS AND WRITES THE FOOTER'S OWN STORE, not a new one. `GET .../guide/footer` 1668 * returns the resolved footer including `show.powered`, and the PUT with a 1669 * `show` body is the same call the footer pane made. A second store for one 1670 * boolean is how a switch and a page end up disagreeing. 1671 * 1672 * Absent reads as ON, matching `footerShow`: the credit has always been on 1673 * unless a coach said otherwise, and a missing key must not read as a 1674 * decision they never made. 1675 */ 1676 async function _mountPowered(mount, ctx) { 1677 if (!mount || !ctx || !ctx.courseId) return; 1678 var on = true; 1679 try { 1680 var r = await P.api('/community-hosting/courses/' + ctx.courseId + '/guide/footer'); 1681 var show = (r && r.footer && r.footer.show) || {}; 1682 on = show.powered !== false; 1683 } catch (e) { 1684 /* A course with no design yet has no site and so no footer to read 1685 (`no_design_yet`). Saying so beats a switch that looks live and cannot 1686 save â the same rule the footer pane follows. */ 1687 mount.innerHTML = '<div class="cpg-section-sub">Pick a look for this course first: ' + 1688 'the credit line is part of its footer.</div>'; 1689 return; 1690 } 1691 mount.innerHTML = 1692 '<label class="cpg-toggle" style="display:flex;gap:10px;align-items:center;min-height:34px">' + 1693 '<input type="checkbox" data-st-powered-cb' + (on ? ' checked' : '') + '>' + 1694 '<span>Show <b>Powered by Replicants</b> in the footer</span>' + 1695 '</label>' + 1696 '<div class="cpg-section-sub" data-st-powered-msg style="margin-top:6px"></div>'; 1697 var cb = mount.querySelector('[data-st-powered-cb]'); 1698 var msg = mount.querySelector('[data-st-powered-msg]'); 1699 cb.addEventListener('change', async function () { 1700 var want = cb.checked; 1701 cb.disabled = true; 1702 if (msg) msg.textContent = 'Savingâ¦'; 1703 try { 1704 await P.api('/community-hosting/courses/' + ctx.courseId + '/guide/footer', 1705 { method: 'PUT', body: { show: { powered: want } } }); 1706 if (msg) msg.textContent = want ? '\u2713 Shown in your footer.' : '\u2713 Hidden.'; 1707 } catch (e) { 1708 // The switch goes back where it was: a control that stays where you put 1709 // it after a failed save is a control that lies about the answer. 1710 cb.checked = !want; 1711 if (msg) msg.textContent = 'Couldn\u2019t save that just now.'; 1712 } finally { cb.disabled = false; } 1713 }); 1714 } 1715 1716 // The Go-live sequence's address step, mounted here rather than rebuilt. 1717 // It already talks to `GET /courses/:id/guide/domain` and the platform's 1718 // `/api/domains` endpoints; there is no second domain implementation and 1719 // this file must not become one. 1720 function _mountDomain(mount, ctx) { 1721 if (!mount) return; 1722 var panel = App.CourseAddressPanel; 1723 if (panel && typeof panel.mount === 'function') {
1724 try { panel.mount(mount, ctx); return; } 1725 catch (err) { console.warn('[course-settings] address mount threw â', err && err.message); } 1726 } 1727 // â ï¸ Names the wizard step, so it moves with STEP_LABELS (renamed 2026-08-19). 1728 mount.innerHTML = '<div class="cpg-section-sub">Your web address and favicon are set in the ' + 1729 '<b>Publishing</b> sequence, on the Web address step.</div>'; 1730 } 1731 1732 // (`_mountEmails` is gone with the duplicate mount â door audit F1. The one 1733 // full mount of CourseEmailsTab is the Manage rail's Emails row; Settings 1734 // points there with the data-st-emails-go button above.) 1735 1736 // ââ AI â ONE CONTROL PANEL FOR EVERY AI TOOL ON THIS COURSE ââââââââ 1737 // 1738 // Alex, 2026-08-05: *"There should be one place where you're controlling all 1739 // the AI tools⦠one control panel⦠It should be the area where they're all 1740 // summarised and there's a simple on/off switch for each one⦠You can't be 1741 // hunting around in different places for them."* 1742 // 1743 // The switches were in TWO places: the Classroom board (six tools in 1744 // `classroom.tools`, PUT /courses/:id/classroom) and this tab (the enforced 1745 // columns `assistant_enabled` / `nudges_enabled`, PATCH /courses/:id). This 1746 // panel is the ONE place. It does not invent a store: every row still writes 1747 // the exact key the service that does the work already reads. 1748 // 1749 // ð¨ THE TUTOR HAD TWO SWITCHES AND THEY COULD DISAGREE. 1750 // `community_courses.assistant_enabled` (course-assistant-service:854) and 1751 // `classroom.tools.tutor` (course-assistant-service:865) are BOTH enforced, 1752 // and the runtime gate is the AND of them â so a coach could switch one off, 1753 // the other on, and get a Tutor that refuses with no visible reason. There is 1754 // ONE Tutor row here and it writes BOTH, so from this panel they can never 1755 // drift apart. The tick shows the EFFECTIVE state (the same AND the gate 1756 // computes), never one store's opinion of it. 1757 // 1758 // â ï¸ Write order is chosen so a half-failed save can never lie: 1759 // turning ON â column first, then the board. If the second write fails the 1760 // Tutor is still off, which is exactly what the reverted tick 1761 // then shows. 1762 // turning OFF â board first, then the column. Either write alone already 1763 // closes the gate, so the coach's "off" is true immediately. 1764 1765 /** The rows the panel draws that are NOT on the classroom board. */ 1766 var COLUMN_ROWS = [ 1767 { 1768 key: 'nudges', 1769 name: 'Gentle nudges', 1770 blurb: 'One email to a member who has gone quiet, pointing at the lesson they stopped on.', 1771 }, 1772 ]; 1773 1774 /** 1775 * What using this tool costs, said ON the switch. A control that quietly 1776 * authorises spending is not a control. Only the three tools that reach 1777 * `aiCall` have a note; the rest are queries and emails and must not be made 1778 * to sound expensive. 1779 */ 1780 function _costNote(key) { 1781 if (key === 'tutor') { 1782 return 'Every question a member asks is one AI request, charged to your account. ' 1783 + 'Switched off, questions are refused straight away. Nothing reaches a model and nothing is charged.'; 1784 } 1785 if (key === 'checks') return CHECKS_COST_NOTE; 1786 if (key === 'sticking_points') { 1787 return 'The report itself is free. Asking it for written suggestions is one AI request, ' 1788 + 'charged to your account.'; 1789 } 1790 return null; 1791 } 1792 1793 // ââ THE NUDGE WINDOW â one question about their course, not an integer â 1794 // 1795 // SPEC-U21 §3. This row used to carry 1796 // `<input type="number" min="1" max="90">` under the label *"Nudge after 1797 // this many days of no activity"*. Alex's objection is U21.4 in one line: 1798 // nothing in a coach's expertise answers "how many days", so the box is a 1799 // pause pretending to be a control. 1800 // 1801 // ð¨ IT IS THE SAME COLUMN AND THE SAME SAVE. The choices below still 1802 // `PATCH { nudge_inactive_days }` on the existing course endpoint, which is 1803 // the value `course-nudges-service.computeNudgesDue` binds as `$3::int` in 1804 // the due-query. Changing the CONTROL and not the STORE is what keeps this a
1805 // UX change rather than a second way to set the same thing. 1806 // 1807 // â ï¸ THE SET AND THE RECOMMENDATION BOTH COME FROM THE SERVER 1808 // (`GET /courses/:id/classroom` â `nudge`), never from a default this file 1809 // invents. Two places computing a recommendation is two places to drift, and 1810 // the server is the one that can read the course's release cadence. If the 1811 // block is absent â an older server â the row draws its switch and no window 1812 // control at all, rather than making up a list of days. 1813 // 1814 // â ï¸ And the coach's CURRENT value is always one of the choices, server-side 1815 // (`nudgeChoices`), so a coach who typed 12 into the old box still sees 12 1816 // selected instead of watching this screen quietly move them to 10. 1817 1818 function _nudgeChoiceHtml(n, days) { 1819 var on = Number(n.current) === days; 1820 var rec = n.recommended != null && Number(n.recommended) === days; 1821 return '<button type="button" class="btn btn-sm' + (on ? '' : ' btn-secondary') + '"' + 1822 ' data-ai-nudge-days="' + days + '" aria-pressed="' + (on ? 'true' : 'false') + '"' + 1823 ' style="margin:0 8px 8px 0">' + days + ' days' + 1824 (rec ? '<span data-ai-nudge-rec> · recommended</span>' : '') + 1825 '</button>'; 1826 } 1827 1828 function _nudgeWindowHtml(n, on) { 1829 if (!n || !Array.isArray(n.choices) || !n.choices.length) return ''; 1830 // The basis, in the coach's own course's terms. A recommendation with no 1831 // visible reason is decoration â the same rule booking-setup-advisor 1832 // follows, and the same reason it returns nothing when it cannot read one. 1833 var basis = n.reason 1834 ? '<div class="cpg-section-sub" data-ai-nudge-why><b>Why ' + P.esc(String(n.recommended)) + 1835 ' days:</b> ' + P.esc(n.reason) + '</div>' 1836 : (n.missing 1837 ? '<div class="cpg-section-sub" data-ai-nudge-why>' + P.esc(n.missing) + 1838 ' Five days is a starting point rather than a suggestion.</div>' 1839 : ''); 1840 return '<div class="field" data-ai-nudge-days-wrap style="margin:0 0 10px 30px;' + 1841 (on ? '' : 'display:none') + '">' + 1842 '<label>How long is too long for one of your students to go quiet?</label>' + 1843 '<div data-ai-nudge-choices style="display:flex;flex-wrap:wrap;margin:6px 0 0">' + 1844 n.choices.map(function (d) { return _nudgeChoiceHtml(n, Number(d)); }).join('') + 1845 '</div>' + basis + 1846 '</div>'; 1847 } 1848 1849 function _aiRowHtml(r) { 1850 var cost = _costNote(r.key); 1851 return '<div class="cpg-src-row" data-ai-row="' + P.esc(r.key) + '">' + 1852 '<label class="cpg-src"' + (r.available === false ? ' style="opacity:.65"' : '') + '>' + 1853 '<input type="checkbox" data-ai-switch="' + P.esc(r.key) + '"' + 1854 (r.on ? ' checked' : '') + (r.available === false ? ' disabled' : '') + 1855 ' style="accent-color:var(--accent)">' + 1856 '<span class="cpg-src-body">' + 1857 '<span class="cpg-src-title">' + P.esc(r.name) + '</span>' + 1858 '<span class="cpg-src-blurb">' + P.esc(r.blurb) + '</span>' + 1859 (cost 1860 ? '<span class="cpg-src-blurb" data-ai-cost="' + P.esc(r.key) + '"><b>Costs money when used.</b> ' + 1861 P.esc(cost) + '</span>' 1862 : '') + 1863 (r.available === false 1864 ? '<span class="cpg-src-blurb">This one needs a community to talk in, so it cannot run on this course.</span>' 1865 : '') + 1866 '<span class="cpg-msg" data-ai-msg style="display:none;margin:6px 0 0"></span>' + 1867 '</span>' + 1868 '</label>' + 1869 // OUTSIDE the label on purpose: a control inside a <label> toggles the 1870 // checkbox every time the coach clicks into it. 1871 (r.key === 'nudges' ? _nudgeWindowHtml(r.nudge, r.on) : '') + 1872 '</div>'; 1873 } 1874 1875 /** 1876 * Send one row's decision to the key the service actually reads. 1877 * Returns a promise; a rejection is what reverts the tick. 1878 */ 1879 function _saveTool(ctx, key, want, extra) { 1880 var base = '/community-hosting/courses/' + ctx.courseId; 1881 function board(tools) { 1882 return P.api(base + '/classroom', { 1883 method: 'PUT', 1884 // PATCH semantics server-side: a tool not named here keeps what it had. 1885 body: { tools: tools, chosen_in: 'course_ai_panel' }, 1886 }); 1887 } 1888 function column(body) { return P.api(base, { method: 'PATCH', body: body }); } 1889 1890 if (key === 'tutor') { 1891 return want 1892 ? column({ assistant_enabled: true }).then(function () { return board({ tutor: true }); }) 1893 : board({ tutor: false }).then(function () { return column({ assistant_enabled: false }); }); 1894 } 1895 if (key === 'nudges') { 1896 var body = { nudges_enabled: want }; 1897 if (extra && extra.days != null) body.nudge_inactive_days = extra.days; 1898 return column(body); 1899 } 1900 var t = {}; 1901 t[key] = want; 1902 return board(t); 1903 } 1904 1905 function _wireAiRow(row, ctx) { 1906 var key = row.getAttribute('data-ai-row'); 1907 var box = row.querySelector('[data-ai-switch]'); 1908 var msg = row.querySelector('[data-ai-msg]'); 1909 var daysWrap = row.querySelector('[data-ai-nudge-days-wrap]'); 1910 var choices = row.querySelectorAll('[data-ai-nudge-days]'); 1911 /** The window currently selected, read off the buttons themselves. */ 1912 function chosenDays() { 1913 for (var i = 0; i < choices.length; i++) { 1914 if (choices[i].getAttribute('aria-pressed') === 'true') { 1915 return parseInt(choices[i].getAttribute('data-ai-nudge-days'), 10); 1916 } 1917 } 1918 return null; 1919 } 1920 if (!box) return; 1921 1922 box.addEventListener('change', async function () { 1923 var want = !!box.checked; 1924 var d = chosenDays(); 1925 box.disabled = true; 1926 P.setMsg(msg, 'Savingâ¦');
1927 try { 1928 // Switching nudges ON carries the chosen window with it, so a coach who 1929 // never touches the days still turns it on at the number they were 1930 // shown rather than at whatever the column happened to hold. 1931 await _saveTool(ctx, key, want, d != null ? { days: d } : null); 1932 box.disabled = false; 1933 if (daysWrap) daysWrap.style.display = want ? '' : 'none'; 1934 P.setMsg(msg, want ? 'â On' : 'â Off', 'good'); 1935 } catch (err) { 1936 // Revert the tick. A switch that looks saved and is not is the fault 1937 // this panel exists to remove. 1938 box.checked = !want; 1939 box.disabled = false; 1940 if (daysWrap) daysWrap.style.display = box.checked ? '' : 'none'; 1941 P.setMsg(msg, 'Couldnât save: ' + ((err && err.code) || (err && err.message) || 'unknown'), 'bad'); 1942 } 1943 }); 1944 1945 /** Paint one choice as the selected one. Nothing else decides this. */ 1946 function paint(days) { 1947 Array.prototype.forEach.call(choices, function (b) { 1948 var on = parseInt(b.getAttribute('data-ai-nudge-days'), 10) === days; 1949 b.setAttribute('aria-pressed', on ? 'true' : 'false'); 1950 b.className = 'btn btn-sm' + (on ? '' : ' btn-secondary'); 1951 }); 1952 } 1953 1954 Array.prototype.forEach.call(choices, function (btn) { 1955 btn.addEventListener('click', async function () { 1956 var n = parseInt(btn.getAttribute('data-ai-nudge-days'), 10); 1957 var was = chosenDays(); 1958 if (!n || n === was) return; 1959 // Paint first so the tap feels immediate, and revert on failure â the 1960 // same bargain the switches above make. A control that looks saved and 1961 // is not is the fault this panel exists to remove. 1962 paint(n); 1963 Array.prototype.forEach.call(choices, function (b) { b.disabled = true; }); 1964 P.setMsg(msg, 'Savingâ¦'); 1965 try { 1966 await P.api('/community-hosting/courses/' + ctx.courseId, { 1967 method: 'PATCH', body: { nudge_inactive_days: n }, 1968 }); 1969 Array.prototype.forEach.call(choices, function (b) { b.disabled = false; }); 1970 P.setMsg(msg, 'â We will wait ' + n + ' days', 'good'); 1971 } catch (err) { 1972 paint(was); 1973 Array.prototype.forEach.call(choices, function (b) { b.disabled = false; }); 1974 P.setMsg(msg, 'Couldnât save: ' + ((err && err.code) || (err && err.message) || 'unknown'), 'bad'); 1975 } 1976 }); 1977 }); 1978 } 1979 1980 async function renderAi(el, ctx) { 1981 var c = ctx.course; 1982 1983 el.innerHTML = 1984 '<div class="panel cpg-panel">' + 1985 '<div class="panel-head">' + 1986 '<div class="cpg-section-title">Your AI tools</div>' + 1987 '<div class="cpg-section-sub">Everything the AI does on this course, in one place. ' + 1988 'Turn any of these on or off now or later. Nothing here is permanent.</div>' + 1989 '</div>' + 1990 '<div class="panel-body"><div class="cpg-src-list" data-ai-list>' + 1991 '<div class="cpg-section-sub">Loading your toolsâ¦</div>' + 1992 '</div></div>' + 1993 '<div class="panel-foot" style="justify-content:flex-start">' + 1994 '<button class="btn btn-secondary" type="button" data-st-asklog>ð¬ Read what your students asked</button>' + 1995 '</div>' + 1996 '</div>' + 1997 1998 '<div class="panel cpg-panel">' + 1999 '<div class="panel-head">' + 2000 '<div class="cpg-section-title">What the AI tutor is allowed to read</div>' + 2001 '<div class="cpg-section-sub">The material behind its answers. Its own screen because switching a ' + 2002 'source off deletes what it had indexed.</div>' + 2003 '</div>' + 2004 '<div class="panel-body"><div data-st-sources>' + 2005 '<div class="cpg-section-sub">Loading what it readsâ¦</div>' + 2006 '</div></div>' + 2007 '</div>'; 2008 2009 _speakIt(el); 2010 2011 el.querySelector('[data-st-asklog]').addEventListener('click', function () { 2012 ctx.openFull(window.CourseAssistantLog, 'AI tutor questions screen'); 2013 }); 2014 2015 // The destructive source toggles keep their own button, their own confirm 2016 // and their own endpoint â turning a source off deletes its indexed 2017 // passages, and that never belongs on a one-tap switch. 2018 _loadSources(el.querySelector('[data-st-sources]'), ctx); 2019 2020 var list = el.querySelector('[data-ai-list]'); 2021 var board; 2022 try { 2023 board = await P.api('/community-hosting/courses/' + ctx.courseId + '/classroom'); 2024 } catch (err) { 2025 list.innerHTML = '<div class="cpg-section-sub">Couldnât load your AI tools. Reopen this tab to retry. (' + 2026 P.esc((err && err.code) || (err && err.message) || 'unknown') + ')</div>'; 2027 return; 2028 } 2029 2030 // Drawn from the board's own answer, so each tick matches what the service 2031 // will do â never from a default this file invents. The board's `name` and 2032 // `promise` are the copy; this screen writes no new copy for them. 2033 var rows = ((board && board.tools) || []).map(function (t) { 2034 return { 2035 key: t.key, 2036 name: t.name, 2037 blurb: t.promise, 2038 available: t.available !== false, 2039 // â ï¸ The Tutor's tick is the EFFECTIVE state: the same AND the runtime 2040 // gate computes from its two enforced switches. 2041 on: t.key === 'tutor' ? (!!t.enabled && c.assistant_enabled !== false) : !!t.enabled, 2042 }; 2043 }).concat(COLUMN_ROWS.map(function (r) { 2044 return Object.assign({}, r, { 2045 available: true, 2046 on: c.nudges_enabled === true, 2047 // The window, its closed set of choices and the reason behind the 2048 // recommendation â all computed server-side from this course's own 2049 // release cadence. Absent means the row draws its switch alone. 2050 nudge: r.key === 'nudges' ? (board && board.nudge) : null, 2051 }); 2052 })); 2053 2054 list.innerHTML = rows.map(_aiRowHtml).join(''); 2055 Array.prototype.forEach.call(list.querySelectorAll('[data-ai-row]'), function (row) { 2056 _wireAiRow(row, ctx); 2057 }); 2058 } 2059 2060 // ââ U13 â LESSON CHECKS: THE SWITCH, AND WHAT IT COSTS âââââââââââââ 2061 // 2062 // ð¨ IT WRITES THE KEY THE SERVICE READS. `PUT /courses/:id/classroom` 2063 // â `classroom.tools.checks`, which is what `course-check-service` 2064 // `checksEnabled()` asks before `nextProposal` will spend anything. That is 2065 // the whole point: the last four switches in this area wrote one key and were
2066 // read from another, so a coach flipped them and nothing changed. 2067 // 2068 // â ï¸ AND IT SAYS WHAT IT COSTS, ON THE SWITCH. Turning this on means the 2069 // proposer may make ONE model call PER CHECK the coach asks for. A control 2070 // that quietly authorises spending is not a control, so the price is next to 2071 // the tick box and not in a help article â and the copy separates "on" from 2072 // "spending", because switching it on by itself charges nothing. 2073 var CHECKS_COST_NOTE = 'Each check is written by AI when you ask for it: about one AI request per lesson, ' 2074 + 'charged to your account. Switching this on costs nothing on its own. Nothing is drafted until you ' 2075 + 'ask for one on the Knowledge checks screen.'; 2076 2077 2078 // ââ D26 â WHAT THE ASSISTANT IS ALLOWED TO READ ââââââââââââââââââââ 2079 // 2080 // The toggle is not a display filter. `course-index-service` deletes a 2081 // source's chunks the moment it is switched off â the blueprint's words 2082 // are "turning a source off must delete its chunks, or 'off' is a lie" â 2083 // and the server performs that deletion inside the save, then reports how 2084 // many passages actually went. 2085 // 2086 // So the UI has one job beyond drawing four checkboxes: make that legible 2087 // WITHOUT making it frightening. Three things do it, and none of them is 2088 // a red warning box: 2089 // 1. the count of passages sits ON each row, so the number a coach is 2090 // about to lose is next to the switch they are about to flip; 2091 // 2. the confirm names the sources and the exact number of passages, 2092 // and says what undo costs (re-reading, not re-uploading) â it only 2093 // appears when something is actually being turned OFF; 2094 // 3. the result line says what happened in the past tense with the real 2095 // number, so "off" is never something the coach has to take on trust. 2096 // 2097 // A source that is ON with zero passages is not a bug and the copy says 2098 // so: a recording with no transcript indexes nothing (the measured limit 2099 // in course-index-service's header, and §5.4). 2100 2101 function _srcRowHtml(s) { 2102 var count = s.chunks 2103 ? s.chunks + ' passage' + (s.chunks === 1 ? '' : 's') + ' indexed' 2104 : (s.enabled ? 'nothing to read yet' : 'off. Nothing indexed'); 2105 return '<label class="cpg-src">' + 2106 '<input type="checkbox" data-st-src="' + P.esc(s.kind) + '"' + (s.enabled ? ' checked' : '') + 2107 ' data-st-src-chunks="' + (s.chunks || 0) + '" data-st-src-label="' + P.esc(s.label) + '"' + 2108 ' style="accent-color:var(--accent)">' + 2109 '<span class="cpg-src-body">' + 2110 '<span class="cpg-src-title">' + P.esc(s.label) + 2111 '<span class="cpg-src-count">' + P.esc(count) + '</span></span>' + 2112 '<span class="cpg-src-blurb">' + P.esc(s.blurb) + '</span>' + 2113 '</span>' + 2114 '</label>'; 2115 } 2116 2117 async function _loadSources(mount, ctx) { 2118 if (!mount) return; 2119 var data; 2120 try { 2121 data = await P.api('/community-hosting/courses/' + ctx.courseId + '/assistant/sources'); 2122 } catch (err) { 2123 mount.innerHTML = '<div class="cpg-section-sub">Couldnât load what the AI tutor reads. Reopen this tab to try again.</div>'; 2124 return; 2125 } 2126 var sources = (data && data.sources) || []; 2127 mount.innerHTML = 2128 /* No heading of its own: its one caller's panel already says "What the 2129 tutor is allowed to read", and the two sat one above the other. */ 2130 '<div class="cpg-src-block">' + 2131 '<div class="cpg-section-sub">All on to start with. Switching one off <b>deletes the passages it has ' + 2132 'indexed</b>: the AI tutor can no longer quote it or point members at it. Switching it back on reads ' + 2133 'your material again from scratch; nothing you uploaded is ever touched.</div>' + 2134 '<div class="cpg-src-list">' + sources.map(_srcRowHtml).join('') + '</div>' + 2135 '<div class="cpg-row-end" style="margin-top:12px;gap:10px">' + 2136 '<button class="btn btn-secondary" type="button" data-st-src-save>Save what it reads</button>' + 2137 '<p class="cpg-msg" data-st-src-msg style="display:none;margin:0"></p>' + 2138 '</div>' + 2139 '</div>'; 2140 2141 var msg = mount.querySelector('[data-st-src-msg]'); 2142 var before = {};
2143 sources.forEach(function (s) { before[s.kind] = s.enabled; }); 2144 2145 mount.querySelector('[data-st-src-save]').addEventListener('click', async function () { 2146 var btn = this; 2147 var boxes = Array.prototype.slice.call(mount.querySelectorAll('[data-st-src]')); 2148 var body = {}; 2149 var losing = []; 2150 var losingChunks = 0; 2151 boxes.forEach(function (b) { 2152 var kind = b.getAttribute('data-st-src'); 2153 body[kind] = !!b.checked; 2154 if (before[kind] && !b.checked) { 2155 losing.push(b.getAttribute('data-st-src-label')); 2156 losingChunks += parseInt(b.getAttribute('data-st-src-chunks'), 10) || 0; 2157 } 2158 }); 2159 // CBSheet, not the browser dialog (design audit F1) â same words, with 2160 // the real counts kept. 2161 if (losing.length && !(await window.CBSheet.confirm({ 2162 title: 'Turn off ' + losing.join(' and ') + '?', danger: true, 2163 message: 'Turning off ' + losing.join(' and ') + ' deletes ' + losingChunks + ' indexed passage' + 2164 (losingChunks === 1 ? '' : 's') + ' straight away. ' + 2165 'The AI tutor will stop being able to quote that material or point members at it. ' + 2166 'Your lessons, videos and recordings are not touched: switching it back on simply reads them again.', 2167 confirmLabel: 'Go ahead', 2168 }))) return; 2169 2170 btn.disabled = true; 2171 P.setMsg(msg, losing.length ? 'Saving and removing those passagesâ¦' : 'Savingâ¦'); 2172 try { 2173 var r = await P.api('/community-hosting/courses/' + ctx.courseId + '/assistant/sources', { 2174 method: 'PUT', body: { sources: body }, 2175 }); 2176 var parts = []; 2177 if (r.chunks_removed) parts.push('removed ' + r.chunks_removed + ' passage' + (r.chunks_removed === 1 ? '' : 's')); 2178 if (r.chunks_added) parts.push('read ' + r.chunks_added + ' new passage' + (r.chunks_added === 1 ? '' : 's')); 2179 if (!r.reindexed) { 2180 P.setMsg(msg, 'Saved, but the index could not be rebuilt just now. It will catch up on the next question.', 'bad'); 2181 } else { 2182 P.setMsg(msg, 'â Saved' + (parts.length ? ': ' + parts.join(' and ') + '.' : '.'), 'good'); 2183 } 2184 // Repaint from what the server actually holds, so the counts on the 2185 // rows are the new truth rather than the numbers we sent. 2186 (r.sources || []).forEach(function (s) { before[s.kind] = s.enabled; }); 2187 var list = mount.querySelector('.cpg-src-list'); 2188 if (list && r.sources) list.innerHTML = r.sources.map(_srcRowHtml).join(''); 2189 btn.disabled = false; 2190 } catch (err) { 2191 btn.disabled = false; 2192 P.setMsg(msg, 'Couldnât save: ' + ((err && err.code) || 'unknown'), 'bad'); 2193 } 2194 }); 2195 } 2196 2197 App.CourseCustomizeTab = { render: renderCustomize }; 2198 App.CourseCertificatesTab = { render: renderCertificates }; 2199 App.CourseSettingsTab = { render: renderSettings }; 2200 // U16.2 â the assistant + nudges cluster, for the Manage board's AI block. 2201 // Same file because it is the same markup that used to be inside Settings; 2202 // it moved screens, it was not rewritten. 2203 App.CourseAiTab = { render: renderAi }; 2204})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.