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:
@@ -4313,3 +4313,114 @@ body {
|
||||
.site-footer__inner { grid-template-columns: 1fr; }
|
||||
.site-footer__bottom { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Syttende Mai banner ──────────────────────────────────────────────────── */
|
||||
.syttende-mai-banner {
|
||||
background: #ba0c2f;
|
||||
color: #fff;
|
||||
padding: 9px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
.syttende-mai-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.75);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.syttende-mai-close:hover { color: #fff; }
|
||||
|
||||
/* ── Free-tier credit badge ──────────────────────────────────────────────── */
|
||||
.credit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--soft-teal);
|
||||
color: var(--teal-dark);
|
||||
border: 1px solid rgba(15, 118, 110, 0.25);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.credit-badge.is-low {
|
||||
background: #fff7ed;
|
||||
color: #92400e;
|
||||
border-color: rgba(183, 121, 31, 0.3);
|
||||
}
|
||||
.credit-badge.is-empty {
|
||||
background: #fff0e8;
|
||||
color: #c2410c;
|
||||
border-color: rgba(194, 65, 12, 0.3);
|
||||
}
|
||||
|
||||
/* ── Credit-empty modal ───────────────────────────────────────────────────── */
|
||||
.credit-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(22, 19, 15, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
}
|
||||
.credit-modal {
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
padding: 32px 28px 24px;
|
||||
max-width: 380px;
|
||||
width: calc(100% - 40px);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.credit-modal__icon { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.credit-modal h3 { margin: 0 0 8px; font-size: 1.15rem; color: var(--ink); }
|
||||
.credit-modal p { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; line-height: 1.55; }
|
||||
.credit-modal__actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||
.credit-modal__actions a {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.credit-modal__cta { background: var(--teal); color: #fff; }
|
||||
.credit-modal__cta:hover { background: var(--teal-dark); }
|
||||
.credit-modal__dismiss { background: var(--line); color: var(--ink); }
|
||||
.credit-modal__dismiss:hover { background: #c8cdd8; }
|
||||
|
||||
/* ── Rate-limit toast ────────────────────────────────────────────────────── */
|
||||
.credit-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1b2330;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
z-index: 500;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
animation: toastIn 0.2s ease;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user