Pipeline: sponsor follow-up board (stages from the ledger, notes/follow-ups/tags, stage messages into chat), gated by pipelineMode with a coming-soon card until launch; training cards can wait on the same switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+76
-2
@@ -632,8 +632,8 @@
|
||||
}
|
||||
|
||||
// ── back-office menu: hash-routed panes ───────────────
|
||||
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
|
||||
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
|
||||
const PANES = ['overview', 'line', 'pipeline', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
|
||||
const TITLES = { overview: 'Overview', line: 'My line', pipeline: 'Pipeline', buy: 'Buy packages', campaigns: 'Campaigns',
|
||||
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', training: 'Training',
|
||||
wallet: 'Wallet & account', profile: 'Profile' };
|
||||
function setPane(name) {
|
||||
@@ -654,6 +654,7 @@
|
||||
if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); }
|
||||
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
|
||||
if (name === 'training') loadTraining();
|
||||
if (name === 'pipeline') loadPipeline();
|
||||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||||
}
|
||||
@@ -1336,6 +1337,79 @@
|
||||
}
|
||||
// ── prospects: the member's own follow-up list ──
|
||||
let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
|
||||
// ── Pipeline: the follow-up board (Marty, 2026-09-15). Stages come from the server; cards never move by hand ──
|
||||
let PIPE = null, PIPE_KEY = null;
|
||||
const pipeAgo = ts => { const d = Math.floor((Date.now() - ts) / 86400000); return d <= 0 ? 'today' : d === 1 ? 'yesterday' : d < 30 ? d + 'd ago' : new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
|
||||
const pipeInvite = () => { const t = ($('inviteLine') && $('inviteLine').textContent) || ''; return /^https?:\/\//.test(t) ? t : ''; };
|
||||
function pipeCardHtml(c, showStage) {
|
||||
const today = new Date(); today.setHours(23, 59, 59, 999);
|
||||
const due = c.followUp && c.followUp <= today.getTime();
|
||||
const chips = [];
|
||||
if (c.stalled) chips.push('<span class="chip warm">stalled ' + c.quietDays + 'd</span>');
|
||||
if (due) chips.push('<span class="chip warm">follow up</span>');
|
||||
else if (c.followUp) chips.push('<span class="chip flat">' + new Date(c.followUp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '</span>');
|
||||
if (c.tag) chips.push('<span class="chip flat">' + esc(c.tag) + '</span>');
|
||||
if (c.kind === 'member' && c.buyers) chips.push('<span class="chip flat">' + c.buyers + ' buyer' + (c.buyers === 1 ? '' : 's') + '</span>');
|
||||
const stage = showStage ? (PIPE.stages.find(s => s.key === c.stage) || {}).label || '' : '';
|
||||
return '<div class="pipe-card' + (c.key === PIPE_KEY ? ' on' : '') + '" tabindex="0" data-pk="' + esc(c.key) + '">'
|
||||
+ '<div class="nm"><span>' + esc(c.name) + '</span><small>' + (c.kind === 'prospect' ? 'prospect' : (c.lastSeen ? 'seen ' + pipeAgo(c.lastSeen) : 'never signed in')) + '</small></div>'
|
||||
+ '<div class="sub">' + esc(stage ? stage + ' · ' + c.next : c.next) + '</div>'
|
||||
+ (c.note ? '<div class="note" title="' + esc(c.note) + '">' + esc(c.note) + '</div>' : '')
|
||||
+ (chips.length ? '<div>' + chips.join('') + '</div>' : '') + '</div>';
|
||||
}
|
||||
async function loadPipeline() {
|
||||
try {
|
||||
const r = await (await fetch('/api/my/pipeline')).json();
|
||||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||||
if (!r.live) {
|
||||
$('pipeSoon').hidden = false; $('pipeLive').hidden = true;
|
||||
$('pipeSoonEta').textContent = r.eta ? 'Opens ' + r.eta + '. It is on the roadmap so you can see what is coming and when.' : 'It is on the roadmap so you can see what is coming and when.';
|
||||
return;
|
||||
}
|
||||
PIPE = r; $('pipeSoon').hidden = true; $('pipeLive').hidden = false;
|
||||
const badge = $('pipeBadge'); if (badge) { const n = r.counts.due + r.counts.stalled; badge.hidden = !n; badge.textContent = n > 9 ? '9+' : n; }
|
||||
$('pipeDueCount').hidden = !r.due.length; $('pipeDueCount').textContent = r.due.length;
|
||||
$('pipeDue').innerHTML = r.due.length ? '<div class="pipe-due">' + r.due.map(c => pipeCardHtml(c, true)).join('') + '</div>' : '<p class="muted small">Nothing due. Set a follow-up date on any card and it shows up here.</p>';
|
||||
$('pipeSummary').textContent = r.counts.total ? r.counts.total + ' people on your board' + (r.counts.stalled ? ', ' + r.counts.stalled + ' stalled' : '') + '. Cards move on their own when something happens on the ledger; open one to add a note, a follow-up date or a tag.' : 'Nobody on your board yet. Add prospects on My line, and everyone who joins through your link appears here on their own.';
|
||||
$('pipeBoard').innerHTML = r.columns.map(col => '<div class="pipe-col"><h4>' + esc(col.label) + '<span>' + col.cards.length + '</span></h4><p class="hint">' + esc(col.hint) + '</p>' + col.cards.map(c => pipeCardHtml(c, false)).join('') + '</div>').join('');
|
||||
$('pipeLive').querySelectorAll('[data-pk]').forEach(el => {
|
||||
el.addEventListener('click', () => openPipeCard(el.dataset.pk));
|
||||
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPipeCard(el.dataset.pk); } });
|
||||
});
|
||||
if (PIPE_KEY) { const c = allPipeCards().find(x => x.key === PIPE_KEY); if (c) fillPipeCard(c); else { PIPE_KEY = null; $('pipeCard').hidden = true; } }
|
||||
} catch (e) { IAP.status('Could not load your pipeline.', 'bad'); }
|
||||
}
|
||||
function allPipeCards() { return PIPE ? PIPE.columns.flatMap(c => c.cards) : []; }
|
||||
function fillPipeCard(c) {
|
||||
$('pipeCardName').textContent = c.name;
|
||||
const stage = (PIPE.stages.find(s => s.key === c.stage) || {}).label || '';
|
||||
$('pipeCardMeta').textContent = stage + (c.kind === 'member' ? ' · joined ' + new Date(c.since).toLocaleDateString() + (c.lastSeen ? ' · last seen ' + pipeAgo(c.lastSeen) : ' · never signed in') + (c.bought ? ' · your qualifying buyer' : '') : ' · prospect' + (c.contact ? ' · ' + c.contact : ''));
|
||||
$('pipeCardNext').innerHTML = '<b>Next for them:</b> ' + esc(c.next) + (c.stalled ? ' <span class="chip warm">quiet ' + c.quietDays + ' days</span>' : '');
|
||||
const say = (c.say || '').replace('{{link}}', pipeInvite());
|
||||
$('pipeSayBlock').hidden = !say; $('pipeSay').textContent = say;
|
||||
$('pipeSend').hidden = c.kind !== 'member' || !c.email;
|
||||
$('pipeFollow').value = c.followUp ? new Date(c.followUp).toISOString().slice(0, 10) : '';
|
||||
const sel = $('pipeTag'); sel.innerHTML = PIPE.tags.map(t => '<option value="' + t + '"' + (t === c.tag ? ' selected' : '') + '>' + (t || 'no tag') + '</option>').join('');
|
||||
$('pipeNote').value = c.note || ''; $('pipeSaved').textContent = '';
|
||||
$('pipeCard').hidden = false;
|
||||
}
|
||||
function openPipeCard(key) {
|
||||
const c = allPipeCards().find(x => x.key === key); if (!c) return;
|
||||
PIPE_KEY = key; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.toggle('on', el.dataset.pk === key));
|
||||
fillPipeCard(c); $('pipeCard').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if ($('pipeCardClose')) $('pipeCardClose').addEventListener('click', () => { PIPE_KEY = null; $('pipeCard').hidden = true; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.remove('on')); });
|
||||
if ($('pipeSave')) $('pipeSave').addEventListener('click', async () => {
|
||||
if (!PIPE_KEY) return;
|
||||
try { await api('/api/my/pipeline/note', { key: PIPE_KEY, note: $('pipeNote').value, followUp: $('pipeFollow').value || null, tag: $('pipeTag').value }); $('pipeSaved').textContent = 'Saved.'; loadPipeline(); }
|
||||
catch (e) { IAP.status(e.message, 'bad'); }
|
||||
});
|
||||
if ($('pipeCopy')) $('pipeCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('pipeSay').textContent); $('pipeCopy').textContent = 'Copied'; setTimeout(() => { $('pipeCopy').textContent = 'Copy message'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); } });
|
||||
if ($('pipeSend')) $('pipeSend').addEventListener('click', () => {
|
||||
const c = allPipeCards().find(x => x.key === PIPE_KEY); if (!c || !c.email) return;
|
||||
openConvo(c.email, c.name);
|
||||
const ta = $('chatInput'); if (ta) { ta.value = $('pipeSay').textContent; ta.dispatchEvent(new Event('input')); ta.focus(); }
|
||||
});
|
||||
async function loadProspects() {
|
||||
try {
|
||||
const r = await (await fetch('/api/my/prospects')).json();
|
||||
|
||||
Reference in New Issue
Block a user