Onsite solo ads: inbox delivery with read rewards, composer, homepage format live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+106
-6
@@ -126,6 +126,7 @@
|
||||
}
|
||||
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
|
||||
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
|
||||
setInboxBadge(d.inboxUnread || 0);
|
||||
loadCharts(d);
|
||||
$('nextMove').textContent = nextMove(d);
|
||||
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
|
||||
@@ -182,9 +183,10 @@
|
||||
}
|
||||
|
||||
// ── back-office menu: hash-routed panes ───────────────
|
||||
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'wallet', 'profile'];
|
||||
const PANES = ['overview', 'line', 'buy', 'campaigns', 'inbox', 'earn', 'earnings', 'promo', 'wallet', 'profile'];
|
||||
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
|
||||
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', wallet: 'Wallet & account', profile: 'Profile' };
|
||||
inbox: 'Inbox', earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools',
|
||||
wallet: 'Wallet & account', profile: 'Profile' };
|
||||
function setPane(name) {
|
||||
if (!PANES.includes(name)) name = 'overview';
|
||||
for (const p of PANES) {
|
||||
@@ -194,6 +196,7 @@
|
||||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||||
b.classList.toggle('on', b.dataset.pane === name));
|
||||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||||
if (name === 'inbox') loadInbox();
|
||||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||||
}
|
||||
@@ -288,11 +291,14 @@
|
||||
try {
|
||||
const r = await (await fetch('/api/my/campaigns')).json();
|
||||
if (r.error) return;
|
||||
lastRates = r.rates;
|
||||
soloHint();
|
||||
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
||||
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
|
||||
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
|
||||
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
|
||||
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day';
|
||||
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
|
||||
+ (r.rates.soloCostPerRecipient || 5) + 'cr/delivery';
|
||||
const el = $('campList');
|
||||
el.innerHTML = '';
|
||||
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
|
||||
@@ -314,16 +320,32 @@
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
let lastRates = null;
|
||||
function soloHint() {
|
||||
if (!lastRates || $('cType').value !== 'solo') return;
|
||||
const cost = lastRates.soloCostPerRecipient || 5;
|
||||
const n = Math.floor((Number($('cBudget').value) || 0) / cost);
|
||||
$('cSoloHint').textContent = cost + ' credits per guaranteed inbox delivery'
|
||||
+ (n ? ' — this budget reaches ' + n + ' members' : '')
|
||||
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
|
||||
}
|
||||
$('cBudget').addEventListener('input', soloHint);
|
||||
$('cType').addEventListener('change', () => {
|
||||
const t = $('cType').value;
|
||||
$('cImageRow').hidden = t === 'text';
|
||||
$('cTitleRow').hidden = t !== 'text';
|
||||
$('cImageRow').hidden = t === 'text' || t === 'solo';
|
||||
$('cTitleRow').hidden = t !== 'text' && t !== 'solo';
|
||||
$('cBodyRow').hidden = t !== 'text';
|
||||
$('cSoloRow').hidden = t !== 'solo';
|
||||
$('cSoloHint').hidden = t !== 'solo';
|
||||
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
|
||||
soloHint();
|
||||
});
|
||||
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||||
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
|
||||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
|
||||
title: $('cTitle').value, body: $('cBody').value, budget: Number($('cBudget').value) });
|
||||
title: $('cTitle').value,
|
||||
body: $('cType').value === 'solo' ? $('cSoloBody').value : $('cBody').value,
|
||||
budget: Number($('cBudget').value) });
|
||||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||||
$('cName').value = ''; $('cBudget').value = '';
|
||||
await loadCampaigns();
|
||||
@@ -331,6 +353,84 @@
|
||||
// defers the busy() lookup to click time (busy is declared below)
|
||||
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
|
||||
|
||||
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
|
||||
let ibTimer = null;
|
||||
function setInboxBadge(n) {
|
||||
const b = $('inboxBadge');
|
||||
if (b) { b.hidden = !n; b.textContent = n; }
|
||||
}
|
||||
async function loadInbox() {
|
||||
try {
|
||||
const r = await (await fetch('/api/my/inbox')).json();
|
||||
if (r.error) return;
|
||||
$('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to '
|
||||
+ r.readCap + ' rewarded reads a day)';
|
||||
const el = $('ibList');
|
||||
$('inboxReadCard').hidden = true;
|
||||
$('inboxListCard').hidden = false;
|
||||
setInboxBadge(r.unread);
|
||||
if (!r.items.length) {
|
||||
el.innerHTML = '<p class="muted small">No solo ads yet. When a member sends one, it lands here — and reading it pays.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
for (const i of r.items) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'ib-row' + (i.read ? '' : ' unread');
|
||||
d.innerHTML = '<span class="sub"></span><span class="from"></span>'
|
||||
+ (i.rewarded ? '<span class="badge">claimed</span>' : i.read ? '' : '<span class="badge amber">new</span>')
|
||||
+ '<span class="when">' + new Date(i.delivered).toLocaleDateString() + '</span>';
|
||||
d.querySelector('.sub').textContent = i.subject || '(no subject)';
|
||||
d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member');
|
||||
d.addEventListener('click', () => openInboxItem(i.id));
|
||||
el.appendChild(d);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
async function openInboxItem(id) {
|
||||
try {
|
||||
const r = await (await fetch('/api/my/inbox/' + id)).json();
|
||||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||||
$('inboxListCard').hidden = true;
|
||||
$('inboxReadCard').hidden = false;
|
||||
$('ibSubject').textContent = r.subject || '(no subject)';
|
||||
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
|
||||
$('ibBody').textContent = r.body || '';
|
||||
$('ibVisit').href = r.url;
|
||||
const btn = $('ibClaimBtn');
|
||||
clearInterval(ibTimer);
|
||||
if (r.rewarded) {
|
||||
btn.hidden = true;
|
||||
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
||||
return;
|
||||
}
|
||||
btn.hidden = false;
|
||||
btn.disabled = true;
|
||||
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.';
|
||||
// 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; }
|
||||
clearInterval(ibTimer);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Claim +' + r.reward + ' credits';
|
||||
}, 1000);
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
const c = await api('/api/my/inbox/' + id + '/claim');
|
||||
IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok');
|
||||
btn.hidden = true;
|
||||
$('ibHint').textContent = 'Claimed. Head back for the next one.';
|
||||
loadDashboard();
|
||||
} catch (e2) { IAP.status(e2.message, 'bad'); }
|
||||
};
|
||||
} catch (e) {}
|
||||
}
|
||||
$('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); });
|
||||
|
||||
// ── promo tools: Branded Voice copy, personalized with the member link ──
|
||||
const PROMO_POSTS = [
|
||||
'A membership site where money is handled by code, not people. Every purchase splits instantly to sponsor wallets on the Polygon blockchain. Nothing to withdraw. The money just lands in your wallet. Plus you earn ad credits for viewing ads while you\'re there. {{LINK}}',
|
||||
|
||||
+12
-2
@@ -217,10 +217,11 @@ footer{border-top:1px solid var(--line);margin-top:90px;padding:34px 0 0;color:v
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55)}
|
||||
#status.ok{border-color:var(--mint)}
|
||||
#status.bad{border-color:var(--bad)}
|
||||
input,select{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px;
|
||||
input,select,textarea{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px;
|
||||
padding:11px 14px;font-size:14.5px;font-family:inherit}
|
||||
input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle}
|
||||
input:focus,select:focus{border-color:var(--mint)}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--mint)}
|
||||
textarea{resize:vertical;font:inherit}
|
||||
:focus-visible{outline:2px solid var(--mint);outline-offset:2px}
|
||||
.hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px}
|
||||
/* ── member back-office shell ─────────────────────────── */
|
||||
@@ -294,6 +295,15 @@ input:focus,select:focus{border-color:var(--mint)}
|
||||
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
|
||||
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
|
||||
.donut-center{font-family:var(--disp);font-weight:800}
|
||||
/* ── solo-ads inbox ── */
|
||||
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
|
||||
border-radius:999px;padding:1px 8px;line-height:1.5}
|
||||
.ib-row{display:flex;gap:10px;align-items:baseline;padding:11px 6px;border-bottom:1px solid var(--line);
|
||||
cursor:pointer;flex-wrap:wrap}
|
||||
.ib-row:hover{background:rgba(67,232,195,.05)}
|
||||
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
|
||||
.ib-row.unread .sub{color:var(--mint)}
|
||||
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
|
||||
/* ── 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)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
|
||||
|
||||
+4
-2
@@ -225,8 +225,10 @@
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M3 7l9 6 9-6"/><rect x="3" y="5" width="18" height="14" rx="2"/></svg></div>
|
||||
<h3>Inbox ads <span class="badge amber">soon</span></h3>
|
||||
<p>Delivered on-site, where members earn credits just for reading them.</p>
|
||||
<h3>Solo ads</h3>
|
||||
<p>Your full message, subject line to signature, delivered straight into member inboxes on-site.
|
||||
Priced per guaranteed delivery, and readers earn credits for a real read — so your message
|
||||
gets opened, not skimmed past.</p>
|
||||
</div>
|
||||
<div class="platecard">
|
||||
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
|
||||
|
||||
+28
-5
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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="/assets/site.css?v=20260905l">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905n">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<button data-pane="line" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>My line</button>
|
||||
<button data-pane="buy" type="button"><svg viewBox="0 0 24 24"><circle cx="9" cy="20" r="1.6"/><circle cx="17" cy="20" r="1.6"/><path d="M3 4h2l2.4 11.2A2 2 0 0 0 9.4 17h8.2a2 2 0 0 0 2-1.6L21 8H6"/></svg>Buy packages</button>
|
||||
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>Campaigns</button>
|
||||
<button data-pane="inbox" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 8l9 6 9-6"/></svg>Inbox<span class="pill" id="inboxBadge" hidden></span></button>
|
||||
<button data-pane="earn" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v9M9 10c0-1.1 1.3-1.8 3-1.8s3 .7 3 1.8-1.3 1.6-3 1.8-3 .7-3 1.8 1.3 1.8 3 1.8 3-.7 3-1.8"/></svg>Earn credits</button>
|
||||
<button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button>
|
||||
<button data-pane="promo" type="button"><svg viewBox="0 0 24 24"><path d="M7 10s5-1 9-5v14c-4-4-9-5-9-5H5a2 2 0 0 1-2-2v0a2 2 0 0 1 2-2h2z"/><path d="M8 15l1 5h2l-1-5"/></svg>Promo tools</button>
|
||||
@@ -210,6 +211,7 @@
|
||||
<option value="banner">Banner (per impression)</option>
|
||||
<option value="text">Text ad (per impression)</option>
|
||||
<option value="login">Login ad (per day)</option>
|
||||
<option value="solo">Solo ad (inbox delivery)</option>
|
||||
</select></p>
|
||||
<p><input id="cName" placeholder="Campaign name" style="width:100%"></p>
|
||||
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
|
||||
@@ -220,10 +222,31 @@
|
||||
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" style="width:100%"></p>
|
||||
<p id="cTitleRow" hidden><input id="cTitle" placeholder="Headline (max 60)" style="width:100%"></p>
|
||||
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||
<p id="cSoloRow" hidden><textarea id="cSoloBody" placeholder="Your message (40–1000 characters). Write it like an email worth reading." rows="7" style="width:100%"></textarea></p>
|
||||
<p class="small muted" id="cSoloHint" hidden></p>
|
||||
<button class="btn" id="createCampBtn">Launch campaign</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-inbox" hidden>
|
||||
<div class="card" id="inboxListCard">
|
||||
<h3>Solo ads inbox</h3>
|
||||
<p class="muted small">Full-message ads from members land here — each one delivered to you at
|
||||
most once, never your own. Give one a real read and claim <span id="ibRewardNote">…</span>.</p>
|
||||
<div id="ibList"><p class="muted small">Loading…</p></div>
|
||||
</div>
|
||||
<div class="card" id="inboxReadCard" hidden>
|
||||
<p><a href="#" id="ibBack">← Back to inbox</a></p>
|
||||
<h3 id="ibSubject"></h3>
|
||||
<p class="small muted" id="ibMeta"></p>
|
||||
<div id="ibBody" class="promo-block" style="white-space:pre-wrap"></div>
|
||||
<p style="margin-top:14px">
|
||||
<a class="btn" id="ibVisit" target="_blank" rel="noopener nofollow">Visit the advertiser</a>
|
||||
<button class="btn sec" id="ibClaimBtn" type="button" hidden>…</button></p>
|
||||
<p class="small muted" id="ibHint"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-earn" hidden>
|
||||
<div class="card">
|
||||
<h3>Earn credits by viewing ads</h3>
|
||||
@@ -332,9 +355,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260905l"></script>
|
||||
<script src="/assets/wallet.js?v=20260905l"></script>
|
||||
<script src="/assets/my.js?v=20260905l"></script>
|
||||
<script src="/assets/chat.js?v=20260905l"></script>
|
||||
<script src="/assets/common.js?v=20260905n"></script>
|
||||
<script src="/assets/wallet.js?v=20260905n"></script>
|
||||
<script src="/assets/my.js?v=20260905n"></script>
|
||||
<script src="/assets/chat.js?v=20260905n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Viewing ad — InstantAdPay</title>
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905l">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905n">
|
||||
<style>
|
||||
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)}
|
||||
@@ -43,6 +43,6 @@
|
||||
</div>
|
||||
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
||||
</div>
|
||||
<script src="/assets/view.js?v=20260905l"></script>
|
||||
<script src="/assets/view.js?v=20260905n"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user