7331eac244
Overview gains a recent-activity mini-feed (chain events + line joins) and a quick-actions card jumping straight to buy/earn/campaigns plus one-tap invite copy. Panes fade in (reduced-motion safe). First slice of the back-office polish pass. Assets v=20260905f. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
411 lines
21 KiB
JavaScript
411 lines
21 KiB
JavaScript
// My account: email-first join/login, wallet link at purchase time,
|
||
// free payout activation, invite link, on-chain activity.
|
||
(async function () {
|
||
// back-office shell: no marketing nav here; rehearsal notice lives in the top bar
|
||
const $ = IAP.$;
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
if (c.rehearsal && $('boRehearsal')) {
|
||
$('boRehearsal').hidden = false;
|
||
$('boRehearsal').innerHTML = '<b>Testnet rehearsal</b> · ' + c.chainName;
|
||
}
|
||
} catch (e) {}
|
||
|
||
async function api(path, body) {
|
||
const r = await (await fetch(path, { method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
|
||
if (r.error) throw new Error(r.error);
|
||
return r;
|
||
}
|
||
|
||
function nextMove(d) {
|
||
if (!d.address) return 'Grab your link below and start sharing today. Then link your wallet (one free signature) so every payment can lock to you before your people start buying.';
|
||
if (!d.memberId) return 'Your wallet is linked. Switch on payouts above (one free transaction) and every purchase in your line pays you the moment it happens.';
|
||
if (d.buyerCount === 0) return 'Payouts are on and your link is live. Your next milestone: your first buyer of $20 or more. Every direct pays you 50 percent from their very first package.';
|
||
if (d.buyerCount === 1) return 'One qualifying buyer down, one to go. Your next $20+ buyer unlocks level 2: 20 percent of everything your people’s people buy.';
|
||
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.';
|
||
}
|
||
async function loadDashboard() {
|
||
try {
|
||
const d = await (await fetch('/api/my/dashboard')).json();
|
||
if (d.error) return;
|
||
const wc = d.welcomeCredits || 0;
|
||
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
|
||
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
|
||
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
|
||
$('dbBuyers').textContent = d.buyerCount || 0;
|
||
$('dbTeam').textContent = (d.referrals || []).length;
|
||
$('nextMove').textContent = nextMove(d);
|
||
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
|
||
const wrap = $('rosterWrap');
|
||
if ((d.referrals || []).length) {
|
||
$('rosterEmpty').hidden = true;
|
||
let t = wrap.querySelector('table');
|
||
if (t) t.remove();
|
||
t = document.createElement('table');
|
||
t.className = 'roster';
|
||
t.innerHTML = d.referrals.map(r => '<tr><td>' + r.email + '</td>'
|
||
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
|
||
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
|
||
wrap.appendChild(t);
|
||
}
|
||
// overview recent-activity widget: chain events + line joins, newest first
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const ov = $('ovFeed');
|
||
const rows = [];
|
||
if (d.memberId) {
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
for (const ev of [...(a.earnings || []), ...(a.purchases || [])]
|
||
.sort((x, y) => y.block - x.block).slice(0, 5)) rows.push(IAP.feedRow(ev, c));
|
||
}
|
||
for (const r of (d.referrals || []).slice(0, 3)) {
|
||
const div = document.createElement('div');
|
||
div.className = 'row';
|
||
div.innerHTML = '<span>🤝 ' + r.email + ' joined your line</span><span class="tx muted small">'
|
||
+ new Date(r.joined).toLocaleDateString() + '</span>';
|
||
rows.push(div);
|
||
}
|
||
if (rows.length) {
|
||
ov.innerHTML = '';
|
||
rows.slice(0, 6).forEach(r => ov.appendChild(r));
|
||
}
|
||
} catch (e) {}
|
||
// ready-to-send share message
|
||
const link = location.origin + '/join/' + (d.refCode || d.memberId || '');
|
||
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. '
|
||
+ 'Free to join and look around: ' + link;
|
||
$('copyPitch').hidden = false;
|
||
$('pitchPreview').hidden = false;
|
||
$('pitchPreview').textContent = '"' + pitch + '"';
|
||
$('copyPitch').onclick = async () => {
|
||
try { await navigator.clipboard.writeText(pitch); IAP.status('Message copied. Paste it anywhere.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select the preview text instead.', 'bad'); }
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── back-office menu: hash-routed panes ───────────────
|
||
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'wallet'];
|
||
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
|
||
earn: 'Earn credits', earnings: 'Earnings', wallet: 'Wallet & account' };
|
||
function setPane(name) {
|
||
if (!PANES.includes(name)) name = 'overview';
|
||
for (const p of PANES) {
|
||
const el = $('pane-' + p);
|
||
if (el) el.hidden = p !== name;
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.pane === name));
|
||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.pane)));
|
||
document.querySelectorAll('.qa [data-goto]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.goto)));
|
||
const qaCopy = document.getElementById('qaCopyInvite');
|
||
if (qaCopy) qaCopy.addEventListener('click', async () => {
|
||
const link = document.getElementById('inviteLine').textContent;
|
||
if (!link || !link.startsWith('http')) { setPane('line'); return; }
|
||
try { await navigator.clipboard.writeText(link); IAP.status('Invite link copied.', 'ok'); }
|
||
catch (e) { setPane('line'); }
|
||
});
|
||
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
|
||
if ($('boBurger')) $('boBurger').addEventListener('click', () =>
|
||
document.getElementById('memberArea').classList.toggle('side-open'));
|
||
|
||
async function render() {
|
||
const me = await IAP.refreshNavWallet();
|
||
const signedIn = me && me.signedIn;
|
||
$('authArea').hidden = !!signedIn;
|
||
$('memberArea').hidden = !signedIn;
|
||
if (!signedIn) return;
|
||
setPane(location.hash.slice(1) || 'overview');
|
||
$('campGate').hidden = !!me.memberId;
|
||
$('earnGate').hidden = !!me.memberId;
|
||
loadDashboard();
|
||
|
||
const who = [];
|
||
if (me.email) who.push(me.email);
|
||
if (me.address) who.push('wallet <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>');
|
||
else who.push('no wallet linked yet');
|
||
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
|
||
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
|
||
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
|
||
$('posLine').innerHTML = who.join('<br>');
|
||
|
||
$('creditLine').textContent = (me.credits || 0).toLocaleString();
|
||
$('linkCard').hidden = !!me.address;
|
||
$('activateCard').hidden = !(me.address && !me.memberId);
|
||
$('activityArea').hidden = !me.memberId;
|
||
$('campGate').hidden = true; // earned credits fund campaigns for everyone
|
||
$('campaignCard').hidden = false;
|
||
loadCampaigns();
|
||
|
||
// the share link works from day one; codes resolve to your chain id later
|
||
if (me.refCode || me.memberId) {
|
||
$('inviteLine').textContent = location.origin + '/join/' + (me.refCode || me.memberId);
|
||
$('copyInvite').hidden = false;
|
||
} else {
|
||
$('inviteLine').textContent = 'Sign in with your email to get your link.';
|
||
$('copyInvite').hidden = true;
|
||
}
|
||
if (me.memberId) {
|
||
const bc = me.buyerCount || 0;
|
||
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
|
||
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
|
||
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
|
||
: (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
|
||
loadActivity();
|
||
} else {
|
||
$('qualLine').innerHTML = 'Share your link now. Then switch on payouts (free, above) '
|
||
+ '<b>before your people start buying</b>: the contract locks each buyer to their sponsor at '
|
||
+ 'their first purchase, and payments only route to wallets that are switched on.';
|
||
}
|
||
}
|
||
|
||
async function loadActivity() {
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
const fill = (id, evs, empty) => {
|
||
const el = $(id);
|
||
el.innerHTML = '';
|
||
if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
|
||
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
|
||
};
|
||
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
|
||
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
|
||
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadCampaigns() {
|
||
try {
|
||
const r = await (await fetch('/api/my/campaigns')).json();
|
||
if (r.error) return;
|
||
$('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';
|
||
const el = $('campList');
|
||
el.innerHTML = '';
|
||
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
|
||
const tbl = document.createElement('div');
|
||
tbl.className = 'tablewrap';
|
||
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">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>'
|
||
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td><td>' + c.type + '</td>'
|
||
+ '<td class="num">' + c.imps.toLocaleString() + '</td><td class="num">' + c.clicks + '</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 === '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>' : '')
|
||
+ '</td></tr>').join('') + '</tbody></table>';
|
||
el.appendChild(tbl);
|
||
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
|
||
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
$('cType').addEventListener('change', () => {
|
||
const t = $('cType').value;
|
||
$('cImageRow').hidden = t === 'text';
|
||
$('cTitleRow').hidden = t !== 'text';
|
||
$('cBodyRow').hidden = t !== 'text';
|
||
});
|
||
$('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) });
|
||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||
$('cName').value = ''; $('cBudget').value = '';
|
||
await loadCampaigns();
|
||
}));
|
||
// defers the busy() lookup to click time (busy is declared below)
|
||
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
|
||
|
||
// ── in-dashboard package buying ──
|
||
const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||
async function loadBuyTiles() {
|
||
try {
|
||
const { products } = await (await fetch('/api/catalog')).json();
|
||
const wrap = $('boTiles');
|
||
wrap.innerHTML = '';
|
||
for (const p of products) {
|
||
const bonus = p.creditAmount - p.priceCents;
|
||
const div = document.createElement('div');
|
||
div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : '');
|
||
div.innerHTML = '<div class="name">' + (PKG[p.id] || 'Package ' + p.id) + '</div>'
|
||
+ '<div class="price">$' + Math.round(p.priceCents / 100) + '</div>'
|
||
+ '<div class="cr">' + p.creditAmount.toLocaleString() + ' credits</div>'
|
||
+ '<div class="bonus">' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '</div>'
|
||
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
|
||
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
|
||
+ (p.costWei ? '' : ' disabled') + '>Buy</button>';
|
||
wrap.appendChild(div);
|
||
}
|
||
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => {
|
||
try {
|
||
b.disabled = true;
|
||
const spNow = await (await fetch('/api/sponsor')).json();
|
||
IAP.status('Confirm the purchase in your wallet…');
|
||
const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost);
|
||
if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.');
|
||
IAP.status('Purchase settled on-chain. Credits are in your account.', 'ok');
|
||
await render();
|
||
loadBuyTiles();
|
||
} catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); }
|
||
finally { b.disabled = false; }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
loadBuyTiles();
|
||
|
||
// ── earn-by-viewing: daily ad set with dwell, then claim ──
|
||
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
|
||
async function earnRefresh() {
|
||
try {
|
||
const st = await (await fetch('/api/my/earn')).json();
|
||
if (st.error) return null;
|
||
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
|
||
$('earnBalance').textContent = st.earned + ' earned credits';
|
||
const done = st.views >= st.target;
|
||
$('earnClaimBtn').hidden = !(done && !st.claimed);
|
||
if (st.claimed) $('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.';
|
||
else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.';
|
||
return st;
|
||
} catch (e) { return null; }
|
||
}
|
||
async function earnShowAd() {
|
||
const st = await earnRefresh();
|
||
if (!st || st.views >= st.target) return;
|
||
const type = earnState.types[earnState.i++ % earnState.types.length];
|
||
let ad = null;
|
||
try { ad = (await (await fetch('/api/ads/slot?type=' + type)).json()).ad; } catch (e) {}
|
||
const box = $('earnAdBox');
|
||
if (!ad) {
|
||
box.innerHTML = '<span class="muted small">No member ads are live in rotation right now. '
|
||
+ 'Views resume the moment a campaign is running.</span>';
|
||
return;
|
||
}
|
||
box.innerHTML = ad.imageUrl
|
||
? '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + ad.imageUrl + '" alt="member ad" style="max-height:200px"></a>'
|
||
: '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow" style="font-size:17px"><b>' + ad.title + '</b>' + (ad.body ? '<br><span class="muted">' + ad.body + '</span>' : '') + '</a>';
|
||
const btn = $('earnNextBtn');
|
||
btn.hidden = false;
|
||
btn.disabled = true;
|
||
let left = st.dwell;
|
||
btn.textContent = 'Next ad (' + left + 's)';
|
||
clearInterval(earnState.timer);
|
||
earnState.timer = setInterval(async () => {
|
||
left -= 1;
|
||
if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; }
|
||
clearInterval(earnState.timer);
|
||
btn.textContent = 'Next ad';
|
||
btn.disabled = false;
|
||
await fetch('/api/my/adview', { method: 'POST' }); // dwell served: count it
|
||
await earnRefresh();
|
||
}, 1000);
|
||
}
|
||
$('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); });
|
||
$('earnNextBtn').addEventListener('click', earnShowAd);
|
||
$('earnClaimBtn').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/api/my/claim');
|
||
IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok');
|
||
await earnRefresh();
|
||
loadDashboard();
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
});
|
||
|
||
const busy = (btn, fn) => async () => {
|
||
try { btn.disabled = true; await fn(); }
|
||
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
||
finally { btn.disabled = false; }
|
||
};
|
||
|
||
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
|
||
(async () => {
|
||
const cfg = await IAP.getConfig();
|
||
if (!cfg.emailAuth) return;
|
||
$('passCards').hidden = true;
|
||
$('magicCard').hidden = false;
|
||
const start = busy($('mcSendBtn'), async () => {
|
||
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
|
||
$('mcCodeRow').hidden = false;
|
||
$('mcVerifyBtn').hidden = false;
|
||
$('mcSendBtn').hidden = true;
|
||
$('mcResend').hidden = false;
|
||
if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); }
|
||
else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok');
|
||
$('mcCode').focus();
|
||
});
|
||
$('mcSendBtn').addEventListener('click', start);
|
||
$('mcResend').addEventListener('click', busy($('mcResend'), async () => {
|
||
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
|
||
if (r.devCode) $('mcCode').value = r.devCode;
|
||
IAP.status('Fresh code sent.', 'ok');
|
||
}));
|
||
$('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 render();
|
||
}));
|
||
})();
|
||
|
||
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
|
||
await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value });
|
||
IAP.status('Welcome aboard. You are in.', 'ok');
|
||
await render();
|
||
}));
|
||
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
||
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
||
IAP.status('Logged in.', 'ok');
|
||
await render();
|
||
}));
|
||
$('walletSigninLink').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
try {
|
||
IAP.status('Check your wallet for the free sign-in signature…');
|
||
await IAPWallet.signIn();
|
||
IAP.status('Signed in with your wallet.', 'ok');
|
||
await render();
|
||
} catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
|
||
});
|
||
$('linkBtn').addEventListener('click', busy($('linkBtn'), async () => {
|
||
IAP.status('Check your wallet for the free link signature…');
|
||
await IAPWallet.signIn(); // server binds the wallet to the signed-in email account
|
||
IAP.status('Wallet linked. Earnings pay there from now on.', 'ok');
|
||
await render();
|
||
}));
|
||
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
|
||
const me = await (await fetch('/api/me')).json();
|
||
IAP.status('Confirm the free activation in your wallet…');
|
||
const r = await IAPWallet.activate(me.sponsorId || 0);
|
||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||
IAP.status('Payouts are on. Your invite link is live.', 'ok');
|
||
await render();
|
||
}));
|
||
$('copyInvite').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
|
||
});
|
||
$('logoutLink').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
await fetch('/api/auth/logout', { method: 'POST' });
|
||
IAP.status('Logged out.', 'ok');
|
||
await render();
|
||
});
|
||
|
||
render();
|
||
})();
|