1/** 2 * src/public/js/member-panel.js â the member panel (U15.2). 3 * 4 * SPEC: docs/projects/courses/SPEC-U15-member-panel.md 5 * 6 * Alex: "members should be a very personal thing where when you click on a 7 * member, it gives you much more data about that individual member". 8 * 9 * ââ THE ORGANISING QUESTION DECIDES WHAT GOES ON HERE ââââââââââââââââ 10 * "you're trying to help an individual member." So this is NOT a smaller 11 * analytics screen. Analytics answers "how is this course doing"; this 12 * answers "what do I say to this person". The test for every row: could a 13 * coach act on it in a message to that member today? Cost, tokens, 14 * `stall_count` and every quality-of-service number therefore appear 15 * NOWHERE here (spec §5.3, acceptance 15), and neither do private lesson 16 * notes, which are structurally coach-invisible by design (spec §5.8, 17 * acceptance 14). There is no code below that reads any of them. 18 * 19 * ââ A SECTION WITH NO SOURCE DOES NOT RENDER âââââââââââââââââââââââââ 20 * "a panel with four real facts and two silently empty ones is how a coach 21 * stops trusting the whole screen" (spec §5). So each section is one 22 * independent fetch resolved into one of three states, and they are three 23 * different things on the screen: 24 * 25 * ok â real data, or a WRITTEN zero-state ("nothing yet, and 26 * that is the right answer this early") â never an empty 27 * box, a bare dash, or a 0 that means "we don't know". 28 * unavailable â the read does not exist yet (404 / not_found). The 29 * section is not drawn at all. This is the honest state for 30 * the two backend routes spec §6 still has to add, and it 31 * is why this module ships useful before they land. 32 * failed â the read exists and broke. It SAYS it broke. A failure 33 * rendered as "this member has done nothing" is the same 34 * pixels and the opposite meaning (spec §9, detection). 35 * 36 * ââ NOT MEASURED ANYWHERE, SO NOT SHOWN ââââââââââââââââââââââââââââââ 37 * Pause counts ("how often they stop") and downloads are not recorded by 38 * anything (spec §5.7, §5.9, §7.2, §7.3). They are absent rather than 39 * zeroed â a 0 there would be a claim we cannot make. 40 * 41 * ââ HOSTS ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 42 * Same `render(el, ctx)` contract every Manage-board module uses. It is 43 * full-width in BOTH hosts (spec §6) â the community Members page swaps its 44 * two-column view for this, the course Members tab opens it as a sibling 45 * panel. 46 * 47 * ctx.contactId required â whose panel this is 48 * ctx.courseId optional â present adds the course sections 49 * ctx.member optional â the list row already in memory (name/email/ 50 * joined/last active/suspended), so the header is real 51 * before any fetch resolves 52 * ctx.api required â the host's authenticated fetch helper 53 * ctx.onBack optional â back to the list, with its search intact 54 * ctx.backLabel optional â what the way back is called (default "Members"; 55 * the course home's Students tab says "Students") 56 * ctx.onMessage optional â open the coachâmember DM 57 */ 58(function () { 59 'use strict'; 60 61 var App = (window.App = window.App || {}); 62 63 function _esc(s) { 64 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 65 return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; 66 }); 67 } 68 69 function _initials(name) { 70 var parts = String(name || '').trim().split(/\s+/).filter(Boolean); 71 if (!parts.length) return '?'; 72 return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase(); 73 } 74 75 function _date(iso) { 76 if (!iso) return ''; 77 var d = new Date(iso); 78 if (isNaN(d.getTime())) return ''; 79 return App.Dates.short(d); 80 } 81 82 /* The course Students list's words for a time (course-home.js `ago`). */ 83 function _ago(iso) { 84 var d = _days(iso); 85 if (d == null) return ''; 86 if (d <= 0) return 'today'; 87 if (d === 1) return 'yesterday'; 88 if (d < 30) return d + ' days ago'; 89 var mo = Math.floor(d / 30); 90 if (mo < 12) return mo === 1 ? 'a month ago' : mo + ' months ago'; 91 return 'on ' + _date(iso); 92 } 93 94 function _days(iso) { 95 if (!iso) return null; 96 var d = new Date(iso); 97 if (isNaN(d.getTime())) return null; 98 return Math.floor((Date.now() - d.getTime()) / 86400000); 99 } 100 101 /* How long since they last did anything on the course, beside "Up next". */ 102 function _sinceWords(days) { 103 if (days == null || days < 0) return ''; 104 if (days === 0) return ' · active today'; 105 if (days === 1) return ' · last active yesterday'; 106 return ' · nothing done for ' + days + ' days'; 107 } 108 109 // A 404 means "this read does not exist yet"; anything else means it exists 110 // and broke. The two must not collapse into one grey box â see the header 111 // comment. `_api` throws `Error('HTTP 404 /path not_found')`. 112 function _isUnavailable(err) { 113 var m = (err && err.message) || '';
114 return /HTTP 404/.test(m) || /not_found/.test(m) || /not_setup/.test(m); 115 } 116 117 // Every section fetch goes through this, so one broken query degrades to 118 // one written state instead of a blank panel. 119 function _section(label, promise) { 120 return promise.then( 121 function (data) { return { state: 'ok', data: data }; }, 122 function (err) { 123 if (_isUnavailable(err)) { 124 try { console.info('[member-panel] ' + label + ' not available yet'); } catch (_) {} 125 return { state: 'unavailable', data: null }; 126 } 127 try { console.error('[member-panel] ' + label + ' failed â', (err && err.message) || err); } catch (_) {} 128 return { state: 'failed', data: null }; 129 } 130 ); 131 } 132 133 function _zero(text) { 134 return '<div class="hint" style="padding:6px 0">' + _esc(text) + '</div>'; 135 } 136 137 function _failed(what) { 138 return '<div class="hint" style="padding:6px 0">Couldnât load ' + _esc(what) + 139 ' just now. Refresh to retry. This is a problem loading it, not an empty ' + _esc(what) + '.</div>'; 140 } 141 142 function _card(title, inner) { 143 return '<div class="card" style="margin-top:16px"><div class="card-inner">' + 144 '<h3 style="margin:0 0 10px;font-size:15px;font-weight:600">' + _esc(title) + '</h3>' + 145 inner + '</div></div>'; 146 } 147 148 // ââ Sections ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 149 150 /* How they got onto the course, in words (round 5 read "Added: free"). The 151 same words as the Students list on the course home. */ 152 var SOURCE_WORDS = { manual: 'Added by you', admin: 'Added by you', free: 'Free place', level: 'Reached the level', 153 tier: 'On a plan', time: 'After the wait', purchase: 'Bought their place', import: 'Added by you', cohort: 'With a cohort', 154 space: 'In one of your spaces' }; 155 156 function _money(minor, currency) { 157 if (window.CBMoney && currency) return window.CBMoney.format(minor, currency); // the one money formatter 158 var sym = ({ gbp: '£', usd: '$', eur: 'â¬' })[String(currency || '').toLowerCase()]; 159 var n = (Number(minor) / 100).toFixed(2); 160 return sym ? sym + n : n + ' ' + String(currency || '').toUpperCase(); 161 } 162 163 function _header(ctx, m) { 164 var name = (m && (m.community_display_name || m.display_name || m.first_name || m.name || m.email)) || 'Member'; 165 var email = (m && m.email) || ''; 166 var bits = []; 167 /* Somebody still asking, or turned away, is not IN the community, and the 168 membership row's joined_at is only when they asked (round 1: a person 169 waiting to be let in read "In your community since Sep 26"). */ 170 if (m && m.admission) { 171 var askedAt = m.admission_requested_at || m.joined_at; 172 if (askedAt) bits.push((m.admission === 'declined' ? 'Asked to join ' : 'Asking to join since ') + _date(askedAt)); 173 } else if (m && m.joined_at) { 174 bits.push('In your community since ' + _date(m.joined_at)); 175 } 176 // "Joined", the student's own word for it (round 6: never "Enrolled" to a coach). 177 if (m && m.enrolled_at) bits.push('Joined this course ' + _date(m.enrolled_at)); 178 /* A test purchase is a real enrolment â the test has to run the whole 179 thing â so it is MARKED, never hidden: `order_livemode` is the order's 180 own flag (funnel_orders.livemode), the same fact the funnel Sales tab 181 badges. Alex, 2026-09-01: keep test enrolments, mark them, never delete. */ 182 if (m && m.source) { 183 bits.push(String(m.source) === 'purchase' 184 ? ('Bought their place' + (m.order_livemode === false ? ' · test payment' : '')) 185 : (SOURCE_WORDS[String(m.source)] || 'Joined')); 186 } 187 /* What they paid and which order it was, with the way to it (course 188 round 8, tester b fault 6). The order page is where Refund lives. */ 189 var ord = m && m.order; 190 var orderLink = ''; 191 if (ord && ord.id) { 192 var amt = (ord.total != null && ord.currency) ? _money(ord.total, ord.currency) : ''; 193 /* â ï¸ THE ORDER NUMBER READS AS A LINK (course round 9, tester b minor 6: 194 "the order number is plain text"). It was a bare-looking button; it is 195 now an underlined link that opens the order, where Refund lives, and 196 the amount and the refund sit beside it as words. */ 197 var refunded = ord.status === 'refunded' ? 'refunded' 198 : ord.status === 'partially_refunded' 199 ? 'part refunded' + (ord.refunded && ord.currency ? ' (' + _money(ord.refunded, ord.currency) + ' back)' : '') 200 : ''; 201 orderLink = _esc(amt ? amt + ' · ' : '') + 202 '<a href="#" role="button" data-mp-order="' + _esc(String(ord.id)) + '" ' + 203 'style="color:var(--brand);text-decoration:underline;cursor:pointer">' + 204 _esc('Order ' + (ord.ref || '#' + ord.id)) + '</a>' + 205 (refunded ? _esc(' · ' + refunded) : '') + 206 /* A plan sale said as a plan (course round 9, tester b minor 3). */ 207 (ord.plan && ord.plan.line ? _esc(' · ' + ord.plan.line) : ''); 208 } 209 if (m && m.cohort_name) bits.push('Cohort: ' + m.cohort_name); 210 // "Not visited yet" is a fact about them, not a missing number, so it is 211 // said rather than left blank. 212 /* On a course, "Last seen" is the course's own reading (the Students 213 list's, course-readiness LAST_SEEN_SQL), in the list's own words, so 214 the row and this panel say the same thing (course round 5: the list 215 said 15 days ago and this said today). Elsewhere, the community's. 216 â ï¸ On a course it is ALWAYS the course's reading (batch 7): this used to 217 fall back to the community's "Last active" whenever the course value 218 was missing or null (the merge drops nulls), which put the other fact 219 under the course's heading for a student never seen on the course. */ 220 if (ctx && ctx.courseId != null) { 221 bits.push(m && m.last_seen_at ? ('Last seen ' + _ago(m.last_seen_at)) : 'Not seen yet'); 222 } else if (m && m.admission) { 223 // Not a member yet, so "Not visited yet" says nothing useful. 224 } else { 225 bits.push(m && m.last_active_at ? ('Last active ' + _date(m.last_active_at)) : 'Not visited yet'); 226 } 227 228 /* ONE BREADCRUMB (round 6, 26-person-page). Inside a course the page 229 already carries "Products / Courses / <course>" and the Students tab, 230 so a second "â Students / VERA SEVEN" crumb was a second trail. There 231 the way back is a plain button; the community Members page, which has 232 no trail of its own, keeps its crumb. */ 233 var inCourse = ctx && ctx.courseId != null; 234 var back = inCourse 235 ? '<div style="margin-bottom:4px">' + 236 '<button class="btn btn-secondary" data-mp-back type="button">â All students</button>' + 237 '</div>' 238 : '<div class="page-head-crumbs">' + 239 '<button class="btn btn-ghost" data-mp-back type="button" style="padding:0;border:0;background:none">â ' + _esc(ctx.backLabel || 'Members') + '</button>' + 240 '<span class="crumb-sep">/</span><span class="crumb-current">' + _esc(name) + '</span>' + 241 '</div>'; 242 return '<div class="page-head" style="margin-bottom:6px"><div>' + 243 back + 244 '<div style="display:flex;gap:12px;align-items:center;margin-top:6px">' + 245 '<div class="avatar av-2">' + _esc(_initials(name)) + '</div>' + 246 '<div style="min-width:0">' + 247 '<h1 class="display" style="margin:0">' + _esc(name) + 248 (m && m.is_suspended ? ' <span class="role-tag" style="background:var(--danger-soft);color:var(--danger)">SUSPENDED</span>' : '') + 249 // Member approval (762): somebody still asking, or turned away, is 250 // not a member â say so where the coach is looking. 251 (m && m.admission === 'pending' ? ' <span class="role-tag" style="background:var(--warning-bg,#fff4e5);color:var(--warning-text,#b54708)">ASKING TO JOIN</span>' : '') + 252 (m && m.admission === 'declined' ? ' <span class="role-tag">TURNED AWAY</span>' : '') + 253 '</h1>' + 254 (email ? '<div class="hint" style="margin:0">' + _esc(email) + '</div>' : '') + 255 '<div class="hint" style="margin:0">' + _esc(bits.join(' · ')) + (orderLink ? ' · ' + orderLink : '') + '</div>' + 256 '</div>' + 257 '<div style="flex:1"></div>' + 258 // Not a member yet, so there is nobody to message on the community. 259 // Somebody still asking gets the decision instead, when the caller 260 // can make it (ctx.onDecide), so the coach decides where they read. 261 (m && m.admission === 'pending' && ctx && ctx.onDecide 262 ? '<div style="display:flex;gap:8px;flex:none">
262' + 263 '<button class="btn btn-primary" data-mp-decide="approve" type="button">Let in</button>' + 264 '<button class="btn btn-secondary" data-mp-decide="decline" type="button">Turn away</button>' + 265 '</div>' 266 : (m && m.admission ? '' : '<button class="btn btn-primary" data-mp-message type="button" style="flex:none">Message them</button>')) + 267 '</div>' + 268 '</div></div>'; 269 } 270 271 // 5.1 + 5.2 â where they are, and lesson by lesson. 272 function _courseSection(res) { 273 if (res.state === 'unavailable') return ''; 274 if (res.state === 'failed') return _card('Their progress', _failed('their progress')); 275 var d = res.data || {}; 276 var lessons = Array.isArray(d.lessons) ? d.lessons : []; 277 var head = ''; 278 if (d.completed != null && d.lesson_count != null) { 279 head += '<div style="font-weight:600;margin-bottom:4px">' + 280 _esc(d.completed + ' of ' + d.lesson_count + ' lessons complete') + '</div>'; 281 } 282 if (d.next_lesson_title) { 283 // The actionable pairing course-analytics.js already documents: which 284 // lesson, and how long they have been sitting in front of it. 285 /* Round 6: "nothing for 0 days" read as a fault on somebody active 286 today. Said the way a person would: today, yesterday, then a count. */ 287 head += '<div class="hint" style="margin:0 0 10px">Up next: ' + _esc(d.next_lesson_title) + 288 _sinceWords(_days(d.last_activity_at)) + '</div>'; 289 } 290 if (d.certificate_issued_at) { 291 head += '<div class="hint" style="margin:0 0 10px">Certificate issued ' + _esc(_date(d.certificate_issued_at)) + '</div>'; 292 } 293 if (!lessons.length) { 294 return _card('Their progress', head + _zero('They have not opened a lesson yet. Nothing to see is the right answer this early.')); 295 } 296 var rows = lessons.map(function (l) { 297 var state = l.completed_at ? 'Completed ' + _date(l.completed_at) 298 : (l.started ? 'Started' : 'Not started'); 299 // â ï¸ watch_pct exists for VIDEO lessons only (spec §5.2). A words / 300 // slides / download lesson has a progress row with watch_pct NULL, and 301 // a 0% bar there reads as "they did nothing" when they finished it. 302 var pct = (typeof l.watch_pct === 'number' && isFinite(l.watch_pct)) ? Math.round(l.watch_pct) : null; 303 var bar = pct == null ? '' : 304 '<div class="mp-bar" style="height:6px;border-radius:3px;background:var(--surface-3);margin-top:4px">' + 305 '<div style="height:6px;border-radius:3px;background:var(--brand);width:' + Math.max(0, Math.min(100, pct)) + '%"></div>' + 306 '</div>' + 307 '<div class="hint" style="margin:0">Watched ' + pct + '% of the video</div>'; 308 // "Sittings", never "stops" â nothing counts a pause (spec §5.9 #2). 309 var sittings = (typeof l.sittings === 'number' && l.sittings > 0) 310 ? '<div class="hint" style="margin:0">' + l.sittings + (l.sittings === 1 ? ' sitting' : ' sittings') + '</div>' : ''; 311 return '<div class="mp-lesson" style="padding:9px 2px;border-top:1px solid var(--border-soft)" data-mp-lesson="' + _esc(String(l.lesson_id || '')) + '">' + 312 '<div style="font-weight:600;font-size:13.5px">' + _esc(l.title || 'Lesson') + '</div>' + 313 '<div class="hint" style="margin:0">' + _esc(state) + '</div>' + 314 bar + sittings + 315 '</div>'; 316 }).join(''); 317 return _card('Lesson by lesson', head + rows); 318 } 319 320 // 5.3 â their course-assistant transcripts. 321 function _turnsSection(res, contactId) { 322 if (res.state === 'unavailable') return ''; 323 if (res.state === 'failed') return _card('What they asked the AI tutor', _failed('their questions')); 324 var all = (res.data && Array.isArray(res.data.turns)) ? res.data.turns : []; 325 // â ï¸ THE FILTER IS LOAD-BEARING, NOT BELT-AND-BRACES. 326 // GET /courses/:id/assistant/turns is the WHOLE course's log today; the 327 // `contact_id` filter spec §5.3 adds to it is a backend change that has 328 // not landed. Until it does, an unfiltered response would put another 329 // member's questions on this member's panel. So the panel decides what is 330 // this person's, on this person's id, every time â and keeps deciding it 331 // after the server learns to filter. 332 var mine = all.filter(function (t) { 333 return t && t.contact_id != null && String(t.contact_id) === String(contactId); 334 }); 335 if (!mine.length) { 336 return _card('What they asked the AI tutor', _zero('They have not asked the AI tutor anything yet.')); 337 } 338 var rows = mine.map(function (t) { 339 var where = t.lesson_title ? ('Asked from ' + t.lesson_title) : 'Asked from the course overview'; 340 var handed = t.message_coach 341 ? '<div class="hint" style="margin:0;color:var(--danger)">Handed to you' + 342 (t.escalation_reason ? (': ' + _esc(t.escalation_reason)) : '') + '</div>' 343 : '';
344 return '<div class="mp-turn" style="padding:9px 2px;border-top:1px solid var(--border-soft)">' + 345 '<div style="font-weight:600;font-size:13.5px">' + _esc(t.question || '') + '</div>' + 346 '<div style="font-size:13px;white-space:pre-wrap">' + _esc(t.answer || '') + '</div>' + 347 '<div class="hint" style="margin:0">' + _esc(where) + ' · ' + _esc(_date(t.created_at)) + 348 (t.answer_mode ? (' · ' + _esc(t.answer_mode)) : '') + '</div>' + 349 handed + 350 '</div>'; 351 }).join(''); 352 return _card('What they asked the AI tutor', rows); 353 } 354 355 // 5.5 â knowledge checks. Arrives with the course detail read. 356 function _checksSection(res) { 357 if (res.state === 'unavailable') return ''; 358 if (res.state === 'failed') return ''; 359 var attempts = (res.data && Array.isArray(res.data.check_attempts)) ? res.data.check_attempts : []; 360 if (!attempts.length) return _card('Knowledge checks', _zero('No check attempts yet.')); 361 var rows = attempts.map(function (a) { 362 var wrong = Array.isArray(a.wrong_questions) ? a.wrong_questions : []; 363 return '<div class="mp-attempt" style="padding:9px 2px;border-top:1px solid var(--border-soft)">' + 364 '<div style="font-weight:600;font-size:13.5px">' + _esc(a.lesson_title || 'Check') + ': ' + 365 _esc(a.score + '/' + a.total) + ' · ' + (a.passed ? 'Passed' : 'Not passed') + '</div>' + 366 '<div class="hint" style="margin:0">' + _esc(_date(a.created_at)) + '</div>' + 367 (wrong.length 368 ? '<div class="hint" style="margin:0">Got wrong: ' + _esc(wrong.join(' · ')) + '</div>' 369 : '') + 370 '</div>'; 371 }).join(''); 372 return _card('Knowledge checks', rows); 373 } 374 375 // 5.4 â their comments on this course's lessons. 376 function _commentsSection(res) { 377 if (res.state === 'unavailable' || res.state === 'failed') return ''; 378 var comments = (res.data && Array.isArray(res.data.comments)) ? res.data.comments : []; 379 if (!comments.length) return _card('What they have said', _zero('They have not commented on a lesson yet.')); 380 return _card('What they have said', comments.map(function (c) { 381 return '<div class="mp-comment" style="padding:9px 2px;border-top:1px solid var(--border-soft)">' + 382 '<div style="font-size:13px;white-space:pre-wrap">' + _esc(c.body || '') + '</div>' + 383 '<div class="hint" style="margin:0">' + _esc(c.lesson_title || 'Lesson') + ' · ' + _esc(_date(c.created_at)) + '</div>' + 384 '</div>'; 385 }).join('')); 386 } 387 388 // 5.6 â the person beside the course. Needs GET /community-hosting/members/ 389 // :contact_id, which spec §6 still has to add; until then this is 390 // `unavailable` and draws nothing. 391 function _communitySection(res) { 392 if (res.state === 'unavailable') return ''; 393 if (res.state === 'failed') return _card('In the community', _failed('their community activity')); 394 var d = res.data || {}; 395 var lines = []; 396 if (d.post_count != null) lines.push(d.post_count + (d.post_count === 1 ? ' post' : ' posts')); 397 if (d.comment_count != null) lines.push(d.comment_count + (d.comment_count === 1 ? ' comment' : ' comments')); 398 if (d.level != null) lines.push('Level ' + d.level); 399 if (d.points != null) lines.push(d.points + ' points'); 400 /* "Last in the community", never "Last visit": on a course's person page 401 the header already says "Last seen" for the COURSE, and two different 402 facts under one name is what testers read as a disagreement (batch 7). 403 The value is the community's one definition (last-seen.js). */ 404 if (d.last_active_at) lines.push('Last in the community ' + _date(d.last_active_at)); 405 if (d.outreach_sourced) lines.push('Came through outreach'); 406 var events = Array.isArray(d.events) ? d.events : []; 407 var timeline = events.length 408 ? events.map(function (e) { 409 return '<div class="mp-event" style="padding:7px 2px;border-top:1px solid var(--border-soft)">' + 410 '<span style="font-weight:600;font-size:13px">' + _esc(e.label || e.verb || '') + '</span>' + 411 '<span class="hint" style="margin:0 0 0 8px">' + _esc(_date(e.at || e.created_at)) + '</span>' + 412 '</div>'; 413 }).join('') 414 : _zero('Nothing in the community yet.'); 415 return _card('In the community', 416 (lines.length ? '<div class="hint" style="margin:0 0 8px">' + _esc(lines.join(' · ')) + '</div>' : '') + 417 (d.bio ? '<div style="font-size:13px;margin-bottom:8px;white-space:pre-wrap">' + _esc(d.bio) + '</div>' : '') + 418 timeline); 419 } 420 421 /* ââ 5.10 â Emails we have sent them ââââââââââââââââââââââââââââââââââ 422 Alex, 2026-08-10: *"is there a way to see the history of the emails that 423 have been sent out⦠In the members area⦠you are able to drill in to see 424 the history of all emails sent to each individual member"*, and then 425 *"little pill filters at the top."* 426 427 This is the one section that talks back. The others paint once from a 428 fetch;
428 the pills re-read with `?source=`, because filtering client-side 429 would silently lie the moment a member has more emails than the limit â 430 "Course (2)" showing two of forty is worse than not offering the pill. 431 432 â ï¸ THE PILLS COME FROM `counts`, NOT FROM A HARDCODED LIST. The endpoint 433 returns the per-source counts for THIS member, so a coach is never offered 434 a filter that returns an empty screen. A fixed row of four pills, three of 435 them dead, is how a coach concludes the feature is broken. 436 437 â ï¸ AND THE START DATE IS ON SCREEN. `member_email_log` began on the day it 438 shipped, so a member who joined in March has an empty history that means 439 "not recorded", not "never emailed". Without that line the screen makes a 440 confident false claim about a real person, which is the exact failure the 441 rest of this panel is written to avoid. */ 442 443 var _SOURCE_LABEL = { 444 community: 'Community', course: 'Course', game: 'Game', booking: 'Bookings', 445 }; 446 447 // Friendly names for the kinds a coach will actually see. Anything not 448 // listed degrades to its own de-underscored name rather than to a blank â 449 // a new email type should read a bit raw, not read as nothing. 450 var _KIND_LABEL = { 451 daily_digest: 'Daily digest', 452 daily_today: 'Whatâs Happening Today', 453 coach_post: 'New post from you', 454 comment_on_post: 'Comment on their post', 455 reply_to_comment: 'Reply to their comment', 456 direct_message: 'Direct message', 457 mention: 'Mention', 458 reaction: 'Reaction', 459 badge_earned: 'Badge earned', 460 level_reached: 'Level reached', 461 onboarding_nudge: 'Onboarding nudge', 462 welcome: 'Course welcome', 463 order_receipt: 'Receipt', 464 order_confirmation: 'Order confirmation', 465 never_started: 'First lesson waiting', 466 lesson_release: 'New lesson released', 467 cohort_start: 'Cohort starting', 468 completion_congrats: 'Course finished', 469 booking_confirmed: 'Booking confirmed', 470 booking_reminder_24h: 'Reminder, 24 hours', 471 booking_reminder_1h: 'Reminder, 1 hour', 472 booking_cancelled: 'Booking cancelled', 473 booking_rescheduled: 'Booking moved', 474 }; 475 476 /* ââ WHY THEIR CARD FAILED, AND WHAT THE COACH CAN DO ABOUT IT ââââââââââ 477 * 478 * Their docs put the decline reason, the retry count *"out of the total"* and 479 * a manual retry button in front of the coach. The count is the part that 480 * earns its place: "their card failed" is a fact nobody can act on, whereas 481 * "failed, three attempts of four, next one Friday" decides whether to 482 * message the member today or leave Stripe to it. 483 * 484 * â ï¸ ONLY RENDERS WHEN THERE IS SOMETHING WRONG. A healthy subscription needs 485 * no billing card in a panel about a person â adding one for everybody would 486 * bury the two members who actually need chasing among the two hundred who do 487 * not. 488 */ 489 function _billingSection(res, contactId) { 490 if (res.state === 'unavailable') return ''; 491 if (res.state === 'failed') return _card('Billing', _failed('their billing')); 492 var d = res.data || {}; 493 if (!d.subscription) return ''; 494 var inv = d.invoice; 495 // Nothing to say unless the invoice is unpaid or the membership is past due. 496 if (!inv || (d.status !== 'past_due' && inv.status !== 'open' && inv.status !== 'uncollectible')) return ''; 497 498 var money = (inv.amount_due != null) 499 ? (({ gbp: '£', usd: '$', eur: 'â¬' })[String(inv.currency || '').toLowerCase()] || '') + 500 (Number(inv.amount_due) / 100).toFixed(2).replace(/\.00$/, '') 501 : ''; 502 var rows = ''; 503 rows += '<div style="font-weight:700;font-size:15px">Their card was declined' + (money ? ': ' + _esc(money) : '') + '</div>'; 504 if (inv.decline_reason) { 505 /* Stripe's own words, passed through. Their docs are blunt that banks do 506 not hand over detailed reasons, so rewording it here would be inventing 507 a diagnosis we cannot support. */ 508 rows += '<div class="hint" style="margin-top:4px">' + _esc(inv.decline_reason) + '</div>'; 509 } 510 rows += '<div class="hint" style="margin-top:6px">' + 511 _esc(String(inv.attempts || 0)) + ' attempt' + ((inv.attempts === 1) ? '' : 's') + ' so far. ' + 512 (inv.next_attempt 513 ? ('Stripe tries again on ' + _esc(_date(inv.next_attempt)) + '.') 514 // No next attempt means Stripe has stopped. That is the moment the 515 // coach most needs to act, so it is said plainly rather than omitted. 516 : 'Stripe has stopped retrying. This one needs you.') + 517 '</div>'; 518 /* â ï¸ THE REASSURANCE FOLLOWS THE RETRY STATE, and once did not. This line 519 was unconditional, so a member whose retries Stripe had ABANDONED got 520 "Stripe has stopped retrying â this one needs you" followed immediately 521 by "while this is being retried". Two adjacent sentences contradicting 522 each other, on the one panel whose job is to say whether to act â and on 523 a skim it is the reassuring half that survives, which is the opposite of 524 what that moment needs. 525 526 The access fact stays in both branches because it is true in both: the 527 gate deliberately keeps a past_due member in, and a coach who reads 528 "declined" as "already gone" removes somebody who is still paying up. It 529 is the REASON clause that has to change. */ 530 rows += '<div class="hint" style="margin-top:6px">' + 531 (inv.next_attempt 532 ? 'They still have access while this is being retried.' 533 : 'They still have access, but nothing more will happen on its own.') + 534 '</div>'; 535 if (inv.can_retry) { 536 rows += '<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">' + 537 '<button class="btn btn-secondary" type="button" data-mp-retry="' + _esc(inv.id) + '" data-mp-contact="' + _esc(String(contactId)) + '">Try the card again</button>' + 538 (inv.hosted_invoice_url 539 ? '<a class="btn btn-secondary" href="' + _esc(inv.hosted_invoice_url) + '" target="_blank" rel="noopener">Open the invoice</a>' 540 : '') + 541 '<span class="hint" data-mp-retry-msg style="margin:0"></span></div>'; 542 } 543 return _card('Billing', rows); 544 } 545 546 function _kindLabel(k) { 547 if (_KIND_LABEL[k]) return _KIND_LABEL[k]; 548 var s = String(k || '').replace(/_/g, ' ').trim(); 549 return s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Email'; 550 } 551 552 function _sent(iso) { 553 if (!iso) return ''; 554 var d = new Date(iso); 555 if (isNaN(d.getTime())) return ''; 556 return App.Dates.shortDateTime(d); 557 } 558 559 function _emailRows(emails, sinceNote) { 560 if (!emails.length) { 561 return _zero(sinceNote 562 ? 'No emails recorded for them. We started keeping this history on ' + 563 _date(sinceNote) + ', so anything sent before that is not here.' 564 : 'No emails recorded for them yet.'); 565 } 566 return emails.map(function (e) { 567 /* ð¨ b16 (Alex's decision #31: "Failed emails we should be showing 568 them"). An email that did not go is listed with what happened to it, 569 in plain words ("Receipt not delivered: address bounced"), and a 570 Resend where the server says it can be sent again. */ 571 var failed = e.status === 'failed'; 572 var problem = failed 573 ? '<div style="margin-top:3px;font-size:12.5px;color:#b91c1c" data-mp-email-problem>' + 574 _esc(_kindLabel(e.kind) + ' ' + (e.problem || 'not sent')) + '</div>' + 575 (e.can_resend 576 ? '<div style="margin-top:6px;display:flex;gap:8px;align-items:center">' + 577 '<button type="button" class="btn btn-secondary btn-sm" data-mp-resend="' + _esc(String(e.id)) + '">Resend</button>' + 578 '<span class="hint" data-mp-resend-msg style="margin:0"></span></div>' 579 : (e.resent ? '<div class="hint" style="margin-top:3px">Sent again since.</div>' : '')) 580 : ''; 581 return '<div style="padding:9px 2px;border-top:1px solid var(--border-soft)"' + (failed ? ' data-mp-email-failed' : '') + '>
581' + 582 '<div style="font-weight:600;font-size:13px">' + 583 _esc(e.subject || _kindLabel(e.kind)) + '</div>' + 584 '<div class="hint" style="margin-top:2px">' + 585 _esc(_kindLabel(e.kind)) + ' · ' + _esc(_sent(e.sent_at)) + '</div>' + 586 problem + 587 '</div>'; 588 }).join(''); 589 } 590 591 function _emailPills(counts, total, active) { 592 var pills = [{ source: '', label: 'All', n: total }]; 593 (counts || []).forEach(function (c) { 594 pills.push({ source: c.source, label: _SOURCE_LABEL[c.source] || _kindLabel(c.source), n: c.n }); 595 }); 596 // One pill is not a filter, it is a label. Offering "All" beside the only 597 // source a member has ever been emailed from is a control that cannot 598 // change anything on screen. 599 if (pills.length <= 2) return ''; 600 return '<div style="display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px">' + 601 pills.map(function (p) { 602 var on = String(p.source) === String(active || ''); 603 return '<button type="button" class="btn btn-sm" data-mp-email-pill="' + _esc(p.source) + '"' + 604 ' aria-pressed="' + (on ? 'true' : 'false') + '"' + 605 ' style="border-radius:999px;padding:3px 11px;font-size:12px' + 606 (on ? ';background:var(--brand);color:#fff;border-color:var(--brand)' : '') + '">' + 607 _esc(p.label) + ' (' + p.n + ')</button>'; 608 }).join('') + '</div>'; 609 } 610 611 function _emailsSection(res, active) { 612 if (res.state === 'unavailable') return ''; 613 if (res.state === 'failed') return _card('Emails we have sent them', _failed('their email history')); 614 var d = res.data || {}; 615 var emails = Array.isArray(d.emails) ? d.emails : []; 616 return _card('Emails we have sent them', 617 '<div data-mp-emails>' + 618 _emailPills(d.counts, d.total, active) + 619 '<div data-mp-email-list>' + _emailRows(emails, d.logging_since) + '</div>' + 620 '</div>'); 621 } 622 623 // ââ Render ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 624 625 async function render(el, ctx) { 626 ctx = ctx || {}; 627 var api = ctx.api; 628 var contactId = ctx.contactId; 629 if (!el || !api || contactId == null) return; 630 631 // The header paints from the row the coach just clicked, so the person's 632 // name is on screen before any fetch resolves. 633 el.innerHTML = '<div data-mp-root>' + _header(ctx, ctx.member || {}) + 634 '<div class="hint" style="padding:10px 0" data-mp-body>Loadingâ¦</div></div>'; 635 _bind(el, ctx); 636 637 var cid = encodeURIComponent(String(contactId)); 638 var jobs = [ 639 _section('community profile', api('/community-hosting/members/' + cid)), 640 _section('email history', api('/community-hosting/members/' + cid + '/emails')), 641 _section('billing', api('/community-hosting/members/' + cid + '/billing')), 642 ]; 643 if (ctx.courseId != null) { 644 var course = encodeURIComponent(String(ctx.courseId)); 645 jobs.push(_section('course progress', api('/community-hosting/courses/' + course + '/members/' + cid))); 646 jobs.push(_section('assistant turns', api('/community-hosting/courses/' + course + '/assistant/turns?limit=100&contact_id=' + cid))); 647 } else { 648 jobs.push(Promise.resolve({ state: 'unavailable', data: null })); 649 jobs.push(Promise.resolve({ state: 'unavailable', data: null })); 650 } 651 var out = await Promise.all(jobs); 652 var communityRes = out[0], emailsRes = out[1], billingRes = out[2], courseRes = out[3], turnsRes = out[4]; 653 654 // Refreshed member facts win over the clicked row when the profile read 655 // exists; otherwise the row stands. 656 var merged = {}; 657 var src = [ctx.member || {}, (communityRes.state === 'ok' && communityRes.data && communityRes.data.member) || {}, 658 (courseRes.state === 'ok' && courseRes.data && courseRes.data.member) || {}]; 659 src.forEach(function (o) { Object.keys(o || {}).forEach(function (k) { if (o[k] != null) merged[k] = o[k]; }); }); 660 661 // Member approval: what they said when they asked to join. Kept after 662 // they are let in, so the coach can come back to it.
663 var answers = _answerList(merged.membership_answers); 664 var answersHtml = answers.length 665 ? _card('Their answers', answers.map(function (a) { 666 return '<div style="margin:0 0 10px"><div style="font-weight:600">' + _esc(a.prompt || '') + '</div>' + 667 '<div style="white-space:pre-wrap">' + _esc(a.answer || '') + '</div></div>'; 668 }).join('')) 669 : ''; 670 var body = 671 answersHtml + 672 // First, because a member whose payment is failing is the one thing on 673 // this panel that is time-sensitive. 674 _billingSection(billingRes, contactId) + 675 _courseSection(courseRes) + 676 _checksSection(courseRes) + 677 _turnsSection(turnsRes, contactId) + 678 _commentsSection(courseRes) + 679 _communitySection(communityRes) + 680 _emailsSection(emailsRes, ''); 681 682 if (!body) { 683 // Everything this panel can read is still unbuilt on the server. Say 684 // that, rather than drawing five empty boxes that look like a member 685 // who has done nothing. 686 body = _card('Nothing to show yet', 687 _zero('There is no activity recorded for this member yet.')); 688 } 689 el.innerHTML = '<div data-mp-root>' + _header(ctx, merged) + body + '</div>'; 690 _bind(el, ctx); 691 } 692 693 /* Answers in either stored shape: [{ prompt, answer }] (what the request 694 route writes, and what the server now always returns) or an object keyed 695 by question id. The second used to render as nothing at all. */ 696 function _answerList(raw) { 697 if (!raw || typeof raw !== 'object') return []; 698 var list = Array.isArray(raw) ? raw : Object.keys(raw).map(function (k) { return { prompt: k, answer: raw[k] }; }); 699 return list.filter(function (a) { return a && typeof a === 'object' && a.answer != null && String(a.answer).trim(); }) 700 .map(function (a) { return { prompt: String(a.prompt || a.id || ''), answer: String(a.answer) }; }); 701 } 702 703 function _bind(el, ctx) { 704 Array.prototype.forEach.call(el.querySelectorAll('[data-mp-decide]'), function (b) { 705 b.addEventListener('click', function () { 706 if (ctx.onDecide) ctx.onDecide(b.getAttribute('data-mp-decide')); 707 }); 708 }); 709 var back = el.querySelector('[data-mp-back]'); 710 if (back && ctx.onBack) back.addEventListener('click', function () { ctx.onBack(); }); 711 // The order that bought their place, opened where Refund lives. 712 var ob = el.querySelector('[data-mp-order]'); 713 if (ob) ob.addEventListener('click', function (ev) { 714 if (ev && ev.preventDefault) ev.preventDefault(); 715 var oid = parseInt(ob.getAttribute('data-mp-order'), 10); 716 if (oid && window.OrdersList && typeof window.OrdersList.openOrder === 'function') window.OrdersList.openOrder(oid); 717 }); 718 var msg = el.querySelector('[data-mp-message]'); 719 if (msg && ctx.onMessage) msg.addEventListener('click', function () { ctx.onMessage(ctx.contactId); }); 720 else if (msg && !ctx.onMessage) msg.remove(); 721 _bindRetry(el, ctx); 722 _bindEmailPills(el, ctx); 723 } 724 725 /* â ï¸ THIS ASKS A BANK FOR MONEY, so it says what happened every time â including 726 when the answer is another decline. A retry that silently does nothing leaves 727 the coach pressing it again, and each press is a real charge attempt against 728 a real card. The button disables itself for the duration for the same reason. */ 729 function _bindRetry(el, ctx) { 730 var btn = el.querySelector('[data-mp-retry]'); 731 if (!btn || !ctx.api) return; 732 btn.addEventListener('click', async function () { 733 var out = el.querySelector('[data-mp-retry-msg]'); 734 var invoiceId = btn.getAttribute('data-mp-retry'); 735 var contactId = btn.getAttribute('data-mp-contact'); 736 btn.disabled = true; 737 var was = btn.textContent; 738 btn.textContent = 'Tryingâ¦'; 739 if (out) { out.textContent = ''; out.style.color = ''; } 740 try { 741 var r = await ctx.api('/community-hosting/members/' + encodeURIComponent(contactId) + '/billing/retry', 742 { method: 'POST', body: { invoice_id: invoiceId } }); 743 if (r && r.ok) { 744 if (out) { out.textContent = 'Paid. Their membership is back in good standing.'; out.style.color = 'var(--good,#15803d)'; } 745 btn.remove(); 746 return; 747 } 748 /* A second decline is the ANSWER, not a failure of ours â Stripe's own 749 words go straight to the coach so they can tell the member something 750 specific rather than "it did not work". */ 751 if (out) { 752 out.textContent = (r && r.message) || 'The card was declined again.'; 753 out.style.color = 'var(--danger,#b91c1c)'; 754 } 755 } catch (e) { 756 var code = (e && e.message) || ''; 757 if (out) { 758 out.textContent = /invoice_not_retryable/.test(code) 759 ? 'There is nothing to retry. This invoice is already settled or written off.' 760 : 'Could not reach Stripe just now. Try again in a moment.'; 761 out.style.color = 'var(--danger,#b91c1c)'; 762 } 763 } finally { 764 if (btn.isConnected) { btn.disabled = false; btn.textContent = was; } 765 } 766 }); 767 } 768 769 /* The pills re-READ rather than re-filter. Delegated off the section root so 770 one listener survives the list being replaced, and only the LIST is 771 replaced â redrawing the pills too would make the row the coach just 772 clicked jump under their cursor. 773 774 â ï¸ A FAILED FILTER SAYS SO. Swallowing the error would leave the previous 775 source's emails on screen under the newly-highlighted pill: every row 776 wrong, and nothing to indicate it. */ 777 function _bindEmailPills(el, ctx) { 778 var root = el.querySelector('[data-mp-emails]'); 779 if (!root || !ctx.api || ctx.contactId == null) return; 780 /* b16 (#31): Resend one email that did not go. The server sends it through 781 the same sender as the first attempt, and the list is read again so the 782 new attempt (sent, or failed with its reason) is what the coach sees. */ 783 root.addEventListener('click', async function (ev) { 784 var rb = ev.target && ev.target.closest && ev.target.closest('[data-mp-resend]'); 785 if (!rb || !root.contains(rb)) return; 786 var msg = rb.parentNode && rb.parentNode.querySelector('[data-mp-resend-msg]'); 787 rb.disabled = true; rb.textContent = 'Sendingâ¦';
788 try { 789 var out = await ctx.api('/community-hosting/members/' + encodeURIComponent(String(ctx.contactId)) + 790 '/emails/' + encodeURIComponent(rb.getAttribute('data-mp-resend')) + '/resend', { method: 'POST', body: {} }); 791 var list = root.querySelector('[data-mp-email-list]'); 792 var d = await ctx.api('/community-hosting/members/' + encodeURIComponent(String(ctx.contactId)) + '/emails'); 793 if (list) list.innerHTML = _emailRows(Array.isArray(d && d.emails) ? d.emails : [], d && d.logging_since); 794 var say = out && out.sent ? 'Sent again.' : 'It did not go this time either. The reason is on the list.'; 795 if (typeof window.showToast === 'function') window.showToast(say, out && out.sent ? 'success' : 'info'); 796 } catch (err) { 797 rb.disabled = false; rb.textContent = 'Resend'; 798 if (msg) msg.textContent = (err && err.body && err.body.reason) || 'That did not go through. Try again in a moment.'; 799 } 800 }); 801 root.addEventListener('click', async function (ev) { 802 var btn = ev.target && ev.target.closest && ev.target.closest('[data-mp-email-pill]'); 803 if (!btn || !root.contains(btn)) return; 804 var source = btn.getAttribute('data-mp-email-pill') || ''; 805 var list = root.querySelector('[data-mp-email-list]'); 806 if (!list) return; 807 808 root.querySelectorAll('[data-mp-email-pill]').forEach(function (b) { 809 var on = (b.getAttribute('data-mp-email-pill') || '') === source; 810 b.setAttribute('aria-pressed', on ? 'true' : 'false'); 811 b.style.background = on ? 'var(--brand)' : ''; 812 b.style.color = on ? '#fff' : ''; 813 b.style.borderColor = on ? 'var(--brand)' : ''; 814 }); 815 816 list.innerHTML = '<div class="hint" style="padding:6px 0">Loadingâ¦</div>'; 817 try { 818 var d = await ctx.api('/community-hosting/members/' + 819 encodeURIComponent(String(ctx.contactId)) + '/emails' + 820 (source ? '?source=' + encodeURIComponent(source) : '')); 821 list.innerHTML = _emailRows( 822 Array.isArray(d && d.emails) ? d.emails : [], d && d.logging_since); 823 } catch (err) { 824 try { console.error('[member-panel] email filter failed â', (err && err.message) || err); } catch (_) {} 825 list.innerHTML = _failed('that filter'); 826 } 827 }); 828 } 829 830 App.MemberPanel = { render: render }; 831})();
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.