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:
+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.';
|
||||
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 ──
|
||||
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)' };
|
||||
@@ -129,7 +148,7 @@
|
||||
setInboxBadge(d.inboxUnread || 0);
|
||||
loadCharts(d);
|
||||
$('nextMove').textContent = nextMove(d);
|
||||
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
|
||||
renderSteps(d);
|
||||
const wrap = $('rosterWrap');
|
||||
if ((d.referrals || []).length) {
|
||||
$('rosterEmpty').hidden = true;
|
||||
@@ -167,6 +186,17 @@
|
||||
// ready-to-send share message + promo tools, personalized
|
||||
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
|
||||
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) {
|
||||
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. '
|
||||
@@ -197,6 +227,7 @@
|
||||
b.classList.toggle('on', b.dataset.pane === name));
|
||||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||||
if (name === 'inbox') loadInbox();
|
||||
if (name === 'profile') loadLineBanner();
|
||||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||||
}
|
||||
@@ -332,7 +363,6 @@
|
||||
$('cBudget').addEventListener('input', soloHint);
|
||||
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
|
||||
// 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 =>
|
||||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
|
||||
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
|
||||
@@ -343,32 +373,34 @@
|
||||
$('cSoloEd').focus();
|
||||
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 () => {
|
||||
const f = $('cSoloFile').files[0];
|
||||
if (!f) return;
|
||||
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
|
||||
try {
|
||||
const r = await (await fetch('/api/my/upload', { method: 'POST',
|
||||
headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
|
||||
soloMedia = r;
|
||||
$('edMediaInfo').textContent = f.name + ' attached';
|
||||
$('edMediaRemove').hidden = false;
|
||||
const pv = $('edMediaPrev');
|
||||
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">';
|
||||
insertHtmlAtCursor(r.type === 'video'
|
||||
? '<video src="' + r.url + '" controls playsinline></video><p><br></p>'
|
||||
: '<img src="' + r.url + '" alt=""><p><br></p>');
|
||||
$('edMediaInfo').textContent = f.name + ' inserted';
|
||||
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
||||
$('cSoloFile').value = '';
|
||||
});
|
||||
$('edMediaRemove').addEventListener('click', () => {
|
||||
soloMedia = null;
|
||||
$('edMediaInfo').textContent = '';
|
||||
$('edMediaRemove').hidden = true;
|
||||
$('edMediaPrev').hidden = true;
|
||||
$('edMediaPrev').innerHTML = '';
|
||||
// raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back
|
||||
$('edRawBtn').addEventListener('click', () => {
|
||||
const ed = $('cSoloEd'), raw = $('cSoloRaw');
|
||||
if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; }
|
||||
else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; }
|
||||
});
|
||||
$('cType').addEventListener('change', () => {
|
||||
const t = $('cType').value;
|
||||
@@ -381,17 +413,19 @@
|
||||
soloHint();
|
||||
});
|
||||
$('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,
|
||||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
|
||||
title: $('cTitle').value,
|
||||
body: $('cType').value === 'solo' ? $('cSoloEd').innerHTML : $('cBody').value,
|
||||
mediaUrl: $('cType').value === 'solo' && soloMedia ? soloMedia.url : '',
|
||||
body: $('cType').value === 'solo' ? soloBody : $('cBody').value,
|
||||
ctaLabel: $('cCtaLabel').value,
|
||||
budget: Number($('cBudget').value) });
|
||||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||||
$('cName').value = ''; $('cBudget').value = '';
|
||||
$('cSoloEd').innerHTML = ''; $('cCtaLabel').value = '';
|
||||
if ($('edMediaRemove') && !$('edMediaRemove').hidden) $('edMediaRemove').click();
|
||||
$('cSoloEd').innerHTML = ''; $('cSoloRaw').value = ''; $('cCtaLabel').value = '';
|
||||
$('edMediaInfo').textContent = '';
|
||||
await loadCampaigns();
|
||||
}));
|
||||
// defers the busy() lookup to click time (busy is declared below)
|
||||
@@ -446,29 +480,49 @@
|
||||
: r.mediaType === '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">';
|
||||
$('ibVisit').href = r.url;
|
||||
$('ibVisit').textContent = r.ctaLabel || 'Learn more';
|
||||
// the read reward needs BOTH the dwell AND an actual click-through to the
|
||||
// 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');
|
||||
clearInterval(ibTimer);
|
||||
if (r.rewarded) {
|
||||
btn.hidden = true;
|
||||
visit.classList.remove('cta-need');
|
||||
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
||||
return;
|
||||
}
|
||||
let dwellDone = false;
|
||||
let visited = !!r.visited;
|
||||
btn.hidden = false;
|
||||
btn.disabled = true;
|
||||
visit.classList.toggle('cta-need', !visited);
|
||||
let left = r.dwell;
|
||||
btn.textContent = 'Read it — claim in ' + left + 's';
|
||||
$('ibHint').textContent = 'Stay on this tab while you read; the claim unlocks when the timer is done.';
|
||||
const refresh = () => {
|
||||
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
|
||||
ibTimer = setInterval(() => {
|
||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||||
left -= 1;
|
||||
if (left > 0) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
|
||||
if (left > 0) { refresh(); return; }
|
||||
clearInterval(ibTimer);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Claim +' + r.reward + ' credits';
|
||||
dwellDone = true;
|
||||
refresh();
|
||||
}, 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 () => {
|
||||
try {
|
||||
const c = await api('/api/my/inbox/' + id + '/claim');
|
||||
@@ -644,7 +698,7 @@
|
||||
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
|
||||
await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value });
|
||||
IAP.status('You are in.', 'ok');
|
||||
await showLoginAd();
|
||||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||||
await render();
|
||||
}));
|
||||
})();
|
||||
@@ -657,7 +711,7 @@
|
||||
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
||||
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
||||
IAP.status('Logged in.', 'ok');
|
||||
await showLoginAd();
|
||||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||||
await render();
|
||||
}));
|
||||
$('walletSigninLink').addEventListener('click', async e => {
|
||||
@@ -666,7 +720,7 @@
|
||||
IAP.status('Check your wallet for the free sign-in signature…');
|
||||
await IAPWallet.signIn();
|
||||
IAP.status('Signed in with your wallet.', 'ok');
|
||||
await showLoginAd();
|
||||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||||
await render();
|
||||
} catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
|
||||
});
|
||||
@@ -695,6 +749,87 @@
|
||||
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
|
||||
// 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 —
|
||||
|
||||
Reference in New Issue
Block a user