Balances: one reservation rule (earned first) + purchased-grade credited pool
- balances(): live campaign budgets are set aside up front, earned pool first then purchased, so dashboard numbers never move per impression; new 'in campaigns' line - earned pool gains a purchase_grade portion for refunds/comps: shows under Purchased as 'credited to you' and funds login ads (daily login charge draws on it first) - viewing credits always spend before credited purchased money - dashboard/donut/spend banner/earn pane/positions show purchased, credited, earned, in campaigns - chatbot canned answer + AI prompt: the balance rule Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ const CANNED = [
|
|||||||
a: 'There are no withdrawals here, ever. The contract pays your share straight to your own wallet in the same transaction as the purchase. Nothing to request, nothing to wait on.' },
|
a: 'There are no withdrawals here, ever. The contract pays your share straight to your own wallet in the same transaction as the purchase. Nothing to request, nothing to wait on.' },
|
||||||
{ re: /(how (much|do i) earn|commission|percent|split)/i,
|
{ re: /(how (much|do i) earn|commission|percent|split)/i,
|
||||||
a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' },
|
a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' },
|
||||||
|
{ re: /(balance (went|going|keeps going) down|credits? (disappear|missing|gone|dropping|went down|ticking)|why (is|did) my (balance|credits)|in campaigns|set aside|reserved credits|available to spend)/i,
|
||||||
|
a: 'One rule: your balance is what is NOT committed to a live campaign. When you start a campaign its whole budget is set aside right then (earned credits first, then purchased), so your Available number drops once and then sits still while the ad runs. The budget spends down inside that campaign in Members > Campaigns, where you can see delivered views, network views and what is left. The dashboard shows Purchased available, Earned available and In live campaigns as three separate numbers; a paused campaign keeps its unspent budget set aside so it can resume. Nothing is lost when a balance reads low while a campaign is live. Purchased credits live on the blockchain; if we ever refund or credit purchased money it shows under Purchased as "credited to you" and spends like a purchase, login ads included. Earned credits fund every format except login ads.' },
|
||||||
{ re: /(qualif|unlock level|level 2|level 3|pass.?up)/i,
|
{ re: /(qualif|unlock level|level 2|level 3|pass.?up)/i,
|
||||||
a: 'Level 1 is open to every member. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' },
|
a: 'Level 1 is open to every member. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' },
|
||||||
{ re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i,
|
{ re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i,
|
||||||
@@ -68,6 +70,7 @@ FACTS:
|
|||||||
- 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.
|
- 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), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). 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), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs.
|
||||||
|
- BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads.
|
||||||
- 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.
|
||||||
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
|
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
|
||||||
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
|
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async function bootstrap() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
const alterSafe0 = async sql => { try { await q(sql); } catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; } };
|
const alterSafe0 = async sql => { try { await q(sql); } catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; } };
|
||||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN milestones VARCHAR(255) NULL'); // comma-joined granted milestone keys
|
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN milestones VARCHAR(255) NULL'); // comma-joined granted milestone keys
|
||||||
|
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN purchase_grade INT NOT NULL DEFAULT 0'); // part of the pool that is refunded/credited PURCHASED money: shows as purchased, funds login ads
|
||||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_day CHAR(10) NULL'); // last daily-login-bonus day
|
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_day CHAR(10) NULL'); // last daily-login-bonus day
|
||||||
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_streak INT NOT NULL DEFAULT 0'); // consecutive-day streak
|
await alterSafe0('ALTER TABLE earned_credits ADD COLUMN login_streak INT NOT NULL DEFAULT 0'); // consecutive-day streak
|
||||||
// additive columns (MySQL 8 has no IF NOT EXISTS for columns)
|
// additive columns (MySQL 8 has no IF NOT EXISTS for columns)
|
||||||
|
|||||||
+20
-13
@@ -192,16 +192,19 @@
|
|||||||
try {
|
try {
|
||||||
const st = await (await fetch('/api/my/earn')).json();
|
const st = await (await fetch('/api/my/earn')).json();
|
||||||
if (!st.error) {
|
if (!st.error) {
|
||||||
const purchased = d.credits || 0, earned = st.earned || 0, wc = Math.min(d.welcomeCredits || 0, earned);
|
// one rule: a balance is what is NOT committed to a live campaign. Budgets are
|
||||||
|
// set aside when a campaign starts and spend down inside Campaigns, so these
|
||||||
|
// numbers only move when a campaign is created, topped up or paused
|
||||||
|
const purchased = d.credits || 0, earned = d.earnedCredits != null ? d.earnedCredits : (st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0)), inC = d.inCampaigns || 0;
|
||||||
$('dbCredits').textContent = (purchased + earned).toLocaleString();
|
$('dbCredits').textContent = (purchased + earned).toLocaleString();
|
||||||
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned';
|
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earned.toLocaleString() + ' earned' + (inC ? ' · ' + inC.toLocaleString() + ' in campaigns' : '');
|
||||||
donut($('chDonut'),
|
donut($('chDonut'),
|
||||||
[{ v: purchased, color: CH.mint }, { v: wc, color: CH.amber }, { v: earned - wc, color: CH.cyan }],
|
[{ v: purchased, color: CH.mint }, { v: earned, color: CH.cyan }, { v: inC, color: CH.amber }],
|
||||||
{ big: (purchased + earned).toLocaleString(), small: 'spendable' });
|
{ big: (purchased + earned).toLocaleString(), small: 'available' });
|
||||||
legend($('chDonutLegend'), [
|
legend($('chDonutLegend'), [
|
||||||
{ color: CH.mint, label: 'Purchased', v: purchased.toLocaleString() },
|
{ color: CH.mint, label: 'Purchased, available', v: purchased.toLocaleString() },
|
||||||
{ color: CH.amber, label: 'Welcome', v: wc.toLocaleString() },
|
{ color: CH.cyan, label: 'Earned, available', v: earned.toLocaleString() },
|
||||||
{ color: CH.cyan, label: 'Earned and credited', v: Math.max(0, earned - wc).toLocaleString() }]);
|
{ color: CH.amber, label: 'In live campaigns', v: inC.toLocaleString() }]);
|
||||||
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
|
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
|
||||||
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
|
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
|
||||||
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
|
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
|
||||||
@@ -357,9 +360,9 @@
|
|||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
const wc = d.welcomeCredits || 0;
|
const earnedAv = d.earnedCredits || 0, inCamp = d.inCampaigns || 0;
|
||||||
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
|
$('dbCredits').textContent = ((d.credits || 0) + earnedAv).toLocaleString();
|
||||||
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
|
$('dbCreditsSub').textContent = (d.credits || 0).toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earnedAv.toLocaleString() + ' earned' + (inCamp ? ' · ' + inCamp.toLocaleString() + ' in campaigns' : '');
|
||||||
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
|
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
|
||||||
$('dbBuyers').textContent = d.buyerCount || 0;
|
$('dbBuyers').textContent = d.buyerCount || 0;
|
||||||
$('dbTeam').textContent = (d.referrals || []).length;
|
$('dbTeam').textContent = (d.referrals || []).length;
|
||||||
@@ -591,11 +594,15 @@
|
|||||||
if ($('spendBanner')) {
|
if ($('spendBanner')) {
|
||||||
$('spendBanner').hidden = false;
|
$('spendBanner').hidden = false;
|
||||||
$('spendBig').textContent = r.availableCredits.toLocaleString();
|
$('spendBig').textContent = r.availableCredits.toLocaleString();
|
||||||
$('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery' + (r.earnedCredits ? ' · ' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits.toLocaleString() + ' earned by viewing' : '') + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : '');
|
$('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery · ' + r.purchasedCredits.toLocaleString() + ' purchased' + (r.creditedCredits ? ' (' + r.creditedCredits.toLocaleString() + ' of it credited to you, spends on anything)' : '') + ' + ' + (r.earnedCredits || 0).toLocaleString() + ' earned' + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : '');
|
||||||
|
if ($('spendNote')) $('spendNote').textContent = r.inCampaigns
|
||||||
|
? 'Not counting ' + r.inCampaigns.toLocaleString() + ' credits already set aside for your live campaigns. That budget spends down inside each campaign below. This number only moves when you start, top up or pause a campaign.'
|
||||||
|
: 'This is what is not committed to a campaign. When you start one, its budget moves out of here and spends down inside the campaign.';
|
||||||
$('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '<span><b>' + x[0] + '</b> ' + x[1] + '</span>').join('');
|
$('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '<span><b>' + x[0] + '</b> ' + x[1] + '</span>').join('');
|
||||||
}
|
}
|
||||||
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
||||||
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
|
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
|
||||||
|
+ (r.inCampaigns ? ' · ' + r.inCampaigns.toLocaleString() + ' set aside in live campaigns' : '')
|
||||||
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
|
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
|
||||||
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
|
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
|
||||||
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
|
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
|
||||||
@@ -1361,7 +1368,7 @@
|
|||||||
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
|
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
|
||||||
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
|
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
|
||||||
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span></div>' : '';
|
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span></div>' : '';
|
||||||
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>. A campaign budget spends from one position at a time.</p>' : '');
|
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>' + (r.credited ? ' plus <b>' + r.credited.toLocaleString() + '</b> credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.</p>' : '');
|
||||||
if ($('qsList')) $('qsList').innerHTML = html;
|
if ($('qsList')) $('qsList').innerHTML = html;
|
||||||
// Wallet tab: live POL balance of the linked wallet, and which package it covers
|
// Wallet tab: live POL balance of the linked wallet, and which package it covers
|
||||||
const wb = $('walletBal');
|
const wb = $('walletBal');
|
||||||
@@ -1500,7 +1507,7 @@
|
|||||||
const st = await (await fetch('/api/my/earn')).json();
|
const st = await (await fetch('/api/my/earn')).json();
|
||||||
if (st.error) return null;
|
if (st.error) return null;
|
||||||
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
|
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
|
||||||
$('earnBalance').textContent = st.earned + ' earned credits';
|
$('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : '');
|
||||||
const done = st.views >= st.target;
|
const done = st.views >= st.target;
|
||||||
$('earnClaimBtn').hidden = !(done && !st.claimed);
|
$('earnClaimBtn').hidden = !(done && !st.claimed);
|
||||||
if (st.claimed) {
|
if (st.claimed) {
|
||||||
|
|||||||
@@ -399,6 +399,7 @@
|
|||||||
<div class="lbl">Available to spend</div>
|
<div class="lbl">Available to spend</div>
|
||||||
<div class="big"><span id="spendBig">0</span> <span class="unit">credits</span></div>
|
<div class="big"><span id="spendBig">0</span> <span class="unit">credits</span></div>
|
||||||
<div class="sub" id="spendSub"></div>
|
<div class="sub" id="spendSub"></div>
|
||||||
|
<div class="sub small" id="spendNote"></div>
|
||||||
<div class="rates" id="spendRates"></div>
|
<div class="rates" id="spendRates"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="whereCard">
|
<div class="card" id="whereCard">
|
||||||
|
|||||||
@@ -782,7 +782,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const mm = await chain.member(memberId);
|
const mm = await chain.member(memberId);
|
||||||
out.buyerCount = mm.buyerCount;
|
out.buyerCount = mm.buyerCount;
|
||||||
out.onchainSponsorId = mm.sponsorId;
|
out.onchainSponsorId = mm.sponsorId;
|
||||||
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
|
const bal = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||||||
|
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
|
||||||
} catch (e) { out.chainReadError = true; }
|
} catch (e) { out.chainReadError = true; }
|
||||||
}
|
}
|
||||||
return json(res, 200, out);
|
return json(res, 200, out);
|
||||||
@@ -831,7 +832,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const mm = await chain.member(memberId);
|
const mm = await chain.member(memberId);
|
||||||
out.buyerCount = mm.buyerCount;
|
out.buyerCount = mm.buyerCount;
|
||||||
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
|
const bal = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||||||
|
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
|
||||||
} catch (e) { out.chainReadError = true; }
|
} catch (e) { out.chainReadError = true; }
|
||||||
let earned = 0n, n = 0;
|
let earned = 0n, n = 0;
|
||||||
for (const ev of chain.recentEvents(600)) {
|
for (const ev of chain.recentEvents(600)) {
|
||||||
@@ -878,19 +880,21 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const mainId = await auth.refreshMemberId(s);
|
const mainId = await auth.refreshMemberId(s);
|
||||||
const list = await accounts.positions(s.email);
|
const list = await accounts.positions(s.email);
|
||||||
const out = [];
|
const out = [];
|
||||||
|
const posIds = [mainId, ...list.map(p => p.memberId)].filter(Boolean);
|
||||||
|
let balPer = {}, credited = 0; try { const bb = await ads.balances(posIds, s.email); credited = bb.credited; for (const p of bb.per) balPer[p.memberId] = p.avail; } catch (e) {}
|
||||||
for (const pos of list) {
|
for (const pos of list) {
|
||||||
let id = pos.memberId;
|
let id = pos.memberId;
|
||||||
if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} }
|
if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} }
|
||||||
const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created };
|
const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created };
|
||||||
if (id) {
|
if (id) {
|
||||||
try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {}
|
try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {}
|
||||||
try { row.credits = await ads.availableCredits(id); } catch (e) {}
|
row.credits = balPer[id] != null ? balPer[id] : 0;
|
||||||
}
|
}
|
||||||
out.push(row);
|
out.push(row);
|
||||||
}
|
}
|
||||||
const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 };
|
const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 };
|
||||||
if (mainId) {
|
if (mainId) {
|
||||||
try { main.credits = await ads.availableCredits(mainId); } catch (e) {}
|
main.credits = balPer[mainId] != null ? balPer[mainId] : 0;
|
||||||
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
|
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
|
||||||
}
|
}
|
||||||
// live POL balances (the link signature proved the wallet is theirs) + a POL/USD rate from the catalog
|
// live POL balances (the link signature proved the wallet is theirs) + a POL/USD rate from the catalog
|
||||||
@@ -898,7 +902,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (main.address) main.balanceWei = await bal(main.address);
|
if (main.address) main.balanceWei = await bal(main.address);
|
||||||
for (const row of out) row.balanceWei = await bal(row.address);
|
for (const row of out) row.balanceWei = await bal(row.address);
|
||||||
let polUsd = 0; try { const cat = await chain.catalog(); const pk = cat.find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
|
let polUsd = 0; try { const cat = await chain.catalog(); const pk = cat.find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
|
||||||
return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) });
|
return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0), credited });
|
||||||
}
|
}
|
||||||
if (p === '/api/my/positions/remove' && req.method === 'POST') {
|
if (p === '/api/my/positions/remove' && req.method === 'POST') {
|
||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
@@ -1554,12 +1558,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const memberId = await auth.refreshMemberId(s);
|
const memberId = await auth.refreshMemberId(s);
|
||||||
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
|
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
|
||||||
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
|
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
|
||||||
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
|
const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||||||
out.purchasedCredits = pool.total;
|
out.purchasedCredits = pool.total;
|
||||||
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
|
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
|
||||||
out.positionCount = pool.per.length;
|
out.positionCount = pool.per.length;
|
||||||
out.earnedCredits = await ads.earnedBalance(s.email);
|
out.creditedCredits = pool.credited; // refunded/credited purchased money, free (counts inside purchasedCredits)
|
||||||
out.availableCredits = out.purchasedCredits + out.earnedCredits;
|
out.earnedCredits = pool.earned; // earned pool minus what live campaigns already hold
|
||||||
|
out.earnedReserved = pool.earnedReserved;
|
||||||
|
out.inCampaigns = pool.inCampaigns; // budget still to deliver across live campaigns
|
||||||
|
out.availableCredits = pool.available;
|
||||||
return json(res, 200, out);
|
return json(res, 200, out);
|
||||||
}
|
}
|
||||||
if (p === '/api/my/campaigns' && req.method === 'POST') {
|
if (p === '/api/my/campaigns' && req.method === 'POST') {
|
||||||
@@ -1573,13 +1580,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
// charge the best-funded of the member's positions (main + Qualified Start
|
// charge the best-funded of the member's positions (main + Qualified Start
|
||||||
// wallets). A campaign burns from one member id, so the budget must fit inside it.
|
// wallets). A campaign burns from one member id, so the budget must fit inside it.
|
||||||
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
|
const ids = (await myMemberIds(s)).ids;
|
||||||
|
const pool = await ads.balances(ids, s.email);
|
||||||
const fundId = pool.best.memberId || memberId;
|
const fundId = pool.best.memberId || memberId;
|
||||||
const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0;
|
const earnedNow = String(b.type) !== 'login' ? pool.earned : 0;
|
||||||
const budget = Math.floor(Number(b.budget) || 0);
|
const budget = Math.floor(Number(b.budget) || 0);
|
||||||
if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow)
|
if (pool.per.length > 1 && budget > pool.best.avail + pool.credited + earnedNow && budget <= pool.total + earnedNow)
|
||||||
return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' });
|
return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' });
|
||||||
const r = await ads.createCampaign(s.email, fundId, b);
|
const r = await ads.createCampaign(s.email, fundId, b, ids);
|
||||||
return json(res, r.error ? 400 : 200, r);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);
|
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);
|
||||||
|
|||||||
Reference in New Issue
Block a user