feat: free-tier credit system + Syttende Mai access for Google users

- FreeTier.php: credit check/deduct/reset engine with hourly rate limit
- bootstrap.php: dbnmDb() singleton, dbnToolsIsFreeTier(), credit gate helpers
- index.php: store tier=free|approved in session from SSO JWT
- All 7 API endpoints: credit gate (402/429) + X-Credits-Remaining header
- layout.php: credit meta tag, JS balance var, Syttende Mai banner (05-17 only)
- tools.js: credit badge in topbar, 402 modal, 429 toast, dbnUpdateCredits()
- barnevernet.js + deep-research.js: wire 402/429 handling for NDJSON streams
- tools.css: styles for credit badge, no-credits modal, rate-limit toast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:05:08 +02:00
parent 568314c554
commit 8b77acb828
15 changed files with 464 additions and 2 deletions
+10 -1
View File
@@ -432,10 +432,19 @@
}
if (!response.ok || !response.body) {
setStatus(`Request failed (${response.status}).`, 'error');
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
}
els.runButton.disabled = false;
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
+10 -1
View File
@@ -364,10 +364,19 @@
}
if (!response.ok || !response.body) {
setStatus(`Request failed (${response.status}).`, 'error');
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
}
els.runButton.disabled = false;
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
// Read the NDJSON stream
const reader = response.body.getReader();
+69
View File
@@ -1202,6 +1202,12 @@ async function postJson(url, payload) {
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (response.status === 402 || response.status === 429) {
dbnFreeTierError(response.status, data);
throw new Error(data.error?.message || (response.status === 429 ? 'rate_limit' : 'no_credits'));
}
const remaining = response.headers.get('X-Credits-Remaining');
if (remaining !== null) { dbnUpdateCredits(parseInt(remaining, 10)); }
if (!response.ok) {
throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
}
@@ -1929,3 +1935,66 @@ function escapeHtml(value) {
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
// ── Free-tier credit badge ────────────────────────────────────────────────
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
function dbnUpdateCredits(balance) {
if (typeof balance !== 'number' || balance < 0) return;
_freeTierBalance = balance;
const badge = document.getElementById('creditBadge');
if (!badge) return;
badge.textContent = '🪙 ' + balance + ' credit' + (balance !== 1 ? 's' : '');
badge.classList.toggle('is-low', balance > 0 && balance <= 2);
badge.classList.toggle('is-empty', balance === 0);
}
// Exposed so barnevernet.js / deep-research.js can call it
window.dbnUpdateCredits = dbnUpdateCredits;
function dbnFreeTierError(status, data) {
if (status === 429) {
const toast = document.createElement('div');
toast.className = 'credit-toast';
toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
return;
}
// 402 — no credits
const overlay = document.createElement('div');
overlay.className = 'credit-modal-overlay';
overlay.innerHTML = `
<div class="credit-modal" role="dialog" aria-modal="true" aria-labelledby="cmTitle">
<div class="credit-modal__icon">🪙</div>
<h3 id="cmTitle">Free credits used up</h3>
<p>Your 10 free credits for this month have been used.<br>
Credits reset on the 1st of next month — or upgrade to CaveauAI for unlimited access.</p>
<div class="credit-modal__actions">
<a href="https://dobetternorge.no/tools/" class="credit-modal__cta">Learn about CaveauAI</a>
<button class="credit-modal__dismiss" onclick="this.closest('.credit-modal-overlay').remove()">Close</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
// Exposed so barnevernet.js / deep-research.js can call it
window.dbnFreeTierError = dbnFreeTierError;
// Inject credit badge into topbar on page load
document.addEventListener('DOMContentLoaded', () => {
if (_freeTierBalance < 0) return;
const actions = document.querySelector('.topbar-actions');
if (!actions) return;
const badge = document.createElement('span');
badge.id = 'creditBadge';
badge.className = 'credit-badge';
badge.title = 'Free monthly credits remaining';
dbnUpdateCredits(_freeTierBalance); // set initial state before inserting
badge.textContent = '🪙 ' + _freeTierBalance + ' credit' + (_freeTierBalance !== 1 ? 's' : '');
badge.classList.toggle('is-low', _freeTierBalance > 0 && _freeTierBalance <= 2);
badge.classList.toggle('is-empty', _freeTierBalance === 0);
actions.insertBefore(badge, actions.firstChild);
});