Files
rm-circle-team-router/public/suite-grants.js
T
martbost 05afe5c5f6 Admin: award Suite capacity to any member as a team bonus
Marty asked for a way to grant impressions to anybody, labelled as an admin
bonus. Team Grants already did this for leaders, but only within their own
organisation and only out of a level pool.

adminGrant() is deliberately a different thing: it comes from no pool (the
team is not a position and has no allowance), it can reach ANY registered
position rather than only someone downline, and it carries a reason that
survives in the ledger. Recorded with by:0 so nothing downstream mistakes it
for a leader's grant — the member's own page renders those as "the team"
rather than "#0".

The route verifies the position exists on-chain before crediting it; a typo
would otherwise sit in the ledger crediting nobody, and it would be invisible
until someone went looking.

Awards land on top of whatever the level already allows and expire with the
month like every other allowance. The admin page lists what has been awarded
this month, so bonuses stay auditable rather than vanishing once given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 05:40:00 -05:00

162 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Team Grants client.
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function n(v) { return Number(v || 0).toLocaleString(); }
var state = { tool: null, status: null, level: 0, minLevel: 7 };
function gate(msg) {
$('gGate').style.display = 'block';
$('gGate').innerHTML = msg;
}
// Shown to everyone, including people below level 7 — being helped should be
// visible, not silent.
function renderReceived(received) {
var keys = Object.keys(received || {});
if (!keys.length) { $('gGot').innerHTML = ''; return; }
var parts = keys.map(function (k) {
var r = received[k];
// by:0 is an admin bonus from the team, not a member grant.
var who = r.from.map(function (f) { return Number(f.by) === 0 ? 'the team' : '#' + f.by; });
var uniq = who.filter(function (v, i) { return who.indexOf(v) === i; });
var t = (state.status && state.status.tools[k]) || {};
return '<div>· <b style="color:var(--text)">' + n(r.total) + ' ' + esc(t.unit || k) +
'</b> of ' + esc((t.label || k).toLowerCase()) + ', from ' + uniq.join(', ') + '</div>';
});
$('gGot').innerHTML = '<div class="g-got"><b style="color:var(--teal)">You have been topped up this month.</b>' +
parts.join('') + '<div style="color:var(--muted);font-size:12.5px;margin-top:6px">' +
'It is already added to your allowances — nothing to claim.</div></div>';
}
function renderPools() {
var host = $('gPools');
host.innerHTML = '';
var tools = state.status.tools;
Object.keys(tools).forEach(function (k) {
var t = tools[k];
var d = document.createElement('div');
d.className = 'g-pool' + (state.tool === k ? ' on' : '');
d.innerHTML = '<div class="big">' + n(t.remaining) + '</div>' +
'<div class="lab">' + esc(t.label) + '</div>' +
'<div class="of">left to give of ' + n(t.pool) + '</div>';
d.addEventListener('click', function () {
state.tool = k;
renderPools();
var el = $('gN');
el.max = Math.min(t.max, t.remaining);
el.step = t.step;
el.value = Math.min(t.step, t.remaining) || '';
$('gNHint').textContent = 'Up to ' + n(Math.min(t.max, t.remaining)) + ' ' + t.unit +
' in one go. You have ' + n(t.remaining) + ' left to give this month.';
});
host.appendChild(d);
});
}
function renderHistory(hist) {
if (!hist || !hist.length) { $('gHistWrap').style.display = 'none'; return; }
$('gHistWrap').style.display = 'block';
var tb = $('gHist');
tb.innerHTML = '';
hist.forEach(function (h) {
var t = (state.status.tools[h.tool]) || {};
var tr = document.createElement('tr');
tr.innerHTML = '<td><b>#' + esc(h.to) + '</b></td>' +
'<td>' + esc(t.label || h.tool) + '</td>' +
'<td><b>' + n(h.n) + '</b> ' + esc(t.unit || '') + '</td>' +
'<td>' + esc(String(h.at || '').slice(0, 10)) + '</td>';
tb.appendChild(tr);
});
}
async function give() {
$('gErr').style.display = 'none';
$('gOk').style.display = 'none';
if (!state.tool) {
$('gErr').textContent = 'Pick what you want to give first.';
$('gErr').style.display = 'block';
return;
}
var to = Number($('gTo').value) || 0;
var amount = Number($('gN').value) || 0;
if (!to) {
$('gErr').textContent = 'Enter the position number you want to help.';
$('gErr').style.display = 'block';
return;
}
$('gGo').disabled = true;
$('gGo').innerHTML = '<span class="spin"></span>Sending…';
try {
var r = await fetch('/api/public/suite-grants', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: to, tool: state.tool, n: amount })
});
var d = await r.json();
if (!r.ok) {
$('gErr').textContent = d.error || 'That did not go through.';
$('gErr').style.display = 'block';
} else {
state.status = d.status;
var t = state.status.tools[d.granted.tool] || {};
$('gOk').innerHTML = '✅ <b>Sent.</b> #' + esc(d.granted.to) + ' now has ' + n(d.granted.n) + ' more ' +
esc(t.unit || '') + ' this month. Worth telling them — it will just appear in their allowance otherwise.';
$('gOk').style.display = 'block';
renderPools();
renderHistory(state.status.history);
}
} catch (e) {
$('gErr').textContent = 'Connection hiccup — try again.';
$('gErr').style.display = 'block';
}
$('gGo').disabled = false;
$('gGo').innerHTML = '🎁 Give it to them';
}
async function boot() {
try {
var r = await fetch('/api/public/suite-grants');
if (r.status === 401) { gate('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
if (r.status === 403) {
var g = await r.json();
gate((g.error || 'Not open for this position yet.') + ' <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.');
return;
}
if (!r.ok) return;
var d = await r.json();
state.status = d.status;
state.level = d.level;
state.minLevel = d.minLevel;
renderReceived(d.status.received);
if (!d.status.canGrant) {
gate('<b>Team Grants unlock at Vertex (level 7).</b> At that point you can hand your own Suite capacity ' +
'down to anyone in your organisation who needs it. Anything granted <em>to</em> you shows above and is already in your allowances.');
return;
}
$('gBody').style.display = 'block';
var first = Object.keys(d.status.tools)[0];
state.tool = first;
renderPools();
var t = d.status.tools[first];
$('gN').max = Math.min(t.max, t.remaining);
$('gN').step = t.step;
$('gN').value = Math.min(t.step, t.remaining) || '';
$('gNHint').textContent = 'Up to ' + n(Math.min(t.max, t.remaining)) + ' ' + t.unit + ' in one go.';
renderHistory(d.status.history);
} catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
boot();
$('gGo').addEventListener('click', give);
});
})();