diff --git a/accounts.js b/accounts.js index d2f513b..a0b2293 100644 --- a/accounts.js +++ b/accounts.js @@ -33,7 +33,7 @@ function newCode(taken) { return c; } const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null, - username: a.username || null, memberId: a.memberId || 0, + username: a.username || null, memberId: a.memberId || 0, joinedVia: a.joinedVia || null, lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null, chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0, @@ -74,11 +74,11 @@ const J = { if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' }; return { ok: true, account: pub(a) }; }, - async ensure(e, ref) { + async ensure(e, ref, via) { let created = false; if (!this.db.byEmail[e]) { const code = newCode(c => this.db.byCode[c]); - this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now() }; + this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now(), joinedVia: via || null }; this.db.byCode[code] = e; created = true; this.save(); @@ -171,7 +171,7 @@ const J = { // ---- MySQL mode ---- const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code, - username: r.username, memberId: r.member_id || 0, + username: r.username, memberId: r.member_id || 0, joinedVia: r.joined_via || null, lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url, avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials, chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0), @@ -195,12 +195,12 @@ const D = { if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' }; return { ok: true, account: rowPub(rows[0]) }; }, - async ensure(e, ref) { + async ensure(e, ref, via) { const code = newCode(); let created = false; try { - await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,NULL,?,?,NULL,?)', - [e, ref, code, Date.now()]); + await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via) VALUES (?,NULL,?,?,NULL,?,?)', + [e, ref, code, Date.now(), via || null]); created = true; } catch (err) { if (err.code !== 'ER_DUP_ENTRY') throw err; @@ -294,10 +294,10 @@ async function signup(email, password, sponsorRef) { return impl().signup(e, String(password), String(sponsorRef || '')); } async function login(email, password) { return impl().login(normEmail(email), String(password || '')); } -async function ensure(email, sponsorRef) { +async function ensure(email, sponsorRef, via) { const e = normEmail(email); if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; - return impl().ensure(e, String(sponsorRef || '')); + return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null); } async function byEmail(email) { return impl().byEmail(normEmail(email)); } async function byAddress(address) { return impl().byAddress(normAddr(address)); } diff --git a/chatbot.js b/chatbot.js index e6f92c5..0195853 100644 --- a/chatbot.js +++ b/chatbot.js @@ -56,6 +56,7 @@ function systemPrompt() { FACTS: - Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits — unlocked by the WELCOME TOUR on first sign-in: they visit their upline's line-banner sites (up to 3, 10 seconds each — the same 3 levels the contract pays), then claim the credits. Members with no upline banners get the credits instantly. - PROMO TOOLS (Members > Promo tools, pill menu): Social posts for X/Facebook/LinkedIn/Telegram-WhatsApp with post/share buttons; Text a friend (5 SMS-sized messages with Text it / WhatsApp / Telegram / Copy); Email swipes (short, standard, long, follow-up); Banners in every standard ad size plus 1080x1080, 1080x1920 and 1280x720 (download or copy URL); the member's Banner wall link; an Objection handling bank (truth + ready-to-send reply); a Videos tab (hook videos in production). Every piece carries the member's invite link; angle links add ?v=instant|adspend|free|ledger. Members who want copy in their own voice can use mybrandedvoice.com. +- INVITE PAGES: a member's link instantadpay.com/join/ opens a lead-capture page (email first, wallet later); add ?v=instant|adspend|free|ledger|two for an angle-matched headline. New free members get a short getting-started email series over the first week (unsubscribe link in every email; the admin edits the sequence in /admin > Settings). - LINE BANNER (free, set in Profile): every member can set a destination URL (must allow framing) plus an optional banner image. It is shown to their next THREE levels of new members during welcome tours (position 1 for directs, 2, 3 below), and on their public BANNER WALL at /wall/ — a shareable page showing their line ladder with their join link. Free viral traffic that compounds as the team grows; no credits spent. - Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery. - Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs. diff --git a/db.js b/db.js index eca2d31..e3e3bc9 100644 --- a/db.js +++ b/db.js @@ -173,6 +173,18 @@ async function bootstrap() { burned_at BIGINT NULL, INDEX (burned_tx) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + // follow-up email sequence queue (one row per free account) + await q(`CREATE TABLE IF NOT EXISTS drips ( + email VARCHAR(190) PRIMARY KEY, + step INT NOT NULL DEFAULT 0, + next_at BIGINT NOT NULL, + started BIGINT NOT NULL, + stopped TINYINT NOT NULL DEFAULT 0, + ref VARCHAR(64) NULL, + angle VARCHAR(20) NULL, + INDEX (stopped, next_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE accounts ADD COLUMN joined_via VARCHAR(20) NULL'); // ?v= angle the lead came in on } // one-time import: only when the tables are empty and JSON files exist diff --git a/drip.js b/drip.js new file mode 100644 index 0000000..0eb98f8 --- /dev/null +++ b/drip.js @@ -0,0 +1,197 @@ +// Follow-up email sequence for new free members ("the lead came in the door +// with their email"). A row is queued when an account is created; a ticker +// sends each step when it comes due; a signed unsubscribe link stops it. +// Sequence copy lives in DATA_DIR/drip.json (admin-editable) with the +// defaults below. Dual-mode storage like the rest of the site (MySQL / JSON). +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const db = require('./db'); + +let DATA_DIR = null, mailer = null, accounts = null, SITE = 'https://instantadpay.com'; + +// hours = time after the account was created +const DEFAULT_SEQUENCE = [ + { hours: 24, subject: 'Your first five minutes on InstantAdPay', + body: 'Yesterday you created a free InstantAdPay account. Here is the shortest useful thing you can do with it today.\n\n' + + '1. Pick a username. It becomes your invite link, and it is how your sponsor and your line see you.\n' + + '2. Take the welcome tour. It is three short stops on your upline\'s pages, and it unlocks your welcome credits. Those credits spend on real ads.\n' + + '3. Copy your invite link and put it somewhere: a bio, a group, a text to one person.\n\n' + + 'Your link right now: {{link}}\n\n' + + 'That is the whole first day. Nothing to buy, nothing to connect.\n\n' + + 'Sign in: {{site}}/my\n\n{{footer}}' }, + { hours: 48, subject: 'Where the money actually goes', + body: 'Most programs ask you to trust a back office. Here is what happens instead, in plain terms.\n\n' + + 'When anyone buys an ad package, a verified smart contract on Polygon splits that payment in the same transaction: 50 percent to their direct sponsor, 20 percent to level two, 10 percent to level three, 20 percent to the platform. It lands in real wallets in seconds. Nothing is held, so there is nothing to withdraw.\n\n' + + 'Every one of those payments is public. Open the live ledger and read it yourself:\n{{site}}/ledger\n\n' + + 'You do not need a wallet to look. You only need one when you buy a package or switch on payouts, and the site walks you through it.\n\n' + + 'Your invite link: {{link}}\n\n{{footer}}' }, + { hours: 96, subject: 'The five dollar test', + body: 'If you want to see the whole machine work without much at stake, the $5 package is the test.\n\n' + + 'It mints 500 ad credits. One credit is one cent of delivery, and you spend them on seven formats: banners, text ads, login ads, solo ads into member inboxes, video ads, featured links and verified visits. Every view is dwell-timed on the server, so a real person saw it.\n\n' + + 'The packages: $5 = 500 credits, $20 = 2,000, $50 = 5,500, $100 = 12,000, $250 = 32,500.\n\n' + + 'Prefer to spend nothing? Keep viewing ads in Earn credits and run your first campaign on those. Both paths are real.\n\n' + + 'Buy packages: {{site}}/my#buy\n\n{{footer}}' }, + { hours: 168, subject: 'Two buyers open level two', + body: 'One week in. Here is the referral side, with the numbers exactly as the contract has them.\n\n' + + 'Every person who joins through your link and buys a package pays you 50 percent of that package, from their very first purchase, in the same transaction.\n\n' + + 'Two qualifying buyers ($20 or more) open level two: 20 percent of everything their people buy. Five open level three: 10 percent of the level after that.\n\n' + + 'Promo tools in your dashboard has posts, texts, email swipes and banners already carrying your link, plus honest answers to the objections you will hear. Your sponsor {{sponsor}} can be messaged from the dashboard any time.\n\n' + + 'Your invite link: {{link}}\n\n' + + 'No income is promised. Results depend on your effort, and crypto carries risk of loss.\n\n{{footer}}' } +]; + +// ---- storage ---- +const J = { + db: null, + FILE: () => path.join(DATA_DIR, 'drips.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 enqueue(rec) { if (!this.db) this.load(); if (this.db[rec.email]) return false; this.db[rec.email] = rec; this.save(); return true; }, + async due(now, limit) { if (!this.db) this.load(); return Object.values(this.db).filter(r => !r.stopped && r.nextAt <= now).sort((a, b) => a.nextAt - b.nextAt).slice(0, limit); }, + async update(email, fields) { if (!this.db) this.load(); if (this.db[email]) { Object.assign(this.db[email], fields); this.save(); } }, + async stats() { if (!this.db) this.load(); const v = Object.values(this.db); return { active: v.filter(r => !r.stopped).length, unsubscribed: v.filter(r => r.stopped === 2).length, done: v.filter(r => r.stopped === 1).length, total: v.length }; }, + async get(email) { if (!this.db) this.load(); return this.db[email] || null; } +}; +const rowR = r => ({ email: r.email, step: r.step, nextAt: Number(r.next_at), started: Number(r.started), stopped: r.stopped, ref: r.ref, angle: r.angle }); +const D = { + async enqueue(rec) { + try { + await db.q('INSERT INTO drips (email,step,next_at,started,stopped,ref,angle) VALUES (?,?,?,?,0,?,?)', + [rec.email, rec.step, rec.nextAt, rec.started, rec.ref || null, rec.angle || null]); + return true; + } catch (e) { if (e.code === 'ER_DUP_ENTRY') return false; throw e; } + }, + async due(now, limit) { return (await db.q('SELECT * FROM drips WHERE stopped=0 AND next_at<=? ORDER BY next_at LIMIT ?', [now, limit])).map(rowR); }, + async update(email, fields) { + const sets = [], vals = []; + if ('step' in fields) { sets.push('step=?'); vals.push(fields.step); } + if ('nextAt' in fields) { sets.push('next_at=?'); vals.push(fields.nextAt); } + if ('stopped' in fields) { sets.push('stopped=?'); vals.push(fields.stopped); } + if (!sets.length) return; + vals.push(email); + await db.q('UPDATE drips SET ' + sets.join(',') + ' WHERE email=?', vals); + }, + async stats() { + const r = await db.q('SELECT SUM(stopped=0) active, SUM(stopped=2) unsubscribed, SUM(stopped=1) done, COUNT(*) total FROM drips'); + return { active: Number(r[0].active || 0), unsubscribed: Number(r[0].unsubscribed || 0), done: Number(r[0].done || 0), total: Number(r[0].total || 0) }; + }, + async get(email) { const r = await db.q('SELECT * FROM drips WHERE email=?', [email]); return r.length ? rowR(r[0]) : null; } +}; +const impl = () => db.enabled() ? D : J; + +// ---- sequence config ---- +function seqFile() { return path.join(DATA_DIR, 'drip.json'); } +function sequence() { + try { + const saved = JSON.parse(fs.readFileSync(seqFile(), 'utf8')); + if (Array.isArray(saved) && saved.length) return saved.map(normStep).filter(Boolean); + } catch (e) {} + return DEFAULT_SEQUENCE; +} +function normStep(s) { + if (!s || typeof s !== 'object') return null; + const hours = Number(s.hours); + const subject = String(s.subject || '').trim().slice(0, 150); + const body = String(s.body || '').trim().slice(0, 8000); + if (!(hours >= 1) || !subject || !body) return null; + return { hours, subject, body }; +} +function setSequence(arr) { + if (!Array.isArray(arr)) return { error: 'Send a list of steps.' }; + const steps = arr.map(normStep); + if (steps.some(s => !s)) return { error: 'Every step needs hours (>= 1), a subject and a body.' }; + if (steps.length > 20) return { error: 'Keep it to 20 steps or fewer.' }; + for (let i = 1; i < steps.length; i++) if (steps[i].hours <= steps[i - 1].hours) return { error: 'Steps must be in increasing hours.' }; + fs.writeFileSync(seqFile(), JSON.stringify(steps, null, 2)); + return { ok: true, sequence: steps }; +} +function resetSequence() { try { fs.unlinkSync(seqFile()); } catch (e) {} return { ok: true, sequence: DEFAULT_SEQUENCE }; } + +// ---- unsubscribe signing ---- +function secret() { + const f = path.join(DATA_DIR, 'drip.secret'); + try { return fs.readFileSync(f, 'utf8').trim(); } catch (e) {} + const s = crypto.randomBytes(24).toString('hex'); + try { fs.writeFileSync(f, s, { mode: 0o600 }); } catch (e) {} + return s; +} +function token(email) { return crypto.createHmac('sha256', secret()).update(String(email).toLowerCase()).digest('hex').slice(0, 32); } +function unsubUrl(email) { return SITE + '/unsubscribe?e=' + encodeURIComponent(email) + '&t=' + token(email); } +async function unsubscribe(email, t) { + const e = String(email || '').trim().toLowerCase(); + if (!e || !t || t !== token(e)) return { error: 'That link is not valid.' }; + const cur = await impl().get(e); + if (!cur) return { ok: true, already: true }; + await impl().update(e, { stopped: 2 }); + return { ok: true }; +} + +// ---- rendering ---- +async function vars(email) { + const a = accounts ? await accounts.byEmail(email) : null; + const tok = a ? (a.username || a.code || (a.memberId ? String(a.memberId) : '')) : ''; + let sponsor = 'your sponsor'; + try { + const sp = accounts && await accounts.sponsorOf(email); + if (sp) sponsor = sp.username ? '@' + sp.username : (sp.memberId ? 'member #' + sp.memberId : 'your sponsor'); + } catch (e) {} + return { + link: tok ? SITE + '/join/' + tok : SITE + '/my', + sponsor, site: SITE, email, + footer: 'You are getting these follow-ups because you created a free InstantAdPay account. Stop them here: ' + unsubUrl(email) + + '\n\nInstantAdPay · Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.' + }; +} +function render(text, v) { return String(text).replace(/\{\{(\w+)\}\}/g, (m, k) => (k in v ? v[k] : m)); } + +// ---- lifecycle ---- +function init(opts) { + DATA_DIR = opts.dataDir; mailer = opts.mailer; accounts = opts.accounts; + if (opts.site) SITE = opts.site; +} +// queue a fresh account; step 0 of the sequence is due `hours` after creation +async function enqueue(email, ref, angle) { + const e = String(email || '').trim().toLowerCase(); + if (!e) return false; + const seq = sequence(); + const now = Date.now(); + return impl().enqueue({ email: e, step: 0, nextAt: now + seq[0].hours * 3600000, started: now, stopped: 0, + ref: String(ref || '').slice(0, 40) || null, angle: String(angle || '').slice(0, 20) || null }); +} +async function sendStep(email, stepIdx, to) { + const seq = sequence(); + const s = seq[stepIdx]; + if (!s) return { error: 'No such step.' }; + const v = await vars(email); + await mailer.send(to || email, render(s.subject, v), render(s.body, v)); + return { ok: true }; +} +let ticking = false; +async function tick() { + if (ticking || !mailer || !mailer.hasKey()) return 0; + ticking = true; + let sent = 0; + try { + const seq = sequence(); + const now = Date.now(); + const rows = await impl().due(now, 50); + for (const r of rows) { + try { + if (r.step >= seq.length) { await impl().update(r.email, { stopped: 1 }); continue; } + await sendStep(r.email, r.step); + sent += 1; + const next = r.step + 1; + if (next >= seq.length) await impl().update(r.email, { step: next, stopped: 1 }); + else await impl().update(r.email, { step: next, nextAt: Math.max(now + 60000, r.started + seq[next].hours * 3600000) }); + } catch (e) { + console.error('drip send', r.email, e.message); + await impl().update(r.email, { nextAt: now + 6 * 3600000 }); // retry later, do not spin + } + } + } finally { ticking = false; } + return sent; +} +async function stats() { return impl().stats(); } + +module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, DEFAULT_SEQUENCE }; diff --git a/public/admin.html b/public/admin.html index db04643..e17b4ca 100644 --- a/public/admin.html +++ b/public/admin.html @@ -84,6 +84,7 @@
0
On-chain members
0
Active campaigns
0
Open reports
+
0
Follow-ups in flight
@@ -200,6 +201,21 @@