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:
martbost
2026-09-15 11:01:03 -05:00
parent 56aa7418ad
commit 02f742f90a
9 changed files with 282 additions and 6 deletions
+1 -1
View File
@@ -796,7 +796,7 @@
}));
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
function drawSite() {
const wrap = $('siteForm');
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
+76 -2
View File
@@ -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();
+18
View File
@@ -730,3 +730,21 @@ img{max-width:100%}
.camp-table td.act{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--line)} .camp-table td.act::before{display:none}
.camp-table td.act .btn{flex:1 1 auto;text-align:center}
}
/* Pipeline board (Marty, 2026-09-15): columns scroll sideways inside their own box, never the page */
.pipe-board{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px;scroll-snap-type:x proximity}
.pipe-col{flex:0 0 220px;scroll-snap-align:start;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
.pipe-col h4{margin:0 0 2px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);display:flex;justify-content:space-between;gap:8px}
.pipe-col h4 span{color:var(--muted);font-variant-numeric:tabular-nums}
.pipe-col .hint{font-size:11.5px;color:var(--muted);margin:0 0 8px;line-height:1.35}
.pipe-card{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin:0 0 8px;cursor:pointer;transition:border-color .15s}
.pipe-card:hover,.pipe-card:focus-visible{border-color:var(--mint);outline:none}
.pipe-card.on{border-color:var(--gold,#e6c15a)}
.pipe-card .nm{font-weight:700;font-size:14px;display:flex;justify-content:space-between;gap:6px;align-items:baseline}
.pipe-card .nm small{font-weight:400;font-size:11px;color:var(--muted);white-space:nowrap}
.pipe-card .sub{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.35}
.pipe-card .note{font-size:12px;color:var(--ink);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pipe-card .chip{margin-top:5px;margin-right:4px}
.pipe-due{display:grid;gap:6px}
.pipe-due .pipe-card{margin:0}
@media (max-width:640px){.pipe-form{grid-template-columns:1fr !important}.pipe-col{flex-basis:200px}}