Member updates: audience defaults to newsletter opt-ins (Sendy list status), explicit 'everyone' option; sendy.status()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 13:47:13 -05:00
parent 7bb956e896
commit 024170e7f8
5 changed files with 29 additions and 8 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ FACTS:
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
- 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/<username>), 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=<username>&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 (active 30 days, active 90 days, everyone), 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.
- 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.
- NO-PAYOUT POSITIONS (2026-09-14): admin can list member ids (Settings > No-payout positions) whose wallets must never be paid again (e.g. a compromised key). Those positions stay linked for history and counting only: the buy-from picker disables them, a purchase from them is refused, and invite links that would place a new member under them (or under their upline chain) route to the fallback sponsor instead. The contract itself cannot change a member's wallet.
- LINE AT A GLANCE, ALL LEVELS (2026-09-14): a gold chip on any level means that person made their $20+ buy (a qualifying buyer for their own sponsor); a small number on the chip is how many qualifying buyers of their own they have. My line rows say the same in words. Leaders use it to see who is one buyer short and encourage them.
+1 -1
View File
@@ -396,7 +396,7 @@
</div>
<div class="card" id="updCard">
<div class="card-head"><h3>Email an update to members</h3><span class="sub" id="updSub"></span></div>
<p class="small muted" style="margin:0 0 10px">Pick the notes to include, add a line or two in your own words, preview, send yourself a test, then send. Plain text from no-reply@instantadpay.com, one member at a time, opt-outs honoured. Nothing goes out until you press Send.</p>
<p class="small muted" style="margin:0 0 10px">Pick the notes to include, add a line or two in your own words, preview, send yourself a test, then send. Plain text from no-reply@instantadpay.com, one member at a time. The default audience is members who ticked the newsletter box at sign-up (the Sendy list is the record); unsubscribes are skipped. Nothing goes out until you press Send.</p>
<div class="grid c2">
<div style="display:grid;gap:8px">
<label class="small">Subject<input id="updSubject" type="text" maxlength="150" placeholder="What is new on InstantAdPay this week"></label>
+14 -1
View File
@@ -35,4 +35,17 @@ function subscribe(email, name) {
});
}
module.exports = { subscribe, enabled };
// 'Subscribed' | 'Unsubscribed' | 'Unconfirmed' | 'Bounced' | 'Soft bounced' | 'Complained' | 'Email does not exist in list' | '' (no key / unreachable)
function status(email) {
const key = apiKey(); const e = String(email || '').trim();
if (!key || !e) return Promise.resolve('');
const body = new URLSearchParams({ api_key: key, email: e, list_id: LIST }).toString();
const u = new URL(URL_BASE + '/api/subscribers/subscription-status.php');
return new Promise(resolve => {
const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d.trim().slice(0, 60))); });
req.on('error', () => resolve('')); req.on('timeout', () => { req.destroy(); resolve(''); }); req.end(body);
});
}
module.exports = { subscribe, enabled, status };
+1 -1
View File
@@ -378,7 +378,7 @@ async function boot() {
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
releases.init({ dataDir: DATA_DIR });
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain });
updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, adminEmail: ADMIN_EMAIL });
updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, sendy, adminEmail: ADMIN_EMAIL });
videomaker.init({ dataDir: DATA_DIR, spaces, accounts });
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); } });
+12 -4
View File
@@ -4,21 +4,29 @@
// Log: DATA_DIR/updates-log.json. Nothing here sends on its own: every send is a click in Admin > Releases.
const fs = require('fs');
const path = require('path');
let R = null; // { dataDir, accounts, releases, mailer, drip, adminEmail }
let R = null; // { dataDir, accounts, releases, mailer, drip, sendy, adminEmail }
const SITE = 'https://instantadpay.com';
const FILE = () => path.join(R.dataDir, 'updates-log.json');
function log() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return { sends: [] }; } }
function saveLog(l) { try { fs.writeFileSync(FILE(), JSON.stringify(l)); } catch (e) {} }
function init(refs) { R = refs; }
const AUDIENCES = { active30: 'Active in the last 30 days', active90: 'Active in the last 90 days', all: 'Every member' };
const AUDIENCES = { optin: 'Newsletter opt-ins (ticked the box at sign-up)', optin30: 'Newsletter opt-ins active in the last 30 days', all: 'Every member, including those who declined the newsletter' };
// the sign-up checkbox subscribes the member to the Sendy newsletter list, so Sendy is the record of who opted in
const optinCache = new Map(); // email -> { t, v }
async function optedIn(email) {
const c = optinCache.get(email); if (c && Date.now() - c.t < 6 * 3600000) return c.v;
const st = R.sendy ? await R.sendy.status(email) : ''; const v = st === 'Subscribed';
optinCache.set(email, { t: Date.now(), v }); return v;
}
async function recipients(kind) {
const all = await R.accounts.listAll(20000);
const now = Date.now(); const days = kind === 'active30' ? 30 : kind === 'active90' ? 90 : 0;
const now = Date.now(); const days = kind === 'optin30' ? 30 : 0; const needOptin = kind !== 'all';
const out = [];
for (const a of all) {
if (!a.email || /@(demo|example)\./i.test(a.email)) continue;
if (days && now - Math.max(a.lastSeen || 0, a.created || 0) > days * 86400000) continue;
if (needOptin && !(await optedIn(a.email))) continue;
out.push(a);
}
return out;
@@ -50,7 +58,7 @@ async function send(input, opts) {
return { ok: true, test: true, to };
}
if (running) return { error: 'A send is already running (' + running.sent + ' of ' + running.total + '). Wait for it to finish.' };
const kind = AUDIENCES[input.audience] ? input.audience : 'active30';
const kind = AUDIENCES[input.audience] ? input.audience : 'optin';
const list = await recipients(kind);
if (!list.length) return { error: 'Nobody in that audience.' };
const rec = { id: Date.now().toString(36), ts: Date.now(), subject: compose(input, null).subject, noteIds, audience: kind, total: list.length, sent: 0, skipped: 0, failed: 0, status: 'running' };