Traffic sources: referring domain on join-link visits and joins (per-member table), click sources per campaign (surface or outside host)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-10 10:12:13 -05:00
parent 6e187c4f41
commit ecdee89be1
6 changed files with 74 additions and 22 deletions
+9 -9
View File
@@ -33,7 +33,7 @@ function newCode(taken) {
return c; return c;
} }
const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null, 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, lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, wallOffers: a.wallOffers || null,
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null, avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null,
chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0, 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.' }; if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' };
return { ok: true, account: pub(a) }; return { ok: true, account: pub(a) };
}, },
async ensure(e, ref, via) { async ensure(e, ref, via, joinedRef) {
let created = false; let created = false;
if (!this.db.byEmail[e]) { if (!this.db.byEmail[e]) {
const code = newCode(c => this.db.byCode[c]); 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; this.db.byCode[code] = e;
created = true; created = true;
this.save(); this.save();
@@ -204,7 +204,7 @@ const J = {
// ---- MySQL mode ---- // ---- MySQL mode ----
const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code, 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, 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, avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials,
chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0), 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.' }; if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' };
return { ok: true, account: rowPub(rows[0]) }; return { ok: true, account: rowPub(rows[0]) };
}, },
async ensure(e, ref, via) { async ensure(e, ref, via, joinedRef) {
const code = newCode(); const code = newCode();
let created = false; let created = false;
try { try {
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via) VALUES (?,NULL,?,?,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]); [e, ref, code, Date.now(), via || null, joinedRef || null]);
created = true; created = true;
} catch (err) { } catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw 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 || '')); return impl().signup(e, String(password), String(sponsorRef || ''));
} }
async function login(email, password) { return impl().login(normEmail(email), String(password || '')); } 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); const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; 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 byEmail(email) { return impl().byEmail(normEmail(email)); }
async function byAddress(address) { return impl().byAddress(normAddr(address)); } async function byAddress(address) { return impl().byAddress(normAddr(address)); }
+39 -9
View File
@@ -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; 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 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); } async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); }
}; };
const D = { const D = {
@@ -120,11 +122,13 @@ const D = {
return (await this.prospects(email)).find(x => x.id === r.insertId) || null; 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 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) { async views(tokens, since) {
if (!tokens.length) return []; 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]); 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) })); return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
} }
}; };
const impl = () => db.enabled() ? D : J; 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.' }; } 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 ---- // ---- 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) { async function linkStats(email) {
const a = await accounts.byEmail(email); const a = await accounts.byEmail(email);
if (!a) return { angles: [] }; if (!a) return { angles: [] };
@@ -159,13 +185,17 @@ async function linkStats(email) {
const ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two']; const ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two'];
const rows = {}; const rows = {};
for (const k of ANG) rows[k] = { angle: k || 'plain', views30: 0, views: 0, joins: 0, buyers: 0 }; 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)) { for (const j of joined.slice(0, 300)) {
const r = rows[String(j.joinedVia || '').toLowerCase()] || rows['']; const r = rows[String(j.joinedVia || '').toLowerCase()] || rows[''];
r.joins += 1; 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 ---- // ---- 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; DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer;
J.load(); 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 };
+9
View File
@@ -153,6 +153,14 @@ async function bootstrap() {
ts BIGINT NOT NULL, ts BIGINT NOT NULL,
INDEX (token, ts) INDEX (token, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) 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 ( await q(`CREATE TABLE IF NOT EXISTS visit_seen (
campaign_id INT NOT NULL, campaign_id INT NOT NULL,
email VARCHAR(190) NOT NULL, email VARCHAR(190) NOT NULL,
@@ -218,6 +226,7 @@ async function bootstrap() {
INDEX (stopped, next_at) INDEX (stopped, next_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) 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_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) 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)
} }
+8 -1
View File
@@ -611,7 +611,7 @@
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + '</td>' + '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + '</td>'
+ '<td class="num">' + c.imps.toLocaleString() + '</td>' + '<td class="num">' + c.imps.toLocaleString() + '</td>'
+ '<td class="num">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>' + '<td class="num">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>'
+ '<td class="num">' + c.clicks + '</td>' + '<td class="num">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small" style="white-space:nowrap" title="where the clicks happened">' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => esc(k) + ' ' + v).join(' · ') + (c.impsNas ? ' · network: see Network views' : '') + '</div>' : '') + '</td>'
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>' + '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>' + '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>'
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>' + '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
@@ -1018,6 +1018,13 @@
if (!rows.length) { t.innerHTML = '<tr><td class="muted small">No views yet. Share your link and the numbers start here.</td></tr>'; return; } if (!rows.length) { t.innerHTML = '<tr><td class="muted small">No views yet. Share your link and the numbers start here.</td></tr>'; return; }
t.innerHTML = '<tr><th>Hook</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>' t.innerHTML = '<tr><th>Hook</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
+ rows.map(a => '<tr><td>' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '</td><td class="mono">' + a.views30 + '</td><td class="mono">' + a.views + '</td><td class="mono">' + a.joins + '</td><td class="mono">' + a.buyers + '</td></tr>').join(''); + rows.map(a => '<tr><td>' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '</td><td class="mono">' + a.views30 + '</td><td class="mono">' + a.views + '</td><td class="mono">' + a.joins + '</td><td class="mono">' + a.buyers + '</td></tr>').join('');
const st = $('linkSrcTable');
if (st) {
const src = (r.sources || []).filter(x => x.views || x.joins);
st.innerHTML = src.length ? '<tr><th>Source</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
+ src.map(x => '<tr><td>' + esc(x.source) + '</td><td class="mono">' + x.views30 + '</td><td class="mono">' + x.views + '</td><td class="mono">' + x.joins + '</td><td class="mono">' + x.buyers + '</td></tr>').join('')
: '<tr><td class="muted small">Sources appear as visits arrive.</td></tr>';
}
} catch (e) {} } catch (e) {}
} }
// ── prospects: the member's own follow-up list ── // ── prospects: the member's own follow-up list ──
+3 -1
View File
@@ -281,6 +281,8 @@
<h3>Your links: what is working</h3> <h3>Your links: what is working</h3>
<p class="muted small">Views, joins and qualifying buyers for each hook. Add <span class="mono">?v=instant</span>, <span class="mono">?v=adspend</span>, <span class="mono">?v=free</span>, <span class="mono">?v=ledger</span> or <span class="mono">?v=two</span> to the end of your link to pick the hook the page opens with.</p> <p class="muted small">Views, joins and qualifying buyers for each hook. Add <span class="mono">?v=instant</span>, <span class="mono">?v=adspend</span>, <span class="mono">?v=free</span>, <span class="mono">?v=ledger</span> or <span class="mono">?v=two</span> to the end of your link to pick the hook the page opens with.</p>
<div class="tablewrap"><table id="linkStatsTable" class="small"></table></div> <div class="tablewrap"><table id="linkStatsTable" class="small"></table></div>
<p class="muted small" style="margin:14px 0 6px"><b>Where your visitors came from</b> (referring site of the first visit; "direct" is a typed or pasted link, a text message, or an email)</p>
<div class="tablewrap"><table id="linkSrcTable" class="small"></table></div>
</div> </div>
<div class="card"> <div class="card">
<h3>Your line</h3> <h3>Your line</h3>
@@ -874,7 +876,7 @@
<script src="/assets/common.js?v=20260910c"></script> <script src="/assets/common.js?v=20260910c"></script>
<script src="/assets/wallet.js?v=20260910a"></script> <script src="/assets/wallet.js?v=20260910a"></script>
<script src="/assets/promo.js?v=20260909b"></script> <script src="/assets/promo.js?v=20260909b"></script>
<script src="/assets/my.js?v=20260910k"></script> <script src="/assets/my.js?v=20260910l"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+6 -2
View File
@@ -500,11 +500,12 @@ const server = http.createServer(async (req, res) => {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const angle = String(u.searchParams.get('v') || '').toLowerCase(); const angle = String(u.searchParams.get('v') || '').toLowerCase();
const ang = JOIN_ANGLES[angle] || null; const ang = JOIN_ANGLES[angle] || null;
if (req.method === 'GET') coach.recordView(tok, ang ? angle : ''); // link stats per angle if (req.method === 'GET') coach.recordView(tok, ang ? angle : '', req.headers.referer); // link stats per angle + source
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`; const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
const set = []; const set = [];
if (!cookies['iap.sponsor']) set.push('iap.sponsor=' + tok + cookieTail); if (!cookies['iap.sponsor']) set.push('iap.sponsor=' + tok + cookieTail);
if (ang) set.push('iap.angle=' + angle + cookieTail); if (ang) set.push('iap.angle=' + angle + cookieTail);
if (!cookies['iap.ref']) set.push('iap.ref=' + encodeURIComponent(coach.refHost(req.headers.referer)) + cookieTail); // first-touch source
return serveJoinPage(res, tok, ang ? angle : '', ang, set); return serveJoinPage(res, tok, ang ? angle : '', ang, set);
} }
if (p === '/unsubscribe' && req.method === 'GET') { if (p === '/unsubscribe' && req.method === 'GET') {
@@ -676,7 +677,8 @@ const server = http.createServer(async (req, res) => {
emailCodes.delete(e); emailCodes.delete(e);
const ref = parseCookies(req)['iap.sponsor'] || ''; const ref = parseCookies(req)['iap.sponsor'] || '';
const via = parseCookies(req)['iap.angle'] || ''; const via = parseCookies(req)['iap.angle'] || '';
const r = await accounts.ensure(e, ref, via); // first touch wins; existing accounts unchanged const joinedRef = decodeURIComponent(parseCookies(req)['iap.ref'] || '') || null;
const r = await accounts.ensure(e, ref, via, joinedRef); // first touch wins; existing accounts unchanged
if (r.error) return json(res, 400, r); if (r.error) return json(res, 400, r);
// the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms) // the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms)
if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {}); if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {});
@@ -1542,6 +1544,7 @@ const server = http.createServer(async (req, res) => {
if (m && req.method === 'GET') { if (m && req.method === 'GET') {
const target = await ads.click(m[1]); const target = await ads.click(m[1]);
if (!target) { res.writeHead(404, baseHeaders()); return res.end(); } if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
coach.recordClick(m[1], req.headers.referer); // where the click happened
res.writeHead(302, baseHeaders({ Location: target })); res.writeHead(302, baseHeaders({ Location: target }));
return res.end(); return res.end();
} }
@@ -1550,6 +1553,7 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
const pool = await ads.pooledCredits((await myMemberIds(s)).ids); const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
out.purchasedCredits = pool.total; out.purchasedCredits = pool.total;
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position