Lead capture pages + follow-up email sequence
- /join/<token>[?v=angle] now serves a capture page (email first, wallet later) with angle-matched hook copy, sponsor line, worked-example ledger, how-it-works, live package ladder, and per-angle og tags (og:url keeps ?v=). The sponsor cookie is set exactly as before; ?v= is remembered and stored on the account as joined_via (shown in admin Members). - drip.js: 4-step getting-started sequence (24h/48h/96h/168h) queued when a free account is created with the pre-checked opt-in; ticker every 10 min; signed /unsubscribe link in every email; MySQL + JSON storage. - Admin > Settings: edit the sequence as JSON, reset to defaults, send any step to the admin inbox; Overview shows follow-ups in flight. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+17
-1
@@ -84,6 +84,7 @@
|
||||
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
|
||||
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
|
||||
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
|
||||
<div class="statx"><div><div class="nv" id="ovDrips">0</div><div class="lb">Follow-ups in flight</div><span class="chip flat" id="ovDripSub"></span></div></div>
|
||||
</div>
|
||||
<div class="grid c2" style="margin-top:16px">
|
||||
<div class="card">
|
||||
@@ -200,6 +201,21 @@
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
|
||||
<p class="small muted">Each step: <code>hours</code> after the account was created, a <code>subject</code>, and a plain-text <code>body</code>.
|
||||
Placeholders: <code>{{link}}</code> the member's invite link, <code>{{sponsor}}</code>, <code>{{site}}</code>, <code>{{footer}}</code> (unsubscribe line + disclaimer, keep it).
|
||||
Steps must be in increasing hours.</p>
|
||||
<p><textarea id="dripJson" class="json" style="min-height:360px"></textarea></p>
|
||||
<p id="dripErr" class="small" style="color:var(--bad)" hidden></p>
|
||||
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0">
|
||||
<button class="btn small" id="dripSave" type="button">Save sequence</button>
|
||||
<button class="btn small sec" id="dripReset" type="button">Reset to defaults</button>
|
||||
<span style="flex:1"></span>
|
||||
<select id="dripTestStep" style="width:auto"></select>
|
||||
<button class="btn small sec" id="dripTest" type="button">Send that step to me</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices and rewards (JSON, merged on save)</span></div>
|
||||
<p><textarea id="ratesJson" class="json"></textarea></p>
|
||||
@@ -219,6 +235,6 @@
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260909a"></script>
|
||||
<script src="/assets/admin.js?v=20260909a"></script>
|
||||
<script src="/assets/admin.js?v=20260909b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+26
-3
@@ -85,6 +85,9 @@
|
||||
$('ovReports').textContent = o.openReports || 0;
|
||||
$('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns';
|
||||
$('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || '';
|
||||
const f = o.followups || {};
|
||||
$('ovDrips').textContent = f.active || 0;
|
||||
$('ovDripSub').textContent = (f.done || 0) + ' finished · ' + (f.unsubscribed || 0) + ' unsubscribed';
|
||||
const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]);
|
||||
$('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--line)"><span>' + esc(t) + '</span><b>' + n + '</b></div>').join('') : 'No campaigns yet.';
|
||||
const ch = o.chain || {};
|
||||
@@ -229,11 +232,11 @@
|
||||
const q = ($('memFilter').value || '').trim().toLowerCase();
|
||||
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
|
||||
$('memSub').textContent = list.length + ' of ' + allMembers.length;
|
||||
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Code</th><th>Joined</th><th></th></tr>'
|
||||
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Via</th><th>Code</th><th>Joined</th><th></th></tr>'
|
||||
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
|
||||
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
|
||||
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
|
||||
+ '<td>' + esc(a.sponsorRef || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
|
||||
+ '<td>' + esc(a.sponsorRef || '') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
|
||||
+ '<td class="small muted">' + when(a.created) + '</td>'
|
||||
+ '<td class="act"><button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
|
||||
}
|
||||
@@ -271,10 +274,30 @@
|
||||
|
||||
// ── settings ──
|
||||
async function loadSettings() {
|
||||
const [r, s] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site')]);
|
||||
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
|
||||
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2);
|
||||
$('siteJson').value = JSON.stringify(s.site || {}, null, 2);
|
||||
$('dripJson').value = JSON.stringify(d.sequence || [], null, 2);
|
||||
const st = d.stats || {};
|
||||
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends');
|
||||
$('dripTestStep').innerHTML = (d.sequence || []).map((s, i) => '<option value="' + i + '">Step ' + (i + 1) + ' · ' + s.hours + 'h · ' + esc(s.subject) + '</option>').join('');
|
||||
}
|
||||
$('dripSave').addEventListener('click', busy($('dripSave'), async () => {
|
||||
$('dripErr').hidden = true;
|
||||
let seq;
|
||||
try { seq = JSON.parse($('dripJson').value); } catch (e) { $('dripErr').textContent = 'That is not valid JSON: ' + e.message; $('dripErr').hidden = false; return; }
|
||||
try { const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH'); $('dripJson').value = JSON.stringify(r.sequence, null, 2); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }
|
||||
catch (e) { $('dripErr').textContent = e.message; $('dripErr').hidden = false; }
|
||||
}));
|
||||
$('dripReset').addEventListener('click', busy($('dripReset'), async () => {
|
||||
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
|
||||
const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
|
||||
$('dripJson').value = JSON.stringify(r.sequence, null, 2); IAP.status('Defaults restored.', 'ok'); await loadSettings();
|
||||
}));
|
||||
$('dripTest').addEventListener('click', busy($('dripTest'), async () => {
|
||||
await api('/api/admin/drip/test', { step: Number($('dripTestStep').value) || 0 });
|
||||
IAP.status('Sent to your inbox.', 'ok');
|
||||
}));
|
||||
function saveJson(btnId, taId, errId, path, key) {
|
||||
$(btnId).addEventListener('click', busy($(btnId), async () => {
|
||||
$(errId).hidden = true;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Invite / lead-capture page: /join/<token>[?v=<angle>]
|
||||
// Email first (code sign-in creates the account), wallet later inside the
|
||||
// member area. The angle only changes the hook copy; the sponsor cookie was
|
||||
// set by the server when this page was served.
|
||||
(function () {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ANGLES = {
|
||||
instant: { eyebrow: 'Same-transaction payouts', h: 'Paid before the page <em>reloads.</em>', lead: 'What if your commission landed before the thank-you page finished loading? On InstantAdPay that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.' },
|
||||
adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying <em>ads anyway.</em>', lead: 'Every ad dollar you ever spent went one direction: out. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, dwell-timed views, packages from $5.' },
|
||||
free: { eyebrow: 'Costs nothing to try', h: 'Watch first. <em>Spend never.</em>', lead: 'Join free, view a few ads, earn credits, and run your first campaign for zero dollars. Upgrade only if you want more reach.' },
|
||||
ledger: { eyebrow: 'No back office', h: 'No back office. <em>No payday.</em>', lead: 'Your last program paid you on the 15th, if it paid you. Here every payout is a public transaction on Polygon you can read yourself, and nothing is ever held.' },
|
||||
two: { eyebrow: 'The referral side, exactly as coded', h: 'Two buyers open <em>level two.</em>', lead: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written as constants in a verified contract.' }
|
||||
};
|
||||
const v = new URLSearchParams(location.search).get('v');
|
||||
const a = v && ANGLES[v];
|
||||
if (a) { $('jnEyebrow').textContent = a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead; }
|
||||
|
||||
async function api(path, body) {
|
||||
const r = await (await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
const err = m => { const e = $('jnErr'); e.textContent = m || ''; e.hidden = !m; };
|
||||
function busy(btn, fn) {
|
||||
return async () => { if (btn.disabled) return; btn.disabled = true; err(''); try { await fn(); } catch (e) { err(e.message || 'Something went wrong.'); } finally { btn.disabled = false; } };
|
||||
}
|
||||
const send = busy($('jnSend'), async () => {
|
||||
const r = await api('/api/auth/email/start', { email: $('jnEmail').value });
|
||||
$('jnCodeRow').hidden = false; $('jnVerify').hidden = false; $('jnSend').hidden = true; $('jnResend').hidden = false;
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
IAP.status(r.sent ? 'Code sent. Check your inbox (and spam, the first time).' : 'Dev mode: code filled in.', 'ok');
|
||||
$('jnCode').focus();
|
||||
});
|
||||
$('jnSend').addEventListener('click', send);
|
||||
$('jnResend').addEventListener('click', busy($('jnResend'), async () => {
|
||||
const r = await api('/api/auth/email/start', { email: $('jnEmail').value });
|
||||
if (r.devCode) $('jnCode').value = r.devCode;
|
||||
IAP.status('Fresh code sent.', 'ok');
|
||||
}));
|
||||
$('jnVerify').addEventListener('click', busy($('jnVerify'), async () => {
|
||||
await api('/api/auth/email/verify', { email: $('jnEmail').value, code: $('jnCode').value, newsletter: !!$('jnNews').checked, followups: !!$('jnNews').checked });
|
||||
IAP.status('You are in. Taking you to your dashboard…', 'ok');
|
||||
location.href = '/my';
|
||||
}));
|
||||
$('jnEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('jnVerify').hidden ? $('jnSend') : $('jnVerify')).click(); });
|
||||
$('jnCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('jnVerify').click(); });
|
||||
|
||||
// sponsor line
|
||||
fetch('/api/sponsor').then(r => r.json()).then(sp => {
|
||||
if (sp && sp.invited && sp.name) {
|
||||
$('jnSponName').textContent = sp.name;
|
||||
if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; }
|
||||
$('jnSpon').hidden = false;
|
||||
}
|
||||
}).catch(() => {});
|
||||
// package ladder (dollar constants + live POL quote)
|
||||
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||||
fetch('/api/catalog').then(r => r.json()).then(c => {
|
||||
const wrap = $('jnLadder'); if (!wrap || !c.products) return;
|
||||
wrap.innerHTML = c.products.filter(p => p.active !== false).map(p =>
|
||||
'<div class="jn-pk"><div class="n">' + (NAMES[p.id] || 'Package ' + p.id) + '</div><div class="p">' + IAP.fmtUsd(p.priceCents) + '</div><div class="c">' + Number(p.creditAmount).toLocaleString() + ' credits</div>'
|
||||
+ (p.costWei ? '<div class="small muted">' + IAP.fmtPol(p.costWei) + ' POL now</div>' : '') + '</div>').join('');
|
||||
}).catch(() => {});
|
||||
fetch('/api/stats').then(r => r.json()).then(s => {
|
||||
if (s && s.onchainMembers) $('jnStats').textContent = s.onchainMembers.toLocaleString() + (s.onchainMembers === 1 ? ' member' : ' members') + ' on-chain so far.';
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,follow">
|
||||
<title>You're invited | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260909a">
|
||||
<style>
|
||||
.jn-nav{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 0}
|
||||
.jn-hero{padding:34px 0 10px;text-align:left}
|
||||
.jn-hero h1{font-size:clamp(32px,5vw,54px);line-height:1.08;margin:10px 0 14px;max-width:760px;text-align:left;letter-spacing:-.015em}
|
||||
.jn-hero h1 em{font-style:normal;color:var(--mint)}
|
||||
.jn-hero p.lead{margin:0 0 20px;text-align:left;max-width:640px}
|
||||
.jn-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:26px;align-items:start;margin-top:8px}
|
||||
@media(max-width:860px){.jn-grid{grid-template-columns:1fr}}
|
||||
.jn-cap{border-color:var(--mint);box-shadow:0 0 60px rgba(67,232,195,.12)}
|
||||
.jn-cap h3{margin:0 0 6px}
|
||||
.jn-cap input{width:100%}
|
||||
.jn-cap .btn{width:100%;text-align:center}
|
||||
.jn-chips{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}
|
||||
.jn-chips span{border:1px solid var(--line-strong);border-radius:999px;padding:6px 14px;font-size:13px;color:var(--muted)}
|
||||
.jn-chips span b{color:var(--mint);font-weight:700}
|
||||
.jn-spon{display:flex;align-items:center;gap:10px;margin:0 0 6px}
|
||||
.jn-spon img{width:34px;height:34px;border-radius:50%;object-fit:cover;border:1px solid var(--line-strong)}
|
||||
.jn-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:14px}
|
||||
@media(max-width:700px){.jn-steps{grid-template-columns:1fr}}
|
||||
.jn-steps .card{margin:0}
|
||||
.jn-steps .n{font-family:var(--mono);color:var(--mint);font-size:12px;letter-spacing:.1em}
|
||||
.jn-ladder{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-top:14px}
|
||||
.jn-pk{border:1px solid var(--line);border-radius:12px;padding:14px;text-align:center;background:var(--panel)}
|
||||
.jn-pk .p{font-family:var(--disp);font-size:26px;font-weight:800}
|
||||
.jn-pk .c{color:var(--mint);font-weight:700;font-size:13.5px}
|
||||
.jn-pk .n{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em}
|
||||
.jn-foot{color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px;padding:22px 0 50px}
|
||||
.jn-err{color:var(--bad)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="jn-nav">
|
||||
<a href="/"><img src="/logo.png" alt="InstantAdPay" style="height:34px;display:block"></a>
|
||||
<a class="small muted" href="/my">Already a member? Sign in</a>
|
||||
</div>
|
||||
|
||||
<section class="jn-hero">
|
||||
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b></span></div>
|
||||
<p class="eyebrow" id="jnEyebrow">Advertise and earn on Polygon</p>
|
||||
<h1 id="jnHead">Advertise and earn.<br><em>Paid on-chain, instantly.</em></h1>
|
||||
<p class="lead" id="jnLead">Every ad package splits to real wallets in the same transaction it sells. No pending payouts, no withdraw button, and every payment is public.</p>
|
||||
<div class="jn-chips">
|
||||
<span><b>Same transaction</b> payouts</span>
|
||||
<span><b>Public ledger</b> on Polygon</span>
|
||||
<span><b>Free</b> to join by email</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="jn-grid">
|
||||
<div>
|
||||
<div class="mockup" aria-label="worked example of one purchase">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="addr">instantadpay.com/ledger · worked example</span></div>
|
||||
<div class="body">
|
||||
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>paid</span></div>
|
||||
<div class="mrow"><span>💸 level 1 payout → member #3 (50%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 2 payout → member #2 (20%)</span><span>same block</span></div>
|
||||
<div class="mrow"><span>💸 level 3 payout → member #1 (10%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>🏛 platform fee settled (20%)</span><span>same block</span></div>
|
||||
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted" style="margin:10px 0 0">One purchase, one transaction, four payments. <a href="/ledger" target="_blank" rel="noopener">Open the real ledger →</a> <span id="jnStats"></span></p>
|
||||
|
||||
<h2 style="font-size:24px;margin:30px 0 4px">How it works</h2>
|
||||
<div class="jn-steps">
|
||||
<div class="card"><div class="n">STEP 1</div><h3>Join free by email</h3><p class="muted small">A 6-digit code, no password. You get an invite link and welcome credits to try real ads.</p></div>
|
||||
<div class="card"><div class="n">STEP 2</div><h3>Advertise or earn</h3><p class="muted small">Seven ad formats. View ads to earn credits, or buy a package from $5 when you want reach.</p></div>
|
||||
<div class="card"><div class="n">STEP 3</div><h3>Get paid in the same transaction</h3><p class="muted small">Anyone who buys through your link pays you 50 percent, on-chain, the moment it happens.</p></div>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:24px;margin:30px 0 4px">The packages</h2>
|
||||
<p class="muted small" style="margin:0">Priced in dollars, settled in POL at the live rate. One credit is one cent of ad delivery.</p>
|
||||
<div class="jn-ladder" id="jnLadder"></div>
|
||||
</div>
|
||||
|
||||
<div class="card jn-cap" id="jnCap">
|
||||
<h3>Join free</h3>
|
||||
<p class="muted small" style="margin:0 0 12px">Type your email and we send a 6-digit code. No password, no wallet needed today.</p>
|
||||
<p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p>
|
||||
<p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p>
|
||||
<label class="small muted" style="display:flex;gap:8px;align-items:flex-start;margin:0 0 12px;cursor:pointer">
|
||||
<input type="checkbox" id="jnNews" checked style="margin-top:3px;width:auto">
|
||||
<span>Send me the getting-started emails (a few short ones over the first week). Unsubscribe any time.</span>
|
||||
</label>
|
||||
<p id="jnErr" class="small jn-err" hidden></p>
|
||||
<button class="btn" id="jnSend" type="button">Email me a code</button>
|
||||
<button class="btn" id="jnVerify" type="button" hidden>Create my free account</button>
|
||||
<button class="btn sec small" id="jnResend" type="button" hidden style="margin-top:8px">Send a fresh code</button>
|
||||
<p class="small muted" style="margin:14px 0 0">Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="jn-foot">
|
||||
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260909a"></script>
|
||||
<script src="/assets/join.js?v=20260909a"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user