diff --git a/chatbot.js b/chatbot.js index 1222490..3ca2226 100644 --- a/chatbot.js +++ b/chatbot.js @@ -85,6 +85,7 @@ FACTS: - PROMO TOOLKIT BY BADGE + AI COPY ENGINE (2026-09-14): Promo tools > AI Copy Engine shows the ladder: Free (links, posts, swipes, banners, wall, objections, shorts, badge pages), Spark (payouts on: one-tap campaign templates aimed at the member's link, printable handout with their QR at /handout/), Surge (first qualifying buyer; the AI Copy Engine is live: posts, DMs, follow-ups, objection replies, emails, story posts, team broadcasts in the member's name with their link, honesty rules built in; 20 free generations a month), Circuit (60 free; Video Maker renders every promo video and short with the member's own end card and QR, hosted for download; split tester compares join angles by views, joins, buyers), Nexus (150 free; Leader Ops: three-level team triage with stalled flags and one-click nudges, AI-drafted team broadcasts, credit grants from the leader's earned pool to anyone in their line, co-branded join page showing the sponsor's bio, and the member's own partner code: welcome credits funded from the leader's pool at each redemption, plus a partner kit page /partners?ref=&promo=CODE). After the free allowance each generation costs 10 ad credits from the earned pool. Credits are advertising, never money. - MEMBER UPDATE EMAILS (2026-09-14, admin only): Admin > Releases > Email an update to members: pick release notes, add an intro, choose an audience (newsletter opt-ins, opt-ins active in the last 30 days, or everyone including those who declined), preview, send a test to the admin, then send; plain text from no-reply@instantadpay.com with an opt-out link; the log shows sent/opted out/failed. - LINKED POSITIONS AND BADGES (2026-09-14): qualifying buyers on a member's linked positions count toward their achievement badges (Spark/Surge/Circuit/Nexus) and light their chip on their sponsor's line, and a badge once earned never regresses. The contract still pays levels 2 and 3 to each position based on that position's OWN qualifying buyers, so the Qualifying buyers tile shows the main position's count with the linked positions' count underneath. +- PIPELINE (coming soon, built 2026-09-15; opens when the site setting pipelineMode is on): a follow-up board on the dashboard, tab 'Pipeline' between My line and Buy packages. Columns: Talking to, Joined, Wallet linked, Payouts on, Bought, Building, Later. Prospects (from the My line prospect list) and directs are placed automatically from what they have actually done (coaching rung, on-chain buys); nobody drags cards. The sponsor adds a note, a follow-up date and a tag (hot, later, no response, not interested); a 'Follow up today' strip lists due cards; stalled cards (quiet 3+ days) are flagged; each card carries the message for its exact stage with 'Open chat with this message' (member chat) or copy. Until it opens, the tab shows a coming-soon card with the roadmap date. - PAYMENT + MISSED-PAYMENT NOTICES (2026-09-15): every payout that lands (who bought, level, share in POL and dollars, transaction link) and every missed payout is delivered BOTH by email and as an on-site inbox message from the company account, shown in the login pop-up and the Messages card, so it is waiting when the member signs in. MISSED-PAYOUT detail: when a level-2 or level-3 share passes a member by because that level is not open on their account, the member gets an email the same minute: who bought, the POL and dollar amount they missed, how many qualifying buyers they have versus the 2 or 5 needed, and the two ways to close the gap (bring buyers, or Qualified Start). Qualified members whose wallet rejected a transfer get a different email telling them to link a regular wallet. - LAUNCH WEEK SWIPES (2026-09-15): the founding-week checklist page (/launch) ends with four promoter emails members send to their OWN lists, one a day toward Monday's opening, with the member's invite link and the FOUNDER code (500 credits for anyone who joins before Mon 2026-09-21 9 AM Central) filled in, each with a Copy button. These are swipes for members, not emails the site sends. - LOGIN ADS CHARGE ONLY ON DAYS SHOWN (2026-09-15): the login ad daily fee (100 credits) is charged only for days the ad was actually shown at least once; with many login ads sharing the sign-ins, an ad that was not picked that day pays nothing. Featured links now count views (one per viewer per hour) as well as clicks. diff --git a/db.js b/db.js index 96dbafc..8322471 100644 --- a/db.js +++ b/db.js @@ -185,6 +185,15 @@ async function bootstrap() { updated BIGINT NOT NULL, INDEX (owner_email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS pipeline_notes ( + owner_email VARCHAR(190) NOT NULL, + person VARCHAR(190) NOT NULL, + note VARCHAR(1000) NULL, + follow_up BIGINT NULL, + tag VARCHAR(20) NULL, + updated BIGINT NOT NULL, + PRIMARY KEY (owner_email, person) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS join_views ( id INT AUTO_INCREMENT PRIMARY KEY, token VARCHAR(40) NOT NULL, diff --git a/pipeline.js b/pipeline.js new file mode 100644 index 0000000..d913152 --- /dev/null +++ b/pipeline.js @@ -0,0 +1,113 @@ +// Pipeline (Marty, 2026-09-15): a follow-up board for sponsors. One screen where every prospect +// and every direct sits in a column by what they have actually done. Nobody drags a card: the +// site moves it when the event fires (wallet linked, payouts on, first buy, buyers counted). +// The sponsor adds what the site cannot know: a note, a follow-up date, an outcome tag. +// Built before launch, shown as "coming soon" until site setting pipelineMode = on +// (preview = only the admin account sees the live board, for testing and the training video). +// Dual-mode store like the other modules (MySQL when DATABASE_URL is set, JSON on the volume). +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); + +let DATA_DIR = null, accounts = null, coach = null; +const DAY = 86400000; + +// columns, in order. Prospects live in the first and last; directs land by coaching rung. +const STAGES = [ + { key: 'talking', label: 'Talking to', hint: 'People you have reached out to who have not joined yet.' }, + { key: 'joined', label: 'Joined', hint: 'Joined free. Next: link a wallet.' }, + { key: 'wallet', label: 'Wallet linked', hint: 'Next: switch on payouts, one free transaction.' }, + { key: 'payouts', label: 'Payouts on', hint: 'Next: a first package. $20 or more counts for you.' }, + { key: 'bought', label: 'Bought', hint: 'Your qualifying buyer. Next: their first person.' }, + { key: 'building', label: 'Building', hint: 'They have buyers of their own. Coach them to their two.' }, + { key: 'later', label: 'Later', hint: 'Parked: not now, no response, not interested.' } +]; +const TAGS = ['', 'hot', 'later', 'no response', 'not interested']; +const RUNG_STAGE = ['joined', 'wallet', 'payouts', 'bought', 'building', 'building', 'building']; +const PARKED = new Set(['later', 'no response', 'not interested']); + +// ---- stores: one row per (owner, person) ---- +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'pipeline.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, + async notes(owner) { if (!this.db) this.load(); return Object.values(this.db).filter(r => r.owner === owner); }, + async put(owner, person, f) { if (!this.db) this.load(); const k = owner + '|' + person; this.db[k] = Object.assign(this.db[k] || { owner, person }, f, { updated: Date.now() }); this.save(); return this.db[k]; } +}; +const rowR = r => ({ owner: r.owner_email, person: r.person, note: r.note || '', followUp: r.follow_up ? Number(r.follow_up) : null, tag: r.tag || '', updated: Number(r.updated) }); +const D = { + async notes(owner) { return (await db.q('SELECT * FROM pipeline_notes WHERE owner_email=?', [owner])).map(rowR); }, + async put(owner, person, f) { + await db.q('INSERT INTO pipeline_notes (owner_email,person,note,follow_up,tag,updated) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE note=VALUES(note), follow_up=VALUES(follow_up), tag=VALUES(tag), updated=VALUES(updated)', + [owner, person, f.note, f.followUp || null, f.tag || '', Date.now()]); + return rowR((await db.q('SELECT * FROM pipeline_notes WHERE owner_email=? AND person=?', [owner, person]))[0]); + } +}; +const impl = () => db.enabled() ? D : J; + +function init(opts) { DATA_DIR = opts.dataDir; accounts = opts.accounts; coach = opts.coach; } + +// who may see the live board: mode on = everyone; preview = the admin account only +function visible(mode, email, adminEmail) { + if (mode === 'on') return true; + if (mode === 'preview') return !!(email && adminEmail && String(email).toLowerCase() === String(adminEmail).toLowerCase()); + return false; +} + +async function board(email) { + const owner = String(email || '').toLowerCase(); + const now = Date.now(); const eod = new Date(); eod.setHours(23, 59, 59, 999); + const notes = {}; for (const n of await impl().notes(owner)) notes[n.person] = n; + const cards = []; + // prospects: the sponsor's own list of people they have talked to + for (const p of await coach.prospects(owner)) { + const n = notes['p:' + p.id] || {}; + let stage = 'talking'; + if (p.status === 'not now') stage = 'later'; + if (p.status === 'joined') stage = 'joined'; + if (p.status === 'bought') stage = 'bought'; + if (PARKED.has(n.tag)) stage = 'later'; + cards.push({ key: 'p:' + p.id, kind: 'prospect', name: p.name, contact: p.contact || '', status: p.status, stage, + since: p.updated || p.created || now, lastSeen: 0, quietDays: Math.floor((now - (p.updated || p.created || now)) / DAY), + stalled: false, next: p.status === 'contacted' || p.status === 'interested' ? 'Send your link and ask for a yes' : 'Reach out once', + say: 'Hey ' + p.name + ', here is the link I mentioned. Free to join with an email, and I will walk you through the first three steps: {{link}}', + note: n.note || p.note || '', followUp: n.followUp || p.nextTs || null, tag: n.tag || '' }); + } + // directs: stage from what they have actually done (the coaching rung) + const levels = await accounts.downline(owner, 1); + for (const m of (levels[0] ? levels[0].members : [])) { + let c = null; try { c = await coach.describe(m, now); } catch (e) {} + const n = notes[m.email] || {}; + const rung = c ? c.rung : 0; + const stage = PARKED.has(n.tag) && rung < 3 ? 'later' : RUNG_STAGE[rung] || 'joined'; + cards.push({ key: m.email, kind: 'member', email: m.email, name: m.username ? '@' + m.username : (m.memberId ? 'member #' + m.memberId : 'member'), + memberId: m.memberId || 0, stage, rung, rungLabel: c ? c.label : 'Joined', since: m.created || now, + lastSeen: m.lastSeen || 0, quietDays: c ? c.quietDays : 0, stalled: !!(c && c.stalled), + buyers: c ? c.buyerCount || 0 : 0, bought: !!(c && c.counted), + next: c ? c.next : 'Link a wallet', say: c ? c.say.replace('{{name}}', m.username || 'there') : '', + note: n.note || '', followUp: n.followUp || null, tag: n.tag || '' }); + } + const due = cards.filter(c => c.followUp && c.followUp <= eod.getTime()).sort((a, b) => a.followUp - b.followUp); + const columns = STAGES.map(s => ({ key: s.key, label: s.label, hint: s.hint, + cards: cards.filter(c => c.stage === s.key).sort((a, b) => (b.stalled - a.stalled) || (a.followUp || 9e15) - (b.followUp || 9e15) || b.since - a.since) })); + return { stages: STAGES, tags: TAGS, columns, due, counts: { total: cards.length, stalled: cards.filter(c => c.stalled).length, due: due.length } }; +} + +async function save(email, body) { + const owner = String(email || '').toLowerCase(); + const person = String(body.key || '').trim().toLowerCase().slice(0, 190); + if (!person) return { error: 'Pick who to update.' }; + // only people on the sponsor's own board + const ok = person.startsWith('p:') ? (await coach.prospects(owner)).some(p => 'p:' + p.id === person) + : await accounts.isDownlineOf(owner, person, 1); + if (!ok) return { error: 'Not on your board.' }; + const tag = TAGS.includes(String(body.tag || '').toLowerCase()) ? String(body.tag || '').toLowerCase() : ''; + let followUp = null; + if (body.followUp) { const t = /^\d{4}-\d{2}-\d{2}$/.test(body.followUp) ? Date.parse(body.followUp + 'T12:00:00') : Number(body.followUp); if (t && !isNaN(t)) followUp = t; } + const note = String(body.note || '').trim().slice(0, 1000); + const row = await impl().put(owner, person, { note, followUp, tag }); + return { ok: true, note: row }; +} + +module.exports = { init, STAGES, TAGS, visible, board, save }; diff --git a/public/assets/admin.js b/public/assets/admin.js index 04bbb56..96ca472 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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]) => '
' + esc(SITE_META[k] || humanize(k)) + '' diff --git a/public/assets/my.js b/public/assets/my.js index 48d7c39..fc3c084 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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('stalled ' + c.quietDays + 'd'); + if (due) chips.push('follow up'); + else if (c.followUp) chips.push('' + new Date(c.followUp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ''); + if (c.tag) chips.push('' + esc(c.tag) + ''); + if (c.kind === 'member' && c.buyers) chips.push('' + c.buyers + ' buyer' + (c.buyers === 1 ? '' : 's') + ''); + const stage = showStage ? (PIPE.stages.find(s => s.key === c.stage) || {}).label || '' : ''; + return '
' + + '
' + esc(c.name) + '' + (c.kind === 'prospect' ? 'prospect' : (c.lastSeen ? 'seen ' + pipeAgo(c.lastSeen) : 'never signed in')) + '
' + + '
' + esc(stage ? stage + ' · ' + c.next : c.next) + '
' + + (c.note ? '
' + esc(c.note) + '
' : '') + + (chips.length ? '
' + chips.join('') + '
' : '') + '
'; + } + 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 ? '
' + r.due.map(c => pipeCardHtml(c, true)).join('') + '
' : '

Nothing due. Set a follow-up date on any card and it shows up here.

'; + $('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 => '

' + esc(col.label) + '' + col.cards.length + '

' + esc(col.hint) + '

' + col.cards.map(c => pipeCardHtml(c, false)).join('') + '
').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 = 'Next for them: ' + esc(c.next) + (c.stalled ? ' quiet ' + c.quietDays + ' days' : ''); + 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 => '').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(); diff --git a/public/assets/site.css b/public/assets/site.css index a8b8d40..36dd487 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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}} diff --git a/public/my.html b/public/my.html index e3d9767..855c705 100644 --- a/public/my.html +++ b/public/my.html @@ -5,7 +5,7 @@ Member area | InstantAdPay - + @@ -130,6 +130,7 @@
+