diff --git a/accounts.js b/accounts.js index 59f65a6..d85c1ff 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, joinedVia: a.joinedVia || null, + username: a.username || null, memberId: a.memberId || 0, joinedVia: a.joinedVia || null, joinedRef: a.joinedRef || null, lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, wallOffers: a.wallOffers || null, avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null, chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0, @@ -75,11 +75,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, via) { + async ensure(e, ref, via, joinedRef) { 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(), joinedVia: via || null }; + this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now(), joinedVia: via || null, joinedRef: joinedRef || null }; this.db.byCode[code] = e; created = true; this.save(); @@ -204,7 +204,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, joinedVia: r.joined_via || null, + username: r.username, memberId: r.member_id || 0, joinedVia: r.joined_via || null, joinedRef: r.joined_ref || null, lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url, wallOffers: r.wall_offers || null, avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials, chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0), @@ -228,12 +228,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, via) { + async ensure(e, ref, via, joinedRef) { const code = newCode(); let created = false; try { - 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]); + await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via,joined_ref) VALUES (?,NULL,?,?,NULL,?,?,?)', + [e, ref, code, Date.now(), via || null, joinedRef || null]); created = true; } catch (err) { if (err.code !== 'ER_DUP_ENTRY') throw err; @@ -360,10 +360,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, via) { +async function ensure(email, sponsorRef, via, joinedRef) { const e = normEmail(email); if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; - return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null); + return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null, String(joinedRef || '').toLowerCase().slice(0, 80) || null); } async function byEmail(email) { return impl().byEmail(normEmail(email)); } async function byAddress(address) { return impl().byAddress(normAddr(address)); } diff --git a/coach.js b/coach.js index 90b7604..775db69 100644 --- a/coach.js +++ b/coach.js @@ -99,7 +99,9 @@ const J = { const row = Object.assign({}, p, { id: this.db.nextId++, owner: email, created: Date.now(), updated: Date.now() }); this.db.prospects.push(row); this.save(); return row; }, async removeProspect(email, id) { const n = this.db.prospects.length; this.db.prospects = this.db.prospects.filter(x => !(x.id === Number(id) && x.owner === email)); this.save(); return n !== this.db.prospects.length; }, - async addView(token, angle, ts) { this.db.views.push({ token, angle, ts }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); }, + async addView(token, angle, ts, ref) { this.db.views.push({ token, angle, ts, ref: ref || '' }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); }, + async addClick(campaignId, src, ts) { this.db.clicks = this.db.clicks || []; this.db.clicks.push({ campaignId, src, ts }); if (this.db.clicks.length > 50000) this.db.clicks = this.db.clicks.slice(-40000); this.save(); }, + async clicksFor(ids) { return (this.db.clicks || []).filter(c => ids.includes(c.campaignId)); }, async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); } }; const D = { @@ -120,11 +122,13 @@ const D = { return (await this.prospects(email)).find(x => x.id === r.insertId) || null; }, async removeProspect(email, id) { const r = await db.q('DELETE FROM prospects WHERE id=? AND owner_email=?', [Number(id), email]); return r.affectedRows > 0; }, - async addView(token, angle, ts) { await db.q('INSERT INTO join_views (token,angle,ts) VALUES (?,?,?)', [token, angle, ts]); }, + async addView(token, angle, ts, ref) { await db.q('INSERT INTO join_views (token,angle,ts,ref) VALUES (?,?,?,?)', [token, angle, ts, ref || null]); }, + async addClick(campaignId, src, ts) { await db.q('INSERT INTO click_sources (campaign_id,src,ts) VALUES (?,?,?)', [Number(campaignId), src, ts]); }, + async clicksFor(ids) { if (!ids.length) return []; const rows = await db.q('SELECT campaign_id, src, ts FROM click_sources WHERE campaign_id IN (' + ids.map(() => '?').join(',') + ')', ids); return rows.map(r => ({ campaignId: r.campaign_id, src: r.src, ts: Number(r.ts) })); }, async views(tokens, since) { if (!tokens.length) return []; - const rows = await db.q('SELECT token, angle, ts FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]); - return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts) })); + const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]); + return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' })); } }; const impl = () => db.enabled() ? D : J; @@ -148,7 +152,29 @@ async function saveProspect(email, body) { async function removeProspect(email, id) { return (await impl().removeProspect(String(email).toLowerCase(), id)) ? { ok: true } : { error: 'No such prospect.' }; } // ---- link stats: views per angle (join page loads), joins and buyers per angle ---- -async function recordView(token, angle) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now()); } catch (e) {} } +// referring domain from a Referer header: our own pages count as 'direct', empty is 'direct' +function refHost(referer) { + try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /instantadpay\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; } +} +async function recordView(token, angle, referer) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now(), refHost(referer)); } catch (e) {} } +// where an ad click happened: our own surface (by page path) or an outside host +function clickSource(referer) { + try { + const u = new URL(String(referer || '')); const h = u.hostname.replace(/^www\./, '').toLowerCase(); + if (!/instantadpay\.com$/.test(h)) return h.slice(0, 80) || 'unknown'; + const p = u.pathname; + if (p.startsWith('/view')) return 'ad viewer'; if (p.startsWith('/my')) return 'member area'; if (p.startsWith('/ledger')) return 'live ledger'; + if (p.startsWith('/wall/')) return 'member walls'; if (p.startsWith('/shorts')) return 'shorts'; if (p.startsWith('/plays')) return 'plays page'; if (p === '/' || p === '') return 'home page'; + return 'site'; + } catch (e) { return 'unknown'; } +} +async function recordClick(campaignId, referer) { try { await impl().addClick(Number(campaignId), clickSource(referer), Date.now()); } catch (e) {} } +async function clickSources(campaignIds) { + const rows = await impl().clicksFor(campaignIds.map(Number)); + const out = {}; + for (const r of rows) { out[r.campaignId] = out[r.campaignId] || {}; out[r.campaignId][r.src] = (out[r.campaignId][r.src] || 0) + 1; } + return out; +} async function linkStats(email) { const a = await accounts.byEmail(email); if (!a) return { angles: [] }; @@ -159,13 +185,17 @@ async function linkStats(email) { const ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two']; const rows = {}; for (const k of ANG) rows[k] = { angle: k || 'plain', views30: 0, views: 0, joins: 0, buyers: 0 }; - for (const v of views) { const r = rows[v.angle] || rows['']; r.views += 1; if (now - v.ts < 30 * DAY) r.views30 += 1; } + const src = {}; + const srcRow = k => (src[k] = src[k] || { source: k, views30: 0, views: 0, joins: 0, buyers: 0 }); + for (const v of views) { const r = rows[v.angle] || rows['']; r.views += 1; if (now - v.ts < 30 * DAY) r.views30 += 1; const s = srcRow(v.ref || 'direct'); s.views += 1; if (now - v.ts < 30 * DAY) s.views30 += 1; } for (const j of joined.slice(0, 300)) { const r = rows[String(j.joinedVia || '').toLowerCase()] || rows['']; r.joins += 1; - if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) r.buyers += 1; } + const s = srcRow(j.joinedRef || 'direct'); s.joins += 1; + if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) { r.buyers += 1; s.buyers += 1; } } } - return { angles: Object.values(rows), totalViews: views.length, totalJoins: joined.length }; + const sources = Object.values(src).sort((a, b) => (b.views + b.joins * 5) - (a.views + a.joins * 5)); + return { angles: Object.values(rows), sources, totalViews: views.length, totalJoins: joined.length }; } // ---- automatic nudges to stalled members + weekly digest to sponsors ---- @@ -217,4 +247,4 @@ function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer; J.load(); } -module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick }; +module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick, recordClick, clickSources, refHost }; diff --git a/db.js b/db.js index db73d39..14b3279 100644 --- a/db.js +++ b/db.js @@ -153,6 +153,14 @@ async function bootstrap() { ts BIGINT NOT NULL, INDEX (token, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE join_views ADD COLUMN ref VARCHAR(80) NULL'); // referring domain + await q(`CREATE TABLE IF NOT EXISTS click_sources ( + id INT AUTO_INCREMENT PRIMARY KEY, + campaign_id INT NOT NULL, + src VARCHAR(80) NOT NULL, + ts BIGINT NOT NULL, + INDEX (campaign_id, ts) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS visit_seen ( campaign_id INT NOT NULL, email VARCHAR(190) NOT NULL, @@ -218,6 +226,7 @@ async function bootstrap() { 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 + await alterSafe('ALTER TABLE accounts ADD COLUMN joined_ref VARCHAR(80) NULL'); // referring domain of the first join-page visit await alterSafe('ALTER TABLE accounts ADD COLUMN wall_offers VARCHAR(2000) NULL'); // JSON [{title,bannerUrl,targetUrl}] for wall positions 2-3 (unlock at 2 / 5 qualifying buyers) } diff --git a/public/assets/my.js b/public/assets/my.js index 993e071..bd04164 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -611,7 +611,7 @@ + '
Views, joins and qualifying buyers for each hook. Add ?v=instant, ?v=adspend, ?v=free, ?v=ledger or ?v=two to the end of your link to pick the hook the page opens with.
Where your visitors came from (referring site of the first visit; "direct" is a typed or pasted link, a text message, or an email)
+