Campaign scheduling (start/end, solo send-from), scheduled NAS push + end sweep, views-by-hour chart

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-11 06:53:53 -05:00
parent 159e19dcdf
commit bf5371ab20
7 changed files with 49 additions and 8 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -77,6 +77,7 @@ FACTS:
- 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.
- WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase. - WALLETS + BUYING POL (Training > Wallets and buying POL, /wallets, members only): preferred MetaMask (recommended; extra accounts for Qualified Start), Phantom, SafePal, Coinbase Wallet; Trust works but blocks buys spending most of its POL (keep ~2x). MoonPay flow: connect wallet, Buy packages > "Buy POL with a card" opens MoonPay with POL on Polygon + the member's address prefilled; card/Apple Pay/Google Pay; first-time ID check; minimum order ~$30; buy package cost + 2-3 POL for fees; POL arrives in minutes; then buy. Exchanges: withdraw POL on the Polygon network. Never MATIC on Ethereum, never share the recovery phrase.
- SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days).
- 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. - 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.
+5 -1
View File
@@ -119,7 +119,11 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day) await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start
await q(`CREATE TABLE IF NOT EXISTS camp_hours (
campaign_id INT NOT NULL, day CHAR(10) NOT NULL, hour TINYINT NOT NULL, n INT NOT NULL DEFAULT 0,
PRIMARY KEY (campaign_id, day, hour)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site views per UTC hour, for the by-hour chart
await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged
await alterSafe('ALTER TABLE campaigns ADD COLUMN daily_cap INT NULL'); // optional credits/day pacing (banner + text) await alterSafe('ALTER TABLE campaigns ADD COLUMN daily_cap INT NULL'); // optional credits/day pacing (banner + text)
await alterSafe('ALTER TABLE campaigns ADD COLUMN day_spent INT NOT NULL DEFAULT 0'); await alterSafe('ALTER TABLE campaigns ADD COLUMN day_spent INT NOT NULL DEFAULT 0');
+29 -5
View File
@@ -614,13 +614,13 @@
tbl.className = 'tablewrap'; tbl.className = 'tablewrap';
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views here</th><th class="num" title="Impressions delivered by Network Ad Space across the wider network (banner and text ads only)">Network views</th><th class="num">Clicks</th>' tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views here</th><th class="num" title="Impressions delivered by Network Ad Space across the wider network (banner and text ads only)">Network views</th><th class="num">Clicks</th>'
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>' + '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td>' + r.campaigns.map(c => '<tr><td><b>' + c.name + '</b>' + hourBars(r.hours && r.hours[c.id]) + '</td>'
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + '</td>' + '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + schedChips(c) + '</td>'
+ '<td class="num">' + c.imps.toLocaleString() + '</td>' + '<td class="num">' + c.imps.toLocaleString() + '</td>'
+ '<td class="num">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>' + '<td class="num">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>'
+ '<td class="num">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small" style="white-space:nowrap" title="where the clicks happened">' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => esc(k) + ' ' + v).join(' · ') + (c.impsNas ? ' · network: see Network views' : '') + '</div>' : '') + '</td>' + '<td class="num">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small" style="white-space:nowrap" title="where the clicks happened">' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => esc(k) + ' ' + v).join(' · ') + (c.impsNas ? ' · network: see Network views' : '') + '</div>' : '') + '</td>'
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>' + '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>' + '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status === 'done' ? '<span class="muted">ended</span>' : c.scheduled ? '<span class="badge">scheduled</span>' : c.status) + '</td>'
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>' + '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '') : c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
+ ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>' + ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>'
@@ -712,6 +712,8 @@
$('cVideoRow').hidden = t !== 'video'; $('cVideoRow').hidden = t !== 'video';
$('cFeaturedRow').hidden = t !== 'featured'; $('cFeaturedRow').hidden = t !== 'featured';
$('cVisitsRow').hidden = t !== 'visits'; $('cVisitsRow').hidden = t !== 'visits';
if ($('cSchedRow')) { $('cSchedRow').hidden = t === 'featured'; $('cStartLbl').textContent = t === 'solo' ? 'Send from (optional)' : 'Start (optional)';
$('cSchedHint').textContent = t === 'solo' ? 'Deliveries to member inboxes begin at the time you pick, so you can land when people are reading. Leave empty to start now.' : 'Leave both empty to start now and run until the budget is spent. Times are your local time. Anything left when a campaign ends goes back to Available.'; }
// fixed-cost types derive their spend (featured = day slots, visits = flat pack), // fixed-cost types derive their spend (featured = day slots, visits = flat pack),
// so hide the free-form Budget field for them to avoid confusion // so hide the free-form Budget field for them to avoid confusion
$('cBudgetRow').hidden = (t === 'featured' || t === 'visits'); $('cBudgetRow').hidden = (t === 'featured' || t === 'visits');
@@ -810,12 +812,15 @@
days: Number($('cFeatDays').value), startDay: featStartDay, days: Number($('cFeatDays').value), startDay: featStartDay,
count: Number($('cVisitCount').value), count: Number($('cVisitCount').value),
dailyCap: ($('cDailyCap') && (t === 'banner' || t === 'text')) ? Number($('cDailyCap').value) || 0 : 0, dailyCap: ($('cDailyCap') && (t === 'banner' || t === 'text')) ? Number($('cDailyCap').value) || 0 : 0,
startsAt: ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value).getTime() : 0,
endsAt: ($('cEndAt') && $('cEndAt').value && !isFeat) ? new Date($('cEndAt').value).getTime() : 0,
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value, ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : t === 'visits' ? (Number($('cVisitCount').value) * (lastRates.visitCostPerVisit || 3)) : Number($('cBudget').value) }); budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : t === 'visits' ? (Number($('cVisitCount').value) * (lastRates.visitCostPerVisit || 3)) : Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok'); const schedStart = ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value) : null;
IAP.status(schedStart && schedStart.getTime() > Date.now() ? 'Campaign saved. It starts serving ' + schedStart.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + '.' : 'Campaign is live. It starts serving right away.', 'ok');
// clear EVERY field so no target/creative carries into the next campaign // clear EVERY field so no target/creative carries into the next campaign
['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel', ['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel',
'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle', 'cDailyCap'] 'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle', 'cDailyCap', 'cStartAt', 'cEndAt']
.forEach(id => { if ($(id)) $(id).value = ''; }); .forEach(id => { if ($(id)) $(id).value = ''; });
$('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = ''; $('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = '';
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = ''; $('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
@@ -1016,6 +1021,25 @@
})); }));
} catch (e) {} } catch (e) {}
} }
// schedule chips on the campaign table (local time) + by-hour view bars
const fmtWhen = ms => new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
function schedChips(c) {
const now = Date.now(); let s = '';
if (c.starts && c.starts > now) s += ' <span class="muted small">starts ' + fmtWhen(c.starts) + '</span>';
if (c.expires && c.type !== 'featured') s += ' <span class="muted small">' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + '</span>';
return s;
}
function hourBars(utc) {
if (!utc || !utc.some(n => n)) return '';
// rotate the 24 UTC buckets into the viewer's local hours
const local = new Array(24).fill(0);
for (let h = 0; h < 24; h++) local[new Date(Date.UTC(2000, 0, 1, h)).getHours()] += utc[h];
const max = Math.max(...local);
const lab = h => (h % 12 || 12) + (h < 12 ? 'am' : 'pm');
return '<div class="hbars" title="on-site views by hour of day, your local time, last 7 days">' + local.map((n, h) =>
'<i style="height:' + Math.max(2, Math.round(n / max * 18)) + 'px" title="' + lab(h) + ': ' + n + '"></i>').join('')
+ '</div><div class="muted small" style="font-size:10px">views by hour · last 7 days · your time</div>';
}
// ── link stats: views, joins, buyers per angle ── // ── link stats: views, joins, buyers per angle ──
async function loadLinkStats() { async function loadLinkStats() {
try { try {
+3
View File
@@ -629,6 +629,9 @@ img{max-width:100%}
.promo-strip{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 20px} .promo-strip{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 20px}
.angle-list{display:flex;flex-direction:column;gap:8px} .angle-list{display:flex;flex-direction:column;gap:8px}
.hp-field{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden} .hp-field{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden}
.hbars{display:flex;align-items:flex-end;gap:2px;height:20px;margin-top:6px;width:120px}
.hbars i{flex:1 1 0;background:var(--mint);opacity:.75;border-radius:1px 1px 0 0;min-width:2px}
.hbars i:hover{opacity:1}
.icon-check{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px} .icon-check{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
.icon-check .ic-btn{font-size:26px;line-height:1;padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--panel);cursor:pointer} .icon-check .ic-btn{font-size:26px;line-height:1;padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--panel);cursor:pointer}
.icon-check .ic-btn:hover{border-color:var(--mint)} .icon-check .ic-btn:hover{border-color:var(--mint)}
+9 -2
View File
@@ -5,7 +5,7 @@
<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="icon" type="image/png" href="/logo-icon.png"> <link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260911b"> <link rel="stylesheet" href="/assets/site.css?v=20260911c">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -438,6 +438,13 @@
<p id="cBudgetRow"><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p> <p id="cBudgetRow"><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
<p id="cCapRow" style="grid-column:1/-1"><input id="cDailyCap" type="number" placeholder="Daily cap in credits (optional) — paces the budget across days" min="10" style="width:100%"> <p id="cCapRow" style="grid-column:1/-1"><input id="cDailyCap" type="number" placeholder="Daily cap in credits (optional) — paces the budget across days" min="10" style="width:100%">
<span class="small muted">Network Ad Space can deliver a lot of impressions fast. A daily cap spreads a banner or text budget over days instead of hours.</span></p> <span class="small muted">Network Ad Space can deliver a lot of impressions fast. A daily cap spreads a banner or text budget over days instead of hours.</span></p>
<div id="cSchedRow" style="grid-column:1/-1">
<div style="display:flex;gap:10px;flex-wrap:wrap">
<label class="small muted" style="flex:1 1 200px;display:block"><span id="cStartLbl">Start (optional)</span><input id="cStartAt" type="datetime-local" style="width:100%;margin-top:4px"></label>
<label class="small muted" style="flex:1 1 200px;display:block">End (optional)<input id="cEndAt" type="datetime-local" style="width:100%;margin-top:4px"></label>
</div>
<span class="small muted" id="cSchedHint">Leave both empty to start now and run until the budget is spent. Times are your local time. Anything left when a campaign ends goes back to Available.</span>
</div>
</div> </div>
<p><input id="cTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p> <p><input id="cTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p>
<p class="small muted" style="margin-top:-6px">Banner and text targets are shown full screen in the <p class="small muted" style="margin-top:-6px">Banner and text targets are shown full screen in the
@@ -884,7 +891,7 @@
<script src="/assets/common.js?v=20260911a"></script> <script src="/assets/common.js?v=20260911a"></script>
<script src="/assets/wallet.js?v=20260910a"></script> <script src="/assets/wallet.js?v=20260910a"></script>
<script src="/assets/promo.js?v=20260911a"></script> <script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260911a"></script> <script src="/assets/my.js?v=20260911b"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+2
View File
@@ -298,6 +298,7 @@ async function boot() {
chatbot.init({ dataDir: DATA_DIR, chain }); chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000); setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000); setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
setInterval(() => ads.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer }); coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
burner.init({ chain, ads }); burner.init({ chain, ads });
@@ -1624,6 +1625,7 @@ 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));
out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days
const pool = await ads.balances((await myMemberIds(s)).ids, s.email); 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