diff --git a/coach.js b/coach.js index 7cf0170..9395dab 100644 --- a/coach.js +++ b/coach.js @@ -229,20 +229,53 @@ async function nudgeTick() { catch (e) { console.error('nudge', acct.email, e.message); } if (nudges >= 40) break; // spread the load across ticks } - // weekly digest: every sponsor with at least one direct + // weekly member email (Marty, 2026-09-14, modelled on the mailer.gold weekly): credits sitting unspent, + // the streak, the contest standings with the member's own rank, the tank, and the line for sponsors + let lbWeek = null; try { lbWeek = X.lb ? await X.lb().view('week') : null; } catch (e) {} + let tankN = 0; try { tankN = X.tank ? (await X.tank.waiting()).length : 0; } catch (e) {} for (const acct of all) { if (!acct.email) continue; const lastD = await impl().lastDigest(acct.email); if (now - lastD < 7 * DAY) continue; + if (now - (acct.lastSeen || acct.created || 0) > 45 * DAY) { await impl().setDigest(acct.email, now); continue; } // gone quiet for six weeks: leave them to the nudges const view = await coachView(acct.email); - if (!view.directs.length) { await impl().setDigest(acct.email, now); continue; } - const week = view.directs.filter(d => now - d.joined < 7 * DAY).length; - const lines = view.directs.slice(0, 25).map(d => '- ' + d.name + ': ' + d.label + (d.stalled ? ' (quiet ' + d.quietDays + ' days)' : '') + '. Next: ' + d.next); - const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => '- Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"'); - const body = 'Your line this week:\n' + week + ' joined in the last 7 days. ' + view.stalled + ' of your ' + view.directs.length + ' directs have gone quiet.\n\n' - + lines.join('\n') + '\n\n' + (actions.length ? 'Three things to do today:\n' + actions.join('\n') + '\n\n' : '') - + 'Open My line to message anyone in one click: https://instantadpay.com/my#line\n\nInstantAdPay'; - try { await mailer.send(acct.email, 'Your line this week: ' + view.stalled + ' to nudge', body); digests += 1; } catch (e) { console.error('digest', acct.email, e.message); } + // audience: sponsors with directs (as before) unless the admin switched the weekly on for every member + const everyone = X.siteConfig && String(X.siteConfig().memberWeeklyEmail || '0') === '1'; + if (!everyone && !view.directs.length) { await impl().setDigest(acct.email, now); continue; } + const name = acct.username ? '@' + acct.username : 'there'; + const parts = ['Hi ' + name + ', your week on InstantAdPay:']; + // credits + try { + const ids = [acct.memberId, ...(await accounts.positions(acct.email)).map(p => p.memberId)].filter(Boolean); + const bal = await X.ads.balances(ids, acct.email); + if (bal.available > 0) parts.push('- You have ' + bal.available.toLocaleString() + ' ad credits sitting unspent. That is ' + bal.available.toLocaleString() + ' cents of delivery doing nothing. Campaigns > New campaign, point it at your invite link: https://instantadpay.com/my#campaigns'); + else if (bal.inCampaigns > 0) parts.push('- All ' + bal.inCampaigns.toLocaleString() + ' of your credits are working in live campaigns. Good.'); + } catch (e) {} + // streak + try { + const st = await X.ads.viewStatus(acct.email); + if (st.streakDay > 1) parts.push('- Your claim streak is on day ' + st.streakDay + '. Today\'s claim pays ' + (st.claimCredits || 0) + ' credits; miss a day and it restarts at 5.'); + else parts.push('- Five ads and a claim a day is the free way in: 5, 7, 10, then 25 credits every seventh day in a row: https://instantadpay.com/my#earn'); + } catch (e) {} + // contest + if (lbWeek && lbWeek.top) { + const me = lbWeek.top.find(r => r.name === '@' + acct.username) || null; + let mine = me; if (!mine) { try { mine = (await X.lb().view('week', acct.email)).me; } catch (e) {} } + parts.push('- Referral contest this week (' + (lbWeek.prize || 'credits to the top 3') + '): ' + (lbWeek.top.length ? lbWeek.top.slice(0, 3).map(r => r.rank + '. ' + r.name + ' (' + r.sales + ' sold)').join(', ') : 'no sales yet, first sale takes the top spot') + '.' + + (mine ? ' You are #' + mine.rank + ' with ' + mine.sales + ' sold.' : ' You are not on the board yet; one $20 package bought by someone you sponsor puts you there.') + ' https://instantadpay.com/leaderboard'); + } + // tank + if (tankN > 0 && acct.memberId) parts.push('- ' + tankN + ' member' + (tankN === 1 ? ' is' : 's are') + ' waiting for a sponsor in the holding tank. Adopt one from My line: https://instantadpay.com/my#line'); + // line (sponsors only) + if (view.directs.length) { + const week = view.directs.filter(d => now - d.joined < 7 * DAY).length; + parts.push('- Your line: ' + view.directs.length + ' direct' + (view.directs.length === 1 ? '' : 's') + ', ' + week + ' joined this week, ' + view.stalled + ' gone quiet.'); + const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => ' Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"'); + if (actions.length) parts.push(actions.join('\n')); + } + parts.push('\nTwenty minutes, in order: the set, one message to your line, the tank, one conversation outward. https://instantadpay.com/blog/the-twenty-minute-day\n\nInstantAdPay · https://instantadpay.com/my\nNo income is guaranteed. Credits are advertising, not money.'); + const subject = lbWeek && lbWeek.top && lbWeek.top.length && lbWeek.top[0].name === '@' + acct.username ? 'You are #1 this week on InstantAdPay' : 'Your week on InstantAdPay: credits, streak, contest'; + try { await mailer.send(acct.email, subject, parts.join('\n')); digests += 1; } catch (e) { console.error('digest', acct.email, e.message); } await impl().setDigest(acct.email, now); if (digests >= 30) break; } @@ -250,7 +283,8 @@ async function nudgeTick() { return { nudges, digests }; } -function init(opts) { +let X = {}; // extra refs for the weekly member email (ads, tank, lb getter), 2026-09-14 +function init(opts) { X = arguments[0] || {}; DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer; J.load(); } diff --git a/leaderboard.js b/leaderboard.js index 414098a..89a04fe 100644 --- a/leaderboard.js +++ b/leaderboard.js @@ -73,6 +73,8 @@ async function computeRaw(period) { if (s && s.email !== a.email) joins[s.email] = (joins[s.email] || 0) + 1; } for (const [em, n] of Object.entries(joins)) { const r = rows[em] = rows[em] || { email: em, name: names[em] || em, sales: 0, cents: 0, pol: 0n, buyers: new Set() }; r.joins = n; } + // the admin's own account (company placements, tank arrivals) is not a contestant + if (R.adminEmail && rows[R.adminEmail]) delete rows[R.adminEmail]; const out = Object.values(rows).map(r => ({ email: r.email, name: r.name, sales: r.sales, usd: r.cents / 100, pol: Number(r.pol / 10n ** 14n) / 10000, buyers: r.buyers.size, joins: r.joins || 0 })) .sort((a, b) => b.usd - a.usd || b.sales - a.sales || b.joins - a.joins); out.forEach((r, i) => { r.rank = i + 1; }); @@ -131,7 +133,7 @@ async function renderPage() { let h = 'Leaderboard | InstantAdPay' + '' + '
' - + '

Referral contest

Leaderboard: who is selling.

Ranked by ad packages sold to other people (your own positions never count). Read from the chain, updated live. Weeks run Monday to Sunday, Central time.

'; + + '

Referral contest

Leaderboard: who is selling.

Ranked by ad packages sold to other people (your own positions never count, and the company account is not a contestant). Read from the chain, updated live. Weeks run Monday to Sunday, Central time.

'; h += '

' + week.label + ' ' + fmtD(week.start) + ' to Sunday

' + (week.prize ? '
Weekly prizes: ' + esc(week.prize) + '
' : '') + table(week); h += '

' + month.label + '

' + (month.prize ? '
Monthly prizes: ' + esc(month.prize) + '
' : '') + table(month); h += '

All time

' + table(all); diff --git a/public/assets/admin.js b/public/assets/admin.js index 6ed60c9..2352357 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -757,7 +757,7 @@ })); // site settings: key / value rows; booleans as checkboxes, numbers stay numbers - const SITE_META = { 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', 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 = { 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', 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/server.js b/server.js index d9b3e07..a07809a 100644 --- a/server.js +++ b/server.js @@ -358,7 +358,7 @@ async function boot() { setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000); setInterval(() => ads.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) - coach.init({ dataDir: DATA_DIR, chain, accounts, mailer }); + coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig }); tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' }); legacy.init({ dataDir: DATA_DIR }); traffic.init({ dataDir: DATA_DIR }); @@ -368,7 +368,7 @@ async function boot() { loadOpenTokens(); syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR }); releases.init({ dataDir: DATA_DIR }); - leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, + leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL, notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } }); setTimeout(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 90 * 1000); setInterval(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 60 * 60 * 1000); @@ -405,6 +405,7 @@ function siteConfig() { telegramEchoChatId: '', telegramEchoTopicId: '', telegramEchoEvents: 'payouts', // shared cross-program payments topic telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark + memberWeeklyEmail: '0', // 1 = the weekly 'Your week on InstantAdPay' email goes to every active member, not only sponsors with a line leaderboardWeeklyPrize: '', leaderboardMonthlyPrize: '', leaderboardWeeklyCredits: '1000,500,250', leaderboardMonthlyCredits: '5000,2500,1000', leaderboardAnnounceGeneral: '1', // referral contest prizes (text shown on /leaderboard; credits granted to the winner automatically at rollover) legacyCreditsAdvertiser: 500, legacyCreditsEarner: 150, // welcome-back credits for listed Faucet Wave / Tier One Ads emails arriving via /from/ geoTier1: '', // comma-separated ISO country codes; empty = built-in default (US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE)