Viral welcome tour + line banner + wall, milestone stepper, rich solo editor, CTA-gated reads
- Welcome tour (3 levels x 10s) unlocks welcome credits; line banner in Profile; public /wall/<username> - 'Your next move' redesigned as a milestone stepper - Solo composer: BV-style rich editor (H2/H3, inline image+video, undo/redo, raw text) - Solo read reward now requires clicking through to the advertiser, not just dwelling - Sanitizer: inline media whitelist + script/style stripped whole Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+27
-2
@@ -34,6 +34,7 @@ function newCode(taken) {
|
|||||||
}
|
}
|
||||||
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,
|
username: a.username || null, memberId: a.memberId || 0,
|
||||||
|
lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null,
|
||||||
address: a.address || null, created: a.created } : null;
|
address: a.address || null, created: a.created } : null;
|
||||||
const USER_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
const USER_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||||
const normUser = u => String(u || '').trim().toLowerCase();
|
const normUser = u => String(u || '').trim().toLowerCase();
|
||||||
@@ -98,6 +99,18 @@ const J = {
|
|||||||
this.save();
|
this.save();
|
||||||
return { ok: true, account: pub(acct) };
|
return { ok: true, account: pub(acct) };
|
||||||
},
|
},
|
||||||
|
async setLineBanner(e, bannerUrl, targetUrl) {
|
||||||
|
const acct = this.db.byEmail[e];
|
||||||
|
if (!acct) return { error: 'No such account.' };
|
||||||
|
acct.lineBannerUrl = bannerUrl || null;
|
||||||
|
acct.lineTargetUrl = targetUrl || null;
|
||||||
|
this.save();
|
||||||
|
return { ok: true, account: pub(acct) };
|
||||||
|
},
|
||||||
|
async byMemberId(id) {
|
||||||
|
const a = Object.values(this.db.byEmail).find(x => x.memberId === Number(id));
|
||||||
|
return a ? pub(a) : null;
|
||||||
|
},
|
||||||
async setMemberId(e, id) {
|
async setMemberId(e, id) {
|
||||||
const acct = this.db.byEmail[e];
|
const acct = this.db.byEmail[e];
|
||||||
if (acct && acct.memberId !== id) { acct.memberId = id; this.save(); }
|
if (acct && acct.memberId !== id) { acct.memberId = id; this.save(); }
|
||||||
@@ -131,7 +144,9 @@ 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, address: r.address, created: Number(r.created) }) : null;
|
username: r.username, memberId: r.member_id || 0,
|
||||||
|
lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url,
|
||||||
|
address: r.address, created: Number(r.created) }) : null;
|
||||||
const D = {
|
const D = {
|
||||||
async signup(e, password, ref) {
|
async signup(e, password, ref) {
|
||||||
const code = newCode();
|
const code = newCode();
|
||||||
@@ -176,6 +191,14 @@ const D = {
|
|||||||
}
|
}
|
||||||
return { ok: true, account: await this.byEmail(e) };
|
return { ok: true, account: await this.byEmail(e) };
|
||||||
},
|
},
|
||||||
|
async setLineBanner(e, bannerUrl, targetUrl) {
|
||||||
|
await db.q('UPDATE accounts SET line_banner_url=?, line_target_url=? WHERE email=?', [bannerUrl || null, targetUrl || null, e]);
|
||||||
|
return { ok: true, account: await this.byEmail(e) };
|
||||||
|
},
|
||||||
|
async byMemberId(id) {
|
||||||
|
const r = await db.q('SELECT * FROM accounts WHERE member_id=?', [Number(id)]);
|
||||||
|
return rowPub(r[0]);
|
||||||
|
},
|
||||||
async setMemberId(e, id) { await db.q('UPDATE accounts SET member_id=? WHERE email=? AND (member_id IS NULL OR member_id<>?)', [id, e, id]); },
|
async setMemberId(e, id) { await db.q('UPDATE accounts SET member_id=? WHERE email=? AND (member_id IS NULL OR member_id<>?)', [id, e, id]); },
|
||||||
async namesForMembers(ids) {
|
async namesForMembers(ids) {
|
||||||
if (!ids.length) return {};
|
if (!ids.length) return {};
|
||||||
@@ -246,4 +269,6 @@ async function linkWallet(email, address) {
|
|||||||
async function count() { return impl().count(); }
|
async function count() { return impl().count(); }
|
||||||
|
|
||||||
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
|
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
|
||||||
setUsername, setMemberId, namesForMembers, listByReferrer, linkWallet, count };
|
setUsername, setMemberId, namesForMembers, listByReferrer, linkWallet, count,
|
||||||
|
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
|
||||||
|
byMemberId: id => impl().byMemberId(id) };
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ function systemPrompt() {
|
|||||||
return `You are the assistant on InstantAdPay (https://instantadpay.com), a membership advertising platform.
|
return `You are the assistant on InstantAdPay (https://instantadpay.com), a membership advertising platform.
|
||||||
|
|
||||||
FACTS:
|
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 at signup (engine-side bonus credits, separate from on-chain purchased credits).
|
- 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.
|
||||||
|
- 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/<username> — 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.
|
- 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), and solo ads. Coming: featured rotation with disclosed rotation size, verified-visit packs.
|
- 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), and solo ads. Coming: featured rotation with disclosed rotation size, verified-visit packs.
|
||||||
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
|
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ async function bootstrap() {
|
|||||||
await alterSafe('ALTER TABLE accounts ADD UNIQUE KEY uq_username (username)');
|
await alterSafe('ALTER TABLE accounts ADD UNIQUE KEY uq_username (username)');
|
||||||
await alterSafe('ALTER TABLE accounts ADD COLUMN member_id INT NULL');
|
await alterSafe('ALTER TABLE accounts ADD COLUMN member_id INT NULL');
|
||||||
await alterSafe('ALTER TABLE accounts ADD KEY idx_member (member_id)');
|
await alterSafe('ALTER TABLE accounts ADD KEY idx_member (member_id)');
|
||||||
|
await alterSafe('ALTER TABLE accounts ADD COLUMN line_banner_url VARCHAR(500) NULL');
|
||||||
|
await alterSafe('ALTER TABLE accounts ADD COLUMN line_target_url VARCHAR(500) NULL');
|
||||||
await q(`CREATE TABLE IF NOT EXISTS daily_views (
|
await q(`CREATE TABLE IF NOT EXISTS daily_views (
|
||||||
email VARCHAR(190) NOT NULL,
|
email VARCHAR(190) NOT NULL,
|
||||||
day CHAR(10) NOT NULL,
|
day CHAR(10) NOT NULL,
|
||||||
@@ -93,11 +95,13 @@ async function bootstrap() {
|
|||||||
email VARCHAR(190) NOT NULL,
|
email VARCHAR(190) NOT NULL,
|
||||||
delivered BIGINT NOT NULL,
|
delivered BIGINT NOT NULL,
|
||||||
read_ts BIGINT NULL,
|
read_ts BIGINT NULL,
|
||||||
|
visited_ts BIGINT NULL,
|
||||||
rewarded TINYINT NOT NULL DEFAULT 0,
|
rewarded TINYINT NOT NULL DEFAULT 0,
|
||||||
rewarded_day CHAR(10) NULL,
|
rewarded_day CHAR(10) NULL,
|
||||||
UNIQUE KEY uq_solo (campaign_id, email),
|
UNIQUE KEY uq_solo (campaign_id, email),
|
||||||
INDEX (email), INDEX (email, rewarded, rewarded_day)
|
INDEX (email), INDEX (email, rewarded, rewarded_day)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
await alterSafe('ALTER TABLE solo_inbox ADD COLUMN visited_ts BIGINT NULL');
|
||||||
// solo message bodies are sanitized rich text; TEXT gives them room
|
// solo message bodies are sanitized rich text; TEXT gives them room
|
||||||
await alterSafe('ALTER TABLE campaigns MODIFY body TEXT NULL');
|
await alterSafe('ALTER TABLE campaigns MODIFY body TEXT NULL');
|
||||||
await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL');
|
await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL');
|
||||||
|
|||||||
+168
-33
@@ -26,6 +26,25 @@
|
|||||||
if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.';
|
if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.';
|
||||||
return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.';
|
return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.';
|
||||||
}
|
}
|
||||||
|
// milestone stepper under "Your next move": lit nodes for what's done,
|
||||||
|
// amber glow on the current target — the same ladder the contract pays
|
||||||
|
function renderSteps(d) {
|
||||||
|
const el = $('ncSteps');
|
||||||
|
if (!el) return;
|
||||||
|
const b = d.buyerCount || 0;
|
||||||
|
const steps = [
|
||||||
|
{ label: 'Joined', sub: 'free account', hit: true },
|
||||||
|
{ label: 'Payouts on', sub: 'wallet linked', hit: !!d.memberId },
|
||||||
|
{ label: 'First buyer', sub: '50% of every pack', hit: b >= 1 },
|
||||||
|
{ label: 'Level 2', sub: '2 buyers · +20%', hit: b >= 2 },
|
||||||
|
{ label: 'Level 3', sub: '5 buyers · +10%', hit: b >= 5 }
|
||||||
|
];
|
||||||
|
const cur = steps.findIndex(s => !s.hit);
|
||||||
|
el.innerHTML = steps.map((s, i) =>
|
||||||
|
'<div class="nc-step' + (s.hit ? ' hit' : i === cur ? ' cur' : '') + '">'
|
||||||
|
+ '<span class="dot">' + (s.hit ? '✓' : i + 1) + '</span>'
|
||||||
|
+ '<span class="lb">' + s.label + '<i>' + s.sub + '</i></span></div>').join('');
|
||||||
|
}
|
||||||
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
|
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
|
||||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
|
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
|
||||||
@@ -129,7 +148,7 @@
|
|||||||
setInboxBadge(d.inboxUnread || 0);
|
setInboxBadge(d.inboxUnread || 0);
|
||||||
loadCharts(d);
|
loadCharts(d);
|
||||||
$('nextMove').textContent = nextMove(d);
|
$('nextMove').textContent = nextMove(d);
|
||||||
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
|
renderSteps(d);
|
||||||
const wrap = $('rosterWrap');
|
const wrap = $('rosterWrap');
|
||||||
if ((d.referrals || []).length) {
|
if ((d.referrals || []).length) {
|
||||||
$('rosterEmpty').hidden = true;
|
$('rosterEmpty').hidden = true;
|
||||||
@@ -167,6 +186,17 @@
|
|||||||
// ready-to-send share message + promo tools, personalized
|
// ready-to-send share message + promo tools, personalized
|
||||||
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
|
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
|
||||||
fillPromo(link);
|
fillPromo(link);
|
||||||
|
if (d.username) { // wall link rides the username
|
||||||
|
const wl = location.origin + '/wall/' + d.username;
|
||||||
|
$('wallLine').textContent = wl;
|
||||||
|
$('wallCopy').hidden = false;
|
||||||
|
$('wallOpen').hidden = false;
|
||||||
|
$('wallOpen').href = '/wall/' + d.username;
|
||||||
|
$('wallCopy').onclick = async () => {
|
||||||
|
try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); }
|
||||||
|
catch (e) { IAP.status('Copy failed. Select the link text instead.', 'bad'); }
|
||||||
|
};
|
||||||
|
}
|
||||||
if (d.refCode || d.memberId) {
|
if (d.refCode || d.memberId) {
|
||||||
const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. '
|
const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. '
|
||||||
+ 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. '
|
+ 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. '
|
||||||
@@ -197,6 +227,7 @@
|
|||||||
b.classList.toggle('on', b.dataset.pane === name));
|
b.classList.toggle('on', b.dataset.pane === name));
|
||||||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||||||
if (name === 'inbox') loadInbox();
|
if (name === 'inbox') loadInbox();
|
||||||
|
if (name === 'profile') loadLineBanner();
|
||||||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||||||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||||||
}
|
}
|
||||||
@@ -332,7 +363,6 @@
|
|||||||
$('cBudget').addEventListener('input', soloHint);
|
$('cBudget').addEventListener('input', soloHint);
|
||||||
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
|
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
|
||||||
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
|
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
|
||||||
let soloMedia = null; // { url, type } from /api/my/upload
|
|
||||||
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
|
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
|
||||||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
|
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
|
||||||
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
|
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
|
||||||
@@ -343,32 +373,34 @@
|
|||||||
$('cSoloEd').focus();
|
$('cSoloEd').focus();
|
||||||
document.execCommand('createLink', false, url);
|
document.execCommand('createLink', false, url);
|
||||||
});
|
});
|
||||||
$('edAttachBtn').addEventListener('click', () => $('cSoloFile').click());
|
// inline media: upload, then drop the element at the cursor (BV-style)
|
||||||
|
let mediaMode = 'image';
|
||||||
|
function insertHtmlAtCursor(html) {
|
||||||
|
const ed = $('cSoloEd');
|
||||||
|
ed.focus();
|
||||||
|
if (!document.execCommand('insertHTML', false, html)) ed.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
$('edImgBtn').addEventListener('click', () => { mediaMode = 'image'; $('cSoloFile').accept = 'image/png,image/jpeg,image/webp,image/gif'; $('cSoloFile').click(); });
|
||||||
|
$('edVidBtn').addEventListener('click', () => { mediaMode = 'video'; $('cSoloFile').accept = 'video/mp4,video/webm'; $('cSoloFile').click(); });
|
||||||
$('cSoloFile').addEventListener('change', async () => {
|
$('cSoloFile').addEventListener('change', async () => {
|
||||||
const f = $('cSoloFile').files[0];
|
const f = $('cSoloFile').files[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
|
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
|
||||||
try {
|
try {
|
||||||
const r = await (await fetch('/api/my/upload', { method: 'POST',
|
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||||
headers: { 'Content-Type': f.type }, body: f })).json();
|
|
||||||
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
|
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
|
||||||
soloMedia = r;
|
insertHtmlAtCursor(r.type === 'video'
|
||||||
$('edMediaInfo').textContent = f.name + ' attached';
|
? '<video src="' + r.url + '" controls playsinline></video><p><br></p>'
|
||||||
$('edMediaRemove').hidden = false;
|
: '<img src="' + r.url + '" alt=""><p><br></p>');
|
||||||
const pv = $('edMediaPrev');
|
$('edMediaInfo').textContent = f.name + ' inserted';
|
||||||
pv.hidden = false;
|
|
||||||
pv.innerHTML = r.type === 'video'
|
|
||||||
? '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>'
|
|
||||||
: '<img src="' + r.url + '" alt="attachment preview" style="max-width:320px;border-radius:10px">';
|
|
||||||
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
||||||
$('cSoloFile').value = '';
|
$('cSoloFile').value = '';
|
||||||
});
|
});
|
||||||
$('edMediaRemove').addEventListener('click', () => {
|
// raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back
|
||||||
soloMedia = null;
|
$('edRawBtn').addEventListener('click', () => {
|
||||||
$('edMediaInfo').textContent = '';
|
const ed = $('cSoloEd'), raw = $('cSoloRaw');
|
||||||
$('edMediaRemove').hidden = true;
|
if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; }
|
||||||
$('edMediaPrev').hidden = true;
|
else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; }
|
||||||
$('edMediaPrev').innerHTML = '';
|
|
||||||
});
|
});
|
||||||
$('cType').addEventListener('change', () => {
|
$('cType').addEventListener('change', () => {
|
||||||
const t = $('cType').value;
|
const t = $('cType').value;
|
||||||
@@ -381,17 +413,19 @@
|
|||||||
soloHint();
|
soloHint();
|
||||||
});
|
});
|
||||||
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||||||
|
// in raw mode the source of truth is the textarea; sync it back first
|
||||||
|
let soloBody = $('cSoloEd').innerHTML;
|
||||||
|
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
|
||||||
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
|
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
|
||||||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
|
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
|
||||||
title: $('cTitle').value,
|
title: $('cTitle').value,
|
||||||
body: $('cType').value === 'solo' ? $('cSoloEd').innerHTML : $('cBody').value,
|
body: $('cType').value === 'solo' ? soloBody : $('cBody').value,
|
||||||
mediaUrl: $('cType').value === 'solo' && soloMedia ? soloMedia.url : '',
|
|
||||||
ctaLabel: $('cCtaLabel').value,
|
ctaLabel: $('cCtaLabel').value,
|
||||||
budget: Number($('cBudget').value) });
|
budget: Number($('cBudget').value) });
|
||||||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||||||
$('cName').value = ''; $('cBudget').value = '';
|
$('cName').value = ''; $('cBudget').value = '';
|
||||||
$('cSoloEd').innerHTML = ''; $('cCtaLabel').value = '';
|
$('cSoloEd').innerHTML = ''; $('cSoloRaw').value = ''; $('cCtaLabel').value = '';
|
||||||
if ($('edMediaRemove') && !$('edMediaRemove').hidden) $('edMediaRemove').click();
|
$('edMediaInfo').textContent = '';
|
||||||
await loadCampaigns();
|
await loadCampaigns();
|
||||||
}));
|
}));
|
||||||
// defers the busy() lookup to click time (busy is declared below)
|
// defers the busy() lookup to click time (busy is declared below)
|
||||||
@@ -446,29 +480,49 @@
|
|||||||
: r.mediaType === 'video'
|
: r.mediaType === 'video'
|
||||||
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
|
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
|
||||||
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
|
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
|
||||||
$('ibVisit').href = r.url;
|
// the read reward needs BOTH the dwell AND an actual click-through to the
|
||||||
$('ibVisit').textContent = r.ctaLabel || 'Learn more';
|
// advertiser — the visit is what makes the ad worth the sender's credits
|
||||||
|
const visit = $('ibVisit');
|
||||||
|
visit.href = r.url;
|
||||||
|
visit.textContent = r.ctaLabel || 'Learn more';
|
||||||
|
visit.target = '_blank';
|
||||||
const btn = $('ibClaimBtn');
|
const btn = $('ibClaimBtn');
|
||||||
clearInterval(ibTimer);
|
clearInterval(ibTimer);
|
||||||
if (r.rewarded) {
|
if (r.rewarded) {
|
||||||
btn.hidden = true;
|
btn.hidden = true;
|
||||||
|
visit.classList.remove('cta-need');
|
||||||
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let dwellDone = false;
|
||||||
|
let visited = !!r.visited;
|
||||||
btn.hidden = false;
|
btn.hidden = false;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
visit.classList.toggle('cta-need', !visited);
|
||||||
let left = r.dwell;
|
let left = r.dwell;
|
||||||
btn.textContent = 'Read it — claim in ' + left + 's';
|
const refresh = () => {
|
||||||
$('ibHint').textContent = 'Stay on this tab while you read; the claim unlocks when the timer is done.';
|
if (!dwellDone) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
|
||||||
|
if (!visited) { btn.textContent = 'Claim +' + r.reward + ' — visit the ad first'; btn.disabled = true; return; }
|
||||||
|
btn.textContent = 'Claim +' + r.reward + ' credits'; btn.disabled = false;
|
||||||
|
};
|
||||||
|
$('ibHint').textContent = 'Read the message, click through to the advertiser, then claim your credits.';
|
||||||
|
refresh();
|
||||||
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
|
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
|
||||||
ibTimer = setInterval(() => {
|
ibTimer = setInterval(() => {
|
||||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||||||
left -= 1;
|
left -= 1;
|
||||||
if (left > 0) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
|
if (left > 0) { refresh(); return; }
|
||||||
clearInterval(ibTimer);
|
clearInterval(ibTimer);
|
||||||
btn.disabled = false;
|
dwellDone = true;
|
||||||
btn.textContent = 'Claim +' + r.reward + ' credits';
|
refresh();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
// clicking the CTA records the visit (and counts the advertiser's click)
|
||||||
|
visit.onclick = async () => {
|
||||||
|
visited = true;
|
||||||
|
visit.classList.remove('cta-need');
|
||||||
|
try { await fetch('/api/my/inbox/' + id + '/visit', { method: 'POST' }); } catch (e2) {}
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const c = await api('/api/my/inbox/' + id + '/claim');
|
const c = await api('/api/my/inbox/' + id + '/claim');
|
||||||
@@ -644,7 +698,7 @@
|
|||||||
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
|
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
|
||||||
await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value });
|
await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value });
|
||||||
IAP.status('You are in.', 'ok');
|
IAP.status('You are in.', 'ok');
|
||||||
await showLoginAd();
|
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||||||
await render();
|
await render();
|
||||||
}));
|
}));
|
||||||
})();
|
})();
|
||||||
@@ -657,7 +711,7 @@
|
|||||||
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
||||||
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
||||||
IAP.status('Logged in.', 'ok');
|
IAP.status('Logged in.', 'ok');
|
||||||
await showLoginAd();
|
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||||||
await render();
|
await render();
|
||||||
}));
|
}));
|
||||||
$('walletSigninLink').addEventListener('click', async e => {
|
$('walletSigninLink').addEventListener('click', async e => {
|
||||||
@@ -666,7 +720,7 @@
|
|||||||
IAP.status('Check your wallet for the free sign-in signature…');
|
IAP.status('Check your wallet for the free sign-in signature…');
|
||||||
await IAPWallet.signIn();
|
await IAPWallet.signIn();
|
||||||
IAP.status('Signed in with your wallet.', 'ok');
|
IAP.status('Signed in with your wallet.', 'ok');
|
||||||
await showLoginAd();
|
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||||||
await render();
|
await render();
|
||||||
} catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
|
} catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
|
||||||
});
|
});
|
||||||
@@ -695,6 +749,87 @@
|
|||||||
await render();
|
await render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── welcome tour (viral banner gauntlet): a new member meets their 3-level
|
||||||
|
// upline's sites, 10 focus-paused seconds each, then unlocks welcome credits.
|
||||||
|
// Same three levels the contract pays — the tour IS the org chart.
|
||||||
|
async function showGauntlet() {
|
||||||
|
try {
|
||||||
|
const g = await (await fetch('/api/my/gauntlet')).json();
|
||||||
|
if (!g.pending || !g.slides || !g.slides.length) return false;
|
||||||
|
const gate = $('gauntGate');
|
||||||
|
gate.hidden = false;
|
||||||
|
for (let i = 0; i < g.slides.length; i++) {
|
||||||
|
const s = g.slides[i];
|
||||||
|
$('ggWho').textContent = 'Position ' + (i + 1) + ': ' + s.name + (i === 0 ? ' — the person who invited you' : '');
|
||||||
|
$('ggProgress').textContent = 'Meeting your line: ' + (i + 1) + ' of ' + g.slides.length;
|
||||||
|
$('ggFrame').src = s.targetUrl;
|
||||||
|
let left = g.dwell || 10;
|
||||||
|
$('ggTimer').textContent = left + 's';
|
||||||
|
await new Promise(done => {
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||||||
|
left -= 1;
|
||||||
|
$('ggTimer').textContent = Math.max(0, left) + 's';
|
||||||
|
if (left <= 0) { clearInterval(t); done(); }
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('ggFrame').src = 'about:blank';
|
||||||
|
$('ggTimer').textContent = '✓';
|
||||||
|
$('ggWho').textContent = 'That is your line. When you grow, they earn — and yours starts the day you share.';
|
||||||
|
$('ggClaim').hidden = false;
|
||||||
|
await new Promise(done => {
|
||||||
|
$('ggClaim').onclick = async () => {
|
||||||
|
try {
|
||||||
|
const r = await api('/api/my/gauntlet/complete', { token: g.token });
|
||||||
|
IAP.status('+' + r.credited + ' welcome credits unlocked. They spend on real campaigns.', 'ok');
|
||||||
|
} catch (e) { IAP.status(e.message, 'bad'); }
|
||||||
|
done();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
gate.hidden = true;
|
||||||
|
$('ggClaim').hidden = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── line banner (profile): the member's slot on welcome tours + their wall ──
|
||||||
|
function fillLineBanner(a) {
|
||||||
|
if (!a) return;
|
||||||
|
if (a.lineTargetUrl) $('lbTarget').value = a.lineTargetUrl;
|
||||||
|
if (a.lineBannerUrl) {
|
||||||
|
$('lbBanner').value = a.lineBannerUrl;
|
||||||
|
$('lbPreview').hidden = false;
|
||||||
|
$('lbPreview').innerHTML = '<img src="' + a.lineBannerUrl + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||||||
|
}
|
||||||
|
$('lbCurrent').textContent = a.lineTargetUrl
|
||||||
|
? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.'
|
||||||
|
: 'Not set yet. Until you set one, your tour slot is skipped.';
|
||||||
|
}
|
||||||
|
async function loadLineBanner() {
|
||||||
|
try { fillLineBanner(await (await fetch('/api/me')).json()); } catch (e) {}
|
||||||
|
}
|
||||||
|
$('lbUploadBtn').addEventListener('click', () => $('lbFile').click());
|
||||||
|
$('lbFile').addEventListener('change', async () => {
|
||||||
|
const f = $('lbFile').files[0];
|
||||||
|
if (!f) return;
|
||||||
|
$('lbUpInfo').textContent = 'Uploading…';
|
||||||
|
try {
|
||||||
|
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||||
|
if (r.error) { $('lbUpInfo').textContent = r.error; $('lbFile').value = ''; return; }
|
||||||
|
$('lbBanner').value = r.url;
|
||||||
|
$('lbUpInfo').textContent = 'Uploaded.';
|
||||||
|
$('lbPreview').hidden = false;
|
||||||
|
$('lbPreview').innerHTML = '<img src="' + r.url + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||||||
|
} catch (e) { $('lbUpInfo').textContent = 'Upload failed. Try again.'; }
|
||||||
|
$('lbFile').value = '';
|
||||||
|
});
|
||||||
|
$('lbSaveBtn').addEventListener('click', busy2($('lbSaveBtn'), async () => {
|
||||||
|
const r = await api('/api/my/linebanner', { bannerUrl: $('lbBanner').value, targetUrl: $('lbTarget').value });
|
||||||
|
IAP.status('Line banner saved. Your next three levels will meet it.', 'ok');
|
||||||
|
if (r.account) fillLineBanner(r.account);
|
||||||
|
}));
|
||||||
|
|
||||||
// ── login ad interstitial (ClickBaitPays pattern): after a successful
|
// ── login ad interstitial (ClickBaitPays pattern): after a successful
|
||||||
// sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW
|
// sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW
|
||||||
// tab (a real, counted click) while the timer counts down on THIS page —
|
// tab (a real, counted click) while the timer counts down on THIS page —
|
||||||
|
|||||||
@@ -259,6 +259,30 @@ textarea{resize:vertical;font:inherit}
|
|||||||
#boBurger{display:block}
|
#boBurger{display:block}
|
||||||
.bo-content{padding:18px}
|
.bo-content{padding:18px}
|
||||||
}
|
}
|
||||||
|
/* ── "Your next move": milestone stepper card ── */
|
||||||
|
.next-card{position:relative;overflow:hidden;border-color:rgba(67,232,195,.28)}
|
||||||
|
.next-card::before{content:"";position:absolute;inset:0;pointer-events:none;background:
|
||||||
|
radial-gradient(420px 150px at 10% 0%,rgba(67,232,195,.12),transparent 70%),
|
||||||
|
radial-gradient(360px 130px at 90% 100%,rgba(157,125,255,.09),transparent 70%)}
|
||||||
|
.nc-head{display:flex;gap:14px;align-items:flex-start;position:relative}
|
||||||
|
.nc-head .pl{flex:0 0 auto;width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
|
||||||
|
background:rgba(67,232,195,.12);border:1px solid rgba(67,232,195,.35)}
|
||||||
|
.nc-head .pl svg{width:20px;height:20px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.nc-steps{display:flex;margin-top:20px;position:relative;flex-wrap:wrap;row-gap:14px}
|
||||||
|
.nc-step{flex:1;min-width:88px;display:flex;flex-direction:column;align-items:center;gap:7px;
|
||||||
|
position:relative;text-align:center;padding:0 4px}
|
||||||
|
.nc-step::before{content:"";position:absolute;top:13px;left:-50%;width:100%;height:2px;background:var(--line-strong)}
|
||||||
|
.nc-step:first-child::before{display:none}
|
||||||
|
.nc-step.hit::before{background:linear-gradient(90deg,var(--mint),var(--mint-hi))}
|
||||||
|
.nc-step .dot{width:27px;height:27px;border-radius:50%;display:grid;place-items:center;font-size:12px;
|
||||||
|
font-weight:800;background:var(--panel-solid);border:2px solid var(--line-strong);color:var(--muted);
|
||||||
|
position:relative;z-index:1}
|
||||||
|
.nc-step.hit .dot{background:var(--mint);border-color:var(--mint);color:var(--mint-ink);
|
||||||
|
box-shadow:0 0 14px rgba(67,232,195,.35)}
|
||||||
|
.nc-step.cur .dot{border-color:var(--amber);color:var(--amber);box-shadow:0 0 12px rgba(255,178,56,.4)}
|
||||||
|
.nc-step .lb{font-size:12px;font-weight:700;line-height:1.25}
|
||||||
|
.nc-step .lb i{display:block;font-style:normal;font-weight:500;font-size:10.5px;color:var(--muted);margin-top:2px}
|
||||||
|
.nc-step.cur .lb{color:var(--amber)}
|
||||||
/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */
|
/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */
|
||||||
.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);
|
.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);
|
||||||
border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
|
border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
|
||||||
@@ -310,6 +334,13 @@ textarea{resize:vertical;font:inherit}
|
|||||||
.lgate-card img{max-width:100%;max-height:50vh;border-radius:12px;border:1px solid var(--line-strong)}
|
.lgate-card img{max-width:100%;max-height:50vh;border-radius:12px;border:1px solid var(--line-strong)}
|
||||||
#lgCreative .lg-linkcard{display:inline-block;background:var(--panel);border:1px solid var(--line-strong);
|
#lgCreative .lg-linkcard{display:inline-block;background:var(--panel);border:1px solid var(--line-strong);
|
||||||
border-radius:14px;padding:22px 28px;font-family:var(--disp);font-weight:700;font-size:18px;overflow-wrap:anywhere}
|
border-radius:14px;padding:22px 28px;font-family:var(--disp);font-weight:700;font-size:18px;overflow-wrap:anywhere}
|
||||||
|
/* ── welcome tour (gauntlet): framed line sites + countdown ── */
|
||||||
|
.lgate-frame{flex:1;border:0;width:100%;background:#fff;min-height:0}
|
||||||
|
.ggmeta{padding:8px 16px;background:var(--panel-solid);border-bottom:1px solid var(--line)}
|
||||||
|
/* ── wall page ── */
|
||||||
|
.wall-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;text-align:center}
|
||||||
|
.wall-card img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
|
||||||
|
.wall-pos{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px}
|
||||||
/* ── solo composer: toolbar + contenteditable editor ── */
|
/* ── solo composer: toolbar + contenteditable editor ── */
|
||||||
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||||
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
|
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
|
||||||
@@ -319,6 +350,10 @@ textarea{resize:vertical;font:inherit}
|
|||||||
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
|
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
|
||||||
.ed-body:focus{border-color:var(--mint)}
|
.ed-body:focus{border-color:var(--mint)}
|
||||||
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
|
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
|
||||||
|
.ed-body img,.ed-body video,.ib-rich img,.ib-rich video{max-width:100%;border-radius:10px;margin:6px 0;display:block}
|
||||||
|
.ed-sep{width:1px;align-self:stretch;background:var(--line-strong);margin:2px 3px}
|
||||||
|
.ed-bar sub{font-size:9px}
|
||||||
|
textarea.ed-body{width:100%;min-height:180px;resize:vertical}
|
||||||
.ed-body a,.ib-rich a{color:var(--mint)}
|
.ed-body a,.ib-rich a{color:var(--mint)}
|
||||||
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
|
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
|
||||||
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
|
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
|
||||||
@@ -331,6 +366,9 @@ textarea{resize:vertical;font:inherit}
|
|||||||
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
|
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
|
||||||
.ib-row.unread .sub{color:var(--mint)}
|
.ib-row.unread .sub{color:var(--mint)}
|
||||||
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
|
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
|
||||||
|
.cta-need{box-shadow:0 0 0 2px var(--amber),0 0 16px rgba(255,178,56,.4)!important;animation:ctapulse 1.6s ease-in-out infinite}
|
||||||
|
@keyframes ctapulse{50%{box-shadow:0 0 0 2px var(--amber),0 0 24px rgba(255,178,56,.65)!important}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.cta-need{animation:none}}
|
||||||
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
|
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
|
||||||
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
|
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
|
||||||
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
|
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Public banner wall: a member's line banner plus their upline ladder, with
|
||||||
|
// their join link. The viral surface: members send traffic here, every visit
|
||||||
|
// puts eyes on the whole line.
|
||||||
|
(async function () {
|
||||||
|
await IAP.renderNav('');
|
||||||
|
const name = location.pathname.split('/').pop();
|
||||||
|
let w = null;
|
||||||
|
try { w = await (await fetch('/api/wall/' + encodeURIComponent(name))).json(); } catch (e) {}
|
||||||
|
const title = IAP.$('wallTitle');
|
||||||
|
if (!w || w.error) {
|
||||||
|
title.textContent = 'No wall under that name.';
|
||||||
|
IAP.$('wallJoin').href = '/my';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
title.innerHTML = 'The <em>' + String(w.name).replace(/[&<>]/g, '') + '</em> line';
|
||||||
|
IAP.$('wallCtaHead').textContent = 'Join ' + w.name + '’s line';
|
||||||
|
IAP.$('wallJoin').href = w.joinUrl;
|
||||||
|
const grid = IAP.$('wallGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
w.ladder.forEach((m, i) => {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'wall-card';
|
||||||
|
const safe = String(m.name || 'member').replace(/[&<>]/g, '');
|
||||||
|
d.innerHTML = '<div class="wall-pos">Position ' + (i + 1) + (i === 0 ? ' · this wall' : '') + '</div>'
|
||||||
|
+ (m.bannerUrl
|
||||||
|
? '<a href="' + m.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + m.bannerUrl + '" alt="' + safe + ' banner"></a>'
|
||||||
|
: m.targetUrl
|
||||||
|
? '<a href="' + m.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + safe + '</b><br><span class="muted small">visit their site</span></a>'
|
||||||
|
: '<b>' + safe + '</b><br><span class="muted small">banner slot open</span>')
|
||||||
|
+ '<div class="small muted" style="margin-top:8px">' + safe + '</div>';
|
||||||
|
grid.appendChild(d);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<title>The contract | InstantAdPay</title>
|
<title>The contract | InstantAdPay</title>
|
||||||
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
|
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||||
<link rel="stylesheet" href="/assets/site.css?v=20260905r">
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -129,8 +129,8 @@
|
|||||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/common.js?v=20260905r"></script>
|
<script src="/assets/common.js?v=20260905u"></script>
|
||||||
<script src="/assets/contract.js?v=20260905r"></script>
|
<script src="/assets/contract.js?v=20260905u"></script>
|
||||||
<script src="/assets/chat.js?v=20260905r"></script>
|
<script src="/assets/chat.js?v=20260905u"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+6
-5
@@ -5,7 +5,7 @@
|
|||||||
<title>InstantAdPay: advertise and earn, locked in code</title>
|
<title>InstantAdPay: advertise and earn, locked in code</title>
|
||||||
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
|
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||||
<link rel="stylesheet" href="/assets/site.css?v=20260905r">
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -260,6 +260,7 @@
|
|||||||
<ul class="checks">
|
<ul class="checks">
|
||||||
<li>A member account and the live ledger</li>
|
<li>A member account and the live ledger</li>
|
||||||
<li>Welcome credits to taste real ad delivery</li>
|
<li>Welcome credits to taste real ad delivery</li>
|
||||||
|
<li>Your line banner, shown to your next three levels as they join</li>
|
||||||
<li>Your personal referral link, working from day one</li>
|
<li>Your personal referral link, working from day one</li>
|
||||||
<li>Earnings from day one on your referrals' package purchases</li>
|
<li>Earnings from day one on your referrals' package purchases</li>
|
||||||
<li>Access to the member dashboard</li>
|
<li>Access to the member dashboard</li>
|
||||||
@@ -409,9 +410,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="/assets/common.js?v=20260905r"></script>
|
<script src="/assets/common.js?v=20260905u"></script>
|
||||||
<script src="/assets/wallet.js?v=20260905r"></script>
|
<script src="/assets/wallet.js?v=20260905u"></script>
|
||||||
<script src="/assets/home.js?v=20260905r"></script>
|
<script src="/assets/home.js?v=20260905u"></script>
|
||||||
<script src="/assets/chat.js?v=20260905r"></script>
|
<script src="/assets/chat.js?v=20260905u"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@
|
|||||||
<title>Live ledger | InstantAdPay</title>
|
<title>Live ledger | InstantAdPay</title>
|
||||||
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
|
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||||
<link rel="stylesheet" href="/assets/site.css?v=20260905r">
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/common.js?v=20260905r"></script>
|
<script src="/assets/common.js?v=20260905u"></script>
|
||||||
<script src="/assets/ledger.js?v=20260905r"></script>
|
<script src="/assets/ledger.js?v=20260905u"></script>
|
||||||
<script src="/assets/chat.js?v=20260905r"></script>
|
<script src="/assets/chat.js?v=20260905u"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+68
-25
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Member area | InstantAdPay</title>
|
<title>Member area | InstantAdPay</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||||
<link rel="stylesheet" href="/assets/site.css?v=20260905r">
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
</head>
|
</head>
|
||||||
<body class="bo-body">
|
<body class="bo-body">
|
||||||
|
|
||||||
@@ -67,6 +67,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── welcome tour: meet your 3-level line, then unlock welcome credits ── -->
|
||||||
|
<div id="gauntGate" class="lgate" hidden>
|
||||||
|
<div class="lgate-bar">
|
||||||
|
<span class="lg-brand">Instant<em>AdPay</em></span>
|
||||||
|
<span class="lgate-note small muted" id="ggNote">Welcome tour: meet the three levels your growth pays.</span>
|
||||||
|
<span class="lg-timer mono" id="ggTimer">…</span>
|
||||||
|
</div>
|
||||||
|
<div class="ggmeta small muted" id="ggWho"></div>
|
||||||
|
<iframe class="lgate-frame" id="ggFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Team member site"></iframe>
|
||||||
|
<div class="lgate-bar" style="border-top:1px solid var(--line);border-bottom:0">
|
||||||
|
<span class="small muted" id="ggProgress"></span>
|
||||||
|
<button class="btn small" id="ggClaim" type="button" hidden>Claim your welcome credits</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── signed-in: back-office shell ──────────────────────── -->
|
<!-- ── signed-in: back-office shell ──────────────────────── -->
|
||||||
<div id="memberArea" class="bo" hidden>
|
<div id="memberArea" class="bo" hidden>
|
||||||
<aside class="bo-side" id="boSide">
|
<aside class="bo-side" id="boSide">
|
||||||
@@ -152,15 +167,15 @@
|
|||||||
<div class="chart-x" id="chCampX"></div>
|
<div class="chart-x" id="chCampX"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="nextCard">
|
<div class="card next-card" id="nextCard">
|
||||||
<h3>Your next move</h3>
|
<div class="nc-head">
|
||||||
<p class="muted" id="nextMove">…</p>
|
<div class="pl"><svg viewBox="0 0 24 24"><path d="M6 3v18"/><path d="M6 4h11l-2.5 4 2.5 4H6"/></svg></div>
|
||||||
<div id="qualBarWrap">
|
<div>
|
||||||
<div class="qualbar"><div id="qualFill"></div>
|
<h3 style="margin:0">Your next move</h3>
|
||||||
<span class="qb-mark" style="left:40%">2 · level 2</span>
|
<p class="muted small" id="nextMove" style="margin:5px 0 0">…</p>
|
||||||
<span class="qb-mark end">5 · level 3</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nc-steps" id="ncSteps"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid c2">
|
<div class="grid c2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -247,24 +262,29 @@
|
|||||||
<button type="button" data-cmd="bold" title="Bold"><b>B</b></button>
|
<button type="button" data-cmd="bold" title="Bold"><b>B</b></button>
|
||||||
<button type="button" data-cmd="italic" title="Italic"><i>I</i></button>
|
<button type="button" data-cmd="italic" title="Italic"><i>I</i></button>
|
||||||
<button type="button" data-cmd="underline" title="Underline"><u>U</u></button>
|
<button type="button" data-cmd="underline" title="Underline"><u>U</u></button>
|
||||||
<button type="button" data-block="h3" title="Heading">Heading</button>
|
<span class="ed-sep"></span>
|
||||||
<button type="button" data-block="p" title="Normal text">Normal</button>
|
<button type="button" data-block="h2" title="Heading">H<sub>2</sub></button>
|
||||||
|
<button type="button" data-block="h3" title="Subheading">H<sub>3</sub></button>
|
||||||
|
<button type="button" data-block="p" title="Normal text">¶</button>
|
||||||
|
<span class="ed-sep"></span>
|
||||||
<button type="button" data-cmd="insertUnorderedList" title="Bullet list">• List</button>
|
<button type="button" data-cmd="insertUnorderedList" title="Bullet list">• List</button>
|
||||||
<button type="button" data-cmd="insertOrderedList" title="Numbered list">1. List</button>
|
<button type="button" data-cmd="insertOrderedList" title="Numbered list">1. List</button>
|
||||||
<button type="button" id="edLinkBtn" title="Insert link">Link</button>
|
<button type="button" id="edLinkBtn" title="Insert link">🔗 Link</button>
|
||||||
<button type="button" data-cmd="removeFormat" title="Clear formatting">Clear</button>
|
<span class="ed-sep"></span>
|
||||||
|
<button type="button" id="edImgBtn" title="Insert image">🖼 Image</button>
|
||||||
|
<button type="button" id="edVidBtn" title="Insert video">🎬 Video</button>
|
||||||
|
<span class="ed-sep"></span>
|
||||||
|
<button type="button" data-cmd="undo" title="Undo">↺</button>
|
||||||
|
<button type="button" data-cmd="redo" title="Redo">↻</button>
|
||||||
|
<button type="button" id="edRawBtn" title="Toggle raw text" style="margin-left:auto">Raw text</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="cSoloEd" class="ed-body" contenteditable="true"
|
<div id="cSoloEd" class="ed-body" contenteditable="true"
|
||||||
data-ph="Write it like an email worth reading. Bold the promise, list the proof, link the receipts."></div>
|
data-ph="Write it like an email worth reading. Bold the promise, list the proof, drop in an image or video, link the receipts."></div>
|
||||||
<p style="margin:10px 0 0"><input id="cCtaLabel" maxlength="30" style="width:100%"
|
<textarea id="cSoloRaw" class="ed-body" hidden style="font-family:var(--mono);font-size:13px"></textarea>
|
||||||
|
<input type="file" id="cSoloFile" accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm" hidden>
|
||||||
|
<span id="edMediaInfo" class="small muted"></span>
|
||||||
|
<p style="margin:12px 0 0"><input id="cCtaLabel" maxlength="30" style="width:100%"
|
||||||
placeholder="Call-to-action button label (default: Learn more) — the button opens your target URL"></p>
|
placeholder="Call-to-action button label (default: Learn more) — the button opens your target URL"></p>
|
||||||
<p class="ed-media">
|
|
||||||
<input type="file" id="cSoloFile" accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm" hidden>
|
|
||||||
<button type="button" class="btn small sec" id="edAttachBtn">Attach image or video</button>
|
|
||||||
<span id="edMediaInfo" class="small muted"></span>
|
|
||||||
<button type="button" class="btn small sec" id="edMediaRemove" hidden>Remove</button>
|
|
||||||
</p>
|
|
||||||
<div id="edMediaPrev" hidden style="margin-top:8px"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="small muted" id="cSoloHint" hidden></p>
|
<p class="small muted" id="cSoloHint" hidden></p>
|
||||||
<button class="btn" id="createCampBtn">Launch campaign</button>
|
<button class="btn" id="createCampBtn">Launch campaign</button>
|
||||||
@@ -350,6 +370,15 @@
|
|||||||
<h3>Email swipe</h3>
|
<h3>Email swipe</h3>
|
||||||
<p class="muted small" id="promoSwipeWrap"></p>
|
<p class="muted small" id="promoSwipeWrap"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Your banner wall</h3>
|
||||||
|
<p class="muted small">A public page showing your line banner and your upline ladder, with your
|
||||||
|
join link front and center. Every visit puts eyes on your whole line — send traffic here and
|
||||||
|
the wall does the pitching.</p>
|
||||||
|
<p class="mono small" id="wallLine" style="overflow-wrap:anywhere">Set a username in Profile to claim your wall.</p>
|
||||||
|
<p><button class="btn small sec" id="wallCopy" type="button" hidden>Copy wall link</button>
|
||||||
|
<a class="btn small sec" id="wallOpen" target="_blank" rel="noopener" hidden>Open your wall</a></p>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Banners</h3>
|
<h3>Banners</h3>
|
||||||
<p class="muted small">Branded InstantAdPay banner sets are in production. They will land here
|
<p class="muted small">Branded InstantAdPay banner sets are in production. They will land here
|
||||||
@@ -366,6 +395,20 @@
|
|||||||
<button class="btn" id="pfSaveBtn">Save username</button>
|
<button class="btn" id="pfSaveBtn">Save username</button>
|
||||||
<p class="muted small" id="pfCurrent" style="margin-top:10px"></p>
|
<p class="muted small" id="pfCurrent" style="margin-top:10px"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Your line banner</h3>
|
||||||
|
<p class="muted small">Set a destination link (and a banner image for your wall), and every new
|
||||||
|
member joining your line meets it on their welcome tour: position 1 for your directs, position 2
|
||||||
|
for their people, position 3 below that — the same three levels the contract pays you on.</p>
|
||||||
|
<p><input id="lbTarget" placeholder="Destination URL (https://… — must allow framing)" style="width:100%"></p>
|
||||||
|
<p><input id="lbBanner" placeholder="Banner image URL (optional — or upload below)" style="width:100%"></p>
|
||||||
|
<p><input type="file" id="lbFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||||
|
<button class="btn small sec" id="lbUploadBtn" type="button">Upload banner image</button>
|
||||||
|
<span class="small muted" id="lbUpInfo"></span></p>
|
||||||
|
<div id="lbPreview" hidden style="margin:8px 0"></div>
|
||||||
|
<p><button class="btn" id="lbSaveBtn">Save line banner</button></p>
|
||||||
|
<p class="muted small" id="lbCurrent"></p>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Account details</h3>
|
<h3>Account details</h3>
|
||||||
<p class="muted small" id="pfDetails">…</p>
|
<p class="muted small" id="pfDetails">…</p>
|
||||||
@@ -399,9 +442,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/common.js?v=20260905r"></script>
|
<script src="/assets/common.js?v=20260905u"></script>
|
||||||
<script src="/assets/wallet.js?v=20260905r"></script>
|
<script src="/assets/wallet.js?v=20260905u"></script>
|
||||||
<script src="/assets/my.js?v=20260905r"></script>
|
<script src="/assets/my.js?v=20260905u"></script>
|
||||||
<script src="/assets/chat.js?v=20260905r"></script>
|
<script src="/assets/chat.js?v=20260905u"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Transaction | InstantAdPay</title>
|
<title>Transaction | InstantAdPay</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||||
<link rel="stylesheet" href="/assets/site.css?v=20260905r">
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="nav"></div>
|
<div id="nav"></div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
|
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script src="/assets/common.js?v=20260905r"></script>
|
<script src="/assets/common.js?v=20260905u"></script>
|
||||||
<script src="/assets/tx.js?v=20260905r"></script>
|
<script src="/assets/tx.js?v=20260905u"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Viewing ad — InstantAdPay</title>
|
<title>Viewing ad — InstantAdPay</title>
|
||||||
<link rel="stylesheet" href="/assets/site.css?v=20260905r">
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
<style>
|
<style>
|
||||||
html,body{height:100%;margin:0;overflow:hidden}
|
html,body{height:100%;margin:0;overflow:hidden}
|
||||||
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
|
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
|
||||||
@@ -43,6 +43,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/view.js?v=20260905r"></script>
|
<script src="/assets/view.js?v=20260905u"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Banner wall | InstantAdPay</title>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||||
|
<link rel="stylesheet" href="/assets/site.css?v=20260905u">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="nav"></div>
|
||||||
|
<section>
|
||||||
|
<div class="wrap" style="max-width:920px">
|
||||||
|
<div class="sectionhead" style="text-align:left">
|
||||||
|
<p class="eyebrow">Member banner wall</p>
|
||||||
|
<h2 id="wallTitle">Loading the wall…</h2>
|
||||||
|
<p>Three positions, three levels — the exact levels the contract pays. Every banner here belongs
|
||||||
|
to a real member of this line, and every payment between them settles on-chain, instantly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid c3" id="wallGrid"></div>
|
||||||
|
<div class="card" style="margin-top:22px;text-align:center">
|
||||||
|
<h3 id="wallCtaHead">Join this line</h3>
|
||||||
|
<p class="muted small">Free to join with just an email. Your wallet only comes out if you buy —
|
||||||
|
and payments go straight to member wallets, never through an admin.</p>
|
||||||
|
<p><a class="btn" id="wallJoin">Join free through this wall</a></p>
|
||||||
|
<p class="small muted"><a href="/ledger">Watch the live ledger</a> · <a href="/contract">Read the contract</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script src="/assets/common.js?v=20260905u"></script>
|
||||||
|
<script src="/assets/wall.js?v=20260905u"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -31,6 +31,24 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
|
|||||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
|
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
|
||||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
const uploadCounts = new Map(); // email:day -> uploads today
|
const uploadCounts = new Map(); // email:day -> uploads today
|
||||||
|
const gauntletTokens = new Map(); // email -> welcome-tour token (server-clock dwell floor)
|
||||||
|
// walk the referral chain upward via sponsorRef (code/username/member id)
|
||||||
|
async function uplineSlides(email, depth = 3) {
|
||||||
|
const out = [];
|
||||||
|
let cur = await accounts.byEmail(email);
|
||||||
|
for (let i = 0; i < depth && cur; i++) {
|
||||||
|
const ref = String(cur.sponsorRef || '').trim().toLowerCase();
|
||||||
|
if (!ref) break;
|
||||||
|
let s = null;
|
||||||
|
if (/^\d+$/.test(ref)) s = await accounts.byMemberId(Number(ref));
|
||||||
|
if (!s) s = await accounts.byCode(ref);
|
||||||
|
if (!s) s = await accounts.byUsername(ref);
|
||||||
|
if (!s || s.email === cur.email) break;
|
||||||
|
out.push(s);
|
||||||
|
cur = s;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
const chatHits = new Map();
|
const chatHits = new Map();
|
||||||
function chatLimited(ip) {
|
function chatLimited(ip) {
|
||||||
const now = Date.now(), rec = chatHits.get(ip);
|
const now = Date.now(), rec = chatHits.get(ip);
|
||||||
@@ -424,7 +442,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
username: (acct && acct.username) || null,
|
username: (acct && acct.username) || null,
|
||||||
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
|
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
|
||||||
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
|
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
|
||||||
if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant
|
if (out.email) {
|
||||||
|
// welcome credits unlock via the welcome tour when an upline with a
|
||||||
|
// line banner exists; members with no tour to walk get them instantly
|
||||||
|
const welcomed = await ads.welcomeGranted(out.email);
|
||||||
|
const tour = welcomed ? [] : (await uplineSlides(out.email)).filter(a => a.lineTargetUrl);
|
||||||
|
if (welcomed || !tour.length) out.welcomeCredits = await ads.grantWelcome(out.email);
|
||||||
|
else { out.welcomeCredits = 0; out.gauntletPending = true; }
|
||||||
|
}
|
||||||
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
|
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
|
||||||
if (memberId) {
|
if (memberId) {
|
||||||
try {
|
try {
|
||||||
@@ -538,6 +563,58 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const r = await ads.claimDaily(s.email);
|
const r = await ads.claimDaily(s.email);
|
||||||
return json(res, r.error ? 400 : 200, r);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
|
// -- line banner: the member's viral slot on welcome tours + their wall
|
||||||
|
if (p === '/api/my/linebanner' && req.method === 'POST') {
|
||||||
|
const s = await auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
const target = String(b.targetUrl || '').trim();
|
||||||
|
if (!/^https?:\/\/[^\s]+$/i.test(target)) return json(res, 400, { error: 'Destination URL must start with http(s)://' });
|
||||||
|
const fc = await frameCheck(target); // welcome tours frame it full screen
|
||||||
|
if (!fc.ok) return json(res, 400, { error: fc.reason });
|
||||||
|
const banner = String(b.bannerUrl || '').trim();
|
||||||
|
if (banner && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(banner))
|
||||||
|
return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL.' });
|
||||||
|
const r = await accounts.setLineBanner(s.email, banner || null, target);
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
|
// -- welcome tour (gauntlet): meet the 3-level upline, then unlock welcome credits
|
||||||
|
if (p === '/api/my/gauntlet' && req.method === 'GET') {
|
||||||
|
const s = await auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
if (await ads.welcomeGranted(s.email)) return json(res, 200, { pending: false });
|
||||||
|
const slides = (await uplineSlides(s.email)).filter(a => a.lineTargetUrl)
|
||||||
|
.map((a, i) => ({ name: a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member',
|
||||||
|
bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl }));
|
||||||
|
if (!slides.length) return json(res, 200, { pending: false });
|
||||||
|
const token = crypto.randomBytes(16).toString('hex');
|
||||||
|
gauntletTokens.set(s.email, { token, ts: Date.now(), n: slides.length });
|
||||||
|
return json(res, 200, { pending: true, slides, dwell: 10, token });
|
||||||
|
}
|
||||||
|
if (p === '/api/my/gauntlet/complete' && req.method === 'POST') {
|
||||||
|
const s = await auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
const t = gauntletTokens.get(s.email);
|
||||||
|
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That tour is no longer open. Reload and try again.' });
|
||||||
|
if (Date.now() - t.ts < t.n * 10 * 1000 - 1500) return json(res, 400, { error: 'Give each site its ten seconds first.' });
|
||||||
|
gauntletTokens.delete(s.email);
|
||||||
|
await ads.grantWelcome(s.email);
|
||||||
|
return json(res, 200, { ok: true, credited: ads.rates().welcomeCredits || 0 });
|
||||||
|
}
|
||||||
|
// -- public banner wall
|
||||||
|
m = /^\/api\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p);
|
||||||
|
if (m && req.method === 'GET') {
|
||||||
|
const tok = m[1].toLowerCase();
|
||||||
|
let a = await accounts.byUsername(tok);
|
||||||
|
if (!a) a = await accounts.byCode(tok);
|
||||||
|
if (!a) return json(res, 404, { error: 'No wall under that name.' });
|
||||||
|
const ladder = [a, ...await uplineSlides(a.email, 2)].slice(0, 3)
|
||||||
|
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
|
||||||
|
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null }));
|
||||||
|
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
|
||||||
|
joinUrl: '/join/' + (a.username || a.code), ladder });
|
||||||
|
}
|
||||||
// -- onsite solo ads: member inbox with read rewards
|
// -- onsite solo ads: member inbox with read rewards
|
||||||
if (p === '/api/my/inbox' && req.method === 'GET') {
|
if (p === '/api/my/inbox' && req.method === 'GET') {
|
||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
@@ -587,6 +664,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
|
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
|
||||||
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
|
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
|
||||||
}
|
}
|
||||||
|
m = /^\/api\/my\/inbox\/(\d+)\/visit$/.exec(p);
|
||||||
|
if (m && req.method === 'POST') {
|
||||||
|
const s = await auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const r = await ads.markSoloVisit(s.email, m[1]);
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
|
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
|
||||||
if (m && req.method === 'POST') {
|
if (m && req.method === 'POST') {
|
||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
@@ -698,6 +782,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
|
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
|
||||||
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
|
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
|
||||||
if (/^\/tx\/0x[0-9a-fA-F]{64}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'tx.html'));
|
if (/^\/tx\/0x[0-9a-fA-F]{64}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'tx.html'));
|
||||||
|
if (/^\/wall\/[A-Za-z0-9_]{1,20}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'wall.html'));
|
||||||
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
|
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
|
||||||
const file = path.join(PUBLIC_DIR, safe);
|
const file = path.join(PUBLIC_DIR, safe);
|
||||||
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
|
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
|
||||||
|
|||||||
Reference in New Issue
Block a user