PageSourceSearch

https://app.replicants.ai/js/member-panel.js?v=20261003r9lf

js replicants.ai collected 2026-10-05 12:30:47 UTC 44,979 bytes, 831 lines download raw bytes

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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.