feat(dashboard): three separate credit pool bars, NOK display, Free Trial expiry banner

This commit is contained in:
chrischristiansen-glitch 2026-06-13 07:35:37 +02:00
parent 8c4728ef2d
commit c114f6c328

View File

@ -78,6 +78,24 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
/* ── credits kpi row ── */ /* ── credits kpi row ── */
.credits-kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px} .credits-kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
/* ── credit pool bars ── */
.credit-pools{display:flex;flex-direction:column;gap:14px;margin-top:16px}
.pool-item{display:flex;flex-direction:column;gap:6px}
.pool-header{display:flex;align-items:center;justify-content:space-between;font-size:12px}
.pool-label{font-weight:500;color:var(--text);display:flex;align-items:center;gap:6px}
.pool-badge{font-size:10px;font-weight:600;padding:2px 7px;border-radius:99px}
.pool-badge.crit{background:var(--red-bg);color:var(--red)}
.pool-badge.warn{background:var(--yellow-bg);color:var(--yellow)}
.pool-badge.ok{background:var(--green-bg);color:var(--green)}
.pool-badge.inactive{background:var(--surface-3);color:var(--muted)}
.pool-meta{font-size:11px;color:var(--muted);font-family:var(--mono)}
.pool-bar-bg{height:6px;background:var(--surface-3);border-radius:3px;overflow:hidden}
.pool-bar-fill{height:100%;border-radius:3px;transition:width 1.2s cubic-bezier(.16,1,.3,1)}
.pool-warning{font-size:11px;padding:6px 10px;border-radius:6px;margin-top:2px;display:none}
.pool-warning.show{display:flex;align-items:center;gap:6px}
.pool-warning.crit{background:var(--red-bg);color:var(--red)}
.pool-warning.warn{background:var(--yellow-bg);color:var(--yellow)}
/* ── draggable module ── */ /* ── draggable module ── */
.module{ .module{
position:relative; position:relative;
@ -125,11 +143,12 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
.kpi-val{font-size:22px;font-weight:700;font-family:var(--mono);letter-spacing:-.03em} .kpi-val{font-size:22px;font-weight:700;font-family:var(--mono);letter-spacing:-.03em}
.kpi-delta{font-size:12px;color:var(--muted)} .kpi-delta{font-size:12px;color:var(--muted)}
/* ── credits runway bar ── */ /* ── free trial expiry banner (topbar) ── */
.runway-bar-wrap{margin-top:14px} .freetrial-banner{display:none;width:100%;background:var(--red-bg);border-bottom:1px solid rgba(255,92,124,.25);padding:7px 24px;font-size:12px;color:var(--red);align-items:center;gap:8px;position:sticky;top:56px;z-index:190}
.runway-label{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:6px} .freetrial-banner.show{display:flex}
.runway-bar-bg{height:8px;background:var(--surface-3);border-radius:4px;overflow:hidden} .freetrial-banner .material-symbols-outlined{font-size:15px;animation:blink 1.2s ease-in-out infinite}
.runway-bar-fill{height:100%;border-radius:4px;transition:width 1.2s cubic-bezier(.16,1,.3,1)}
/* ── credits warning ── */
.credits-warning{display:none;margin-top:12px;padding:10px 14px;border-radius:8px;font-size:12px;align-items:center;gap:8px} .credits-warning{display:none;margin-top:12px;padding:10px 14px;border-radius:8px;font-size:12px;align-items:center;gap:8px}
.credits-warning.show{display:flex} .credits-warning.show{display:flex}
.credits-warning.crit{background:var(--red-bg);color:var(--red);border:1px solid rgba(255,92,124,.2)} .credits-warning.crit{background:var(--red-bg);color:var(--red);border:1px solid rgba(255,92,124,.2)}
@ -194,6 +213,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
.kpi-row{grid-template-columns:1fr 1fr;gap:8px} .kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.credits-kpi-row{grid-template-columns:1fr 1fr;gap:8px} .credits-kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.page-title{font-size:18px} .page-title{font-size:18px}
.freetrial-banner{top:auto;position:relative}
} }
@media(max-width:420px){.kpi-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:1fr}} @media(max-width:420px){.kpi-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:1fr}}
@media print{ @media print{
@ -205,6 +225,12 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
</head> </head>
<body> <body>
<!-- Free Trial kritisk-banner (vises øverst hvis < 30 dager) -->
<div class="freetrial-banner" id="freetrial-banner">
<span class="material-symbols-outlined">warning</span>
<span id="freetrial-msg"></span>
</div>
<header class="topbar"> <header class="topbar">
<div class="tb-logo"> <div class="tb-logo">
<div class="tb-logo-icon"> <div class="tb-logo-icon">
@ -347,21 +373,21 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<div class="module" style="border:none;box-shadow:none"> <div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px"> <div class="module-body" style="padding:12px">
<div class="kpi-top"> <div class="kpi-top">
<span class="kpi-lbl">Kreditter brukt</span> <span class="kpi-lbl">Totalt brukt</span>
<div class="kpi-icon ki-blue"><span class="material-symbols-outlined">credit_card</span></div> <div class="kpi-icon ki-blue"><span class="material-symbols-outlined">credit_card</span></div>
</div> </div>
<div class="kpi-val" id="cr-used"></div> <div class="kpi-val" id="cr-used"></div>
<div class="kpi-delta" id="cr-used-pct">av total</div> <div class="kpi-delta" id="cr-used-pct">siste 90 dager</div>
</div> </div>
</div> </div>
<div class="module" style="border:none;box-shadow:none"> <div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px"> <div class="module-body" style="padding:12px">
<div class="kpi-top"> <div class="kpi-top">
<span class="kpi-lbl">Gjenstår</span> <span class="kpi-lbl">Infra gjenstår</span>
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">savings</span></div> <div class="kpi-icon ki-red"><span class="material-symbols-outlined">timer</span></div>
</div> </div>
<div class="kpi-val" id="cr-remaining"></div> <div class="kpi-val" id="cr-infra-remaining"></div>
<div class="kpi-delta">estimert saldo</div> <div class="kpi-delta" id="cr-infra-days">Free Trial</div>
</div> </div>
</div> </div>
<div class="module" style="border:none;box-shadow:none"> <div class="module" style="border:none;box-shadow:none">
@ -371,40 +397,29 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">local_fire_department</span></div> <div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">local_fire_department</span></div>
</div> </div>
<div class="kpi-val" id="cr-burn"></div> <div class="kpi-val" id="cr-burn"></div>
<div class="kpi-delta">USD/dag (snitt 7d)</div> <div class="kpi-delta">NOK/dag (snitt 7d)</div>
</div> </div>
</div> </div>
<div class="module" style="border:none;box-shadow:none"> <div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px"> <div class="module-body" style="padding:12px">
<div class="kpi-top"> <div class="kpi-top">
<span class="kpi-lbl">Runway</span> <span class="kpi-lbl">Vertex gjenstår</span>
<div class="kpi-icon ki-red" id="cr-runway-icon"><span class="material-symbols-outlined">schedule</span></div> <div class="kpi-icon ki-green"><span class="material-symbols-outlined">bolt</span></div>
</div> </div>
<div class="kpi-val" id="cr-runway"></div> <div class="kpi-val" id="cr-vertex-remaining"></div>
<div class="kpi-delta" id="cr-exhaustion">dager igjen</div> <div class="kpi-delta">Gen App Builder</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Runway-bar --> <!-- Tre separate kreditt-pools -->
<div class="runway-bar-wrap"> <div class="credit-pools" id="cr-pools"></div>
<div class="runway-label">
<span>Kredittforbruk</span>
<span id="cr-bar-label">$0 / $—</span>
</div>
<div class="runway-bar-bg">
<div class="runway-bar-fill" id="cr-bar-fill" style="width:0%;background:var(--blue)"></div>
</div>
</div>
<!-- Advarsel-banner --> <!-- Advarsel-banner -->
<div class="credits-warning" id="cr-warning"> <div class="credits-warning" id="cr-warning">
<span class="material-symbols-outlined" style="font-size:16px">warning</span> <span class="material-symbols-outlined" style="font-size:16px">warning</span>
<span id="cr-warning-msg"></span> <span id="cr-warning-msg"></span>
</div> </div>
<!-- Kreditter per type -->
<div id="cr-types" style="margin-top:14px"></div>
</div> </div>
</div> </div>
<!-- ═══ /CG4-CREDITS ═══ --> <!-- ═══ /CG4-CREDITS ═══ -->
@ -592,12 +607,22 @@ const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,to
const DEMO_ANOMALIES = []; const DEMO_ANOMALIES = [];
const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd']; const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'];
const POOL_COLORS = ['#ff5c7c','#7c5cff','#00d4a8'];
function fmtNOK(v,d=2){return 'kr '+Math.abs(Number(v)||0).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d});} function fmtNOK(v,d=2){return 'kr '+Math.abs(Number(v)||0).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d});}
function fmtUSD(v,d=2){return '$'+(Math.abs(Number(v)||0)).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d});}
function animN(el,end,fmt,dur=700){const t0=performance.now();const go=now=>{const p=Math.min((now-t0)/dur,1),e=1-Math.pow(1-p,3);el.textContent=fmt(e*Math.abs(end));if(p<1)requestAnimationFrame(go);};requestAnimationFrame(go);} function animN(el,end,fmt,dur=700){const t0=performance.now();const go=now=>{const p=Math.min((now-t0)/dur,1),e=1-Math.pow(1-p,3);el.textContent=fmt(e*Math.abs(end));if(p<1)requestAnimationFrame(go);};requestAnimationFrame(go);}
function serviceColor(i){return SVC_COLORS[i%SVC_COLORS.length];} function serviceColor(i){return SVC_COLORS[i%SVC_COLORS.length];}
async function apiFetch(url,opts={}){const r=await fetch(url,opts);if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});}return r;} async function apiFetch(url,opts={}){
try{
const r=await fetch(url,opts);
if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});}
return r;
} catch(e) {
console.warn('apiFetch error:',url,e.message);
throw e;
}
}
// ── auth ────────────────────────────────────────────────────────────────── // ── auth ──────────────────────────────────────────────────────────────────
async function checkAuth(){ async function checkAuth(){
@ -665,37 +690,38 @@ function toggleModule(id){
localStorage.setItem(LS_COLLAPSED,JSON.stringify(collapsed)); localStorage.setItem(LS_COLLAPSED,JSON.stringify(collapsed));
} }
// ── fetch ───────────────────────────────────────────────────────────────── // ── fetch (med retry-fallback) ────────────────────────────────────────────
async function fetchAll(){ async function fetchAll(){
const dot=document.getElementById('live-dot'); const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)'; dot.style.background='var(--warn)';
try{ const results = await Promise.allSettled([
const [sr,fr,ar,hr,svcr] = await Promise.all([
apiFetch(API_BASE+'/billing/summary'), apiFetch(API_BASE+'/billing/summary'),
apiFetch(API_BASE+'/billing/live'), apiFetch(API_BASE+'/billing/live'),
apiFetch(API_BASE+'/billing/anomalies'), apiFetch(API_BASE+'/billing/anomalies'),
apiFetch(API_BASE+'/billing/history'), apiFetch(API_BASE+'/billing/history'),
apiFetch(API_BASE+'/billing/by-service'), apiFetch(API_BASE+'/billing/by-service'),
]); ]);
if(!sr.ok||!fr.ok) throw new Error('Core endpoints failed'); const [sr,fr,ar,hr,svcr] = results;
const [s,f] = await Promise.all([sr.json(),fr.json()]); const coreOk = sr.status==='fulfilled'&&sr.value.ok && fr.status==='fulfilled'&&fr.value.ok;
const a = ar.ok ? await ar.json() : {anomalies:[]}; if(!coreOk){
const h = hr.ok ? await hr.json() : {history:[]}; console.warn('Core endpoints feilet, bruker demo-data');
const svc = svcr.ok ? await svcr.json() : []; currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES,history:[],serviceGroups:null};
document.getElementById('err-msg').textContent='API utilgjengelig — viser demo-data';
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)';
} else {
const [s,f] = await Promise.all([sr.value.json(),fr.value.json()]);
const a = ar.status==='fulfilled'&&ar.value.ok ? await ar.value.json() : {anomalies:[]};
const h = hr.status==='fulfilled'&&hr.value.ok ? await hr.value.json() : {history:[]};
const svc = svcr.status==='fulfilled'&&svcr.value.ok ? await svcr.value.json() : [];
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[],history:h.history||[],serviceGroups:svc}; currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[],history:h.history||[],serviceGroups:svc};
document.getElementById('err-banner').classList.remove('show'); document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--ok)'; dot.style.background='var(--ok)';
}catch(err){
console.warn('API feil, demo-data:',err.message);
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES,history:[],serviceGroups:null};
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message;
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)';
} }
renderAll(); renderAll();
fetchTokens(); fetchTokens();
fetchRecommendations(); fetchRecommendations();
fetchCredits(); // CG4-credits: hentes parallelt med alt annet fetchCredits();
} }
// ── CG4-credits: fetchCredits ───────────────────────────────────────────── // ── CG4-credits: fetchCredits ─────────────────────────────────────────────
@ -709,96 +735,122 @@ async function fetchCredits(){
} catch(e){ } catch(e){
document.getElementById('credits-sub').textContent='Ikke tilgjengelig'; document.getElementById('credits-sub').textContent='Ikke tilgjengelig';
document.getElementById('cr-used').textContent='—'; document.getElementById('cr-used').textContent='—';
document.getElementById('cr-remaining').textContent='—'; document.getElementById('cr-infra-remaining').textContent='—';
document.getElementById('cr-vertex-remaining').textContent='—';
document.getElementById('cr-burn').textContent='—'; document.getElementById('cr-burn').textContent='—';
document.getElementById('cr-runway').textContent='—';
document.getElementById('cr-exhaustion').textContent=e.message;
console.warn('[credits]',e.message); console.warn('[credits]',e.message);
} }
} }
function renderCredits(d){ function renderCredits(d){
// --- KPI-tall --- const pools = d.credit_pools ?? [];
const used = d.credits_used_total_usd ?? 0; const usedTotal = d.credits_used_total_nok ?? 0;
const remaining = d.credits_remaining_usd ?? null; const dailyBurn = d.daily_credit_burn_nok ?? 0;
const total = d.credit_total_usd ?? null; const topWarning = d.warning ?? '';
const burn = d.daily_credit_burn_usd ?? 0;
const runway = d.credit_runway_days ?? null;
const exhausted = d.credit_exhaustion_date ?? null;
const pct = total ? Math.min(used/total,1) : null;
// subtitle // Subtitle
const anyRunway = pools.find(p=>p.runway_days!=null);
document.getElementById('credits-sub').textContent = document.getElementById('credits-sub').textContent =
runway != null ? `Runway: ${runway} dager` : 'Sett GOOGLE_CREDIT_TOTAL_USD for runway'; anyRunway ? `Infra runway: ${anyRunway.runway_days} dager` : 'Kreditter lastet';
// KPI: brukt // KPI: totalt brukt
animN(document.getElementById('cr-used'), used, v => fmtUSD(v)); animN(document.getElementById('cr-used'), usedTotal, v => fmtNOK(v));
document.getElementById('cr-used-pct').textContent =
(pct!=null) ? `${Math.round(pct*100)}% av ${fmtUSD(total,0)}` : 'total ukjent — sett env-var';
// KPI: gjenstår // KPI: Infra gjenstår (Free Trial)
if(remaining!=null){ const infraPool = pools.find(p=>p.env_key==='GOOGLE_CREDIT_INFRA_NOK');
animN(document.getElementById('cr-remaining'), remaining, v => fmtUSD(v)); if(infraPool && infraPool.remaining_nok!=null){
animN(document.getElementById('cr-infra-remaining'), infraPool.remaining_nok, v=>fmtNOK(v));
document.getElementById('cr-infra-days').textContent =
infraPool.expires_days!=null ? `⚠️ Utløper om ${infraPool.expires_days}d` : 'Free Trial';
} else { } else {
document.getElementById('cr-remaining').textContent = '—'; document.getElementById('cr-infra-remaining').textContent='—';
} }
// KPI: daglig burn // KPI: daglig burn
animN(document.getElementById('cr-burn'), burn, v => fmtUSD(v,4)); animN(document.getElementById('cr-burn'), dailyBurn, v=>fmtNOK(v,4));
// KPI: runway // KPI: Vertex gjenstår
const runwayEl = document.getElementById('cr-runway'); const vertexPool = pools.find(p=>p.env_key==='GOOGLE_CREDIT_VERTEX_NOK');
const iconEl = document.getElementById('cr-runway-icon'); if(vertexPool && vertexPool.remaining_nok!=null){
const exhaustEl = document.getElementById('cr-exhaustion'); animN(document.getElementById('cr-vertex-remaining'), vertexPool.remaining_nok, v=>fmtNOK(v));
if(runway!=null){
animN(runwayEl, runway, v => Math.round(v)+' d');
exhaustEl.textContent = exhausted ? 'tom ca. '+exhausted : 'dager igjen';
// Ikon-farge basert på kritikalitet
iconEl.className = 'kpi-icon ' + (runway<30 ? 'ki-red' : runway<60 ? 'ki-yellow' : 'ki-green');
} else { } else {
runwayEl.textContent = '—'; document.getElementById('cr-vertex-remaining').textContent='—';
exhaustEl.textContent = 'sett GOOGLE_CREDIT_TOTAL_USD';
iconEl.className = 'kpi-icon ki-blue';
} }
// --- Runway-bar --- // Tre pool-bars
const fill = document.getElementById('cr-bar-fill'); const poolsEl = document.getElementById('cr-pools');
const barLbl= document.getElementById('cr-bar-label'); if(pools.length){
if(pct!=null){ poolsEl.innerHTML = pools.map((pool, i) => {
setTimeout(()=>fill.style.width=(pct*100).toFixed(1)+'%',80); const color = POOL_COLORS[i] || '#7c5cff';
fill.style.background = pct>0.8 ? 'var(--red)' : pct>0.6 ? 'var(--yellow)' : 'var(--blue)'; const pct = (pool.total_nok && pool.used_nok)
barLbl.textContent = fmtUSD(used)+' / '+fmtUSD(total,0); ? Math.min(pool.used_nok / pool.total_nok * 100, 100).toFixed(1)
: 0;
const barColor = pool.expires_days!=null&&pool.expires_days<=30
? 'var(--red)'
: pct>80 ? 'var(--red)' : pct>60 ? 'var(--yellow)' : color;
let badgeClass='ok', badgeTxt='OK';
if(!pool.configured){badgeClass='inactive';badgeTxt='Ikke satt';}
else if(pool.expires_days!=null&&pool.expires_days<=30){badgeClass='crit';badgeTxt=`${pool.expires_days}d`;}
else if(pool.runway_days!=null&&pool.runway_days<30){badgeClass='crit';badgeTxt=`${pool.runway_days}d`;}
else if(pool.runway_days!=null&&pool.runway_days<60){badgeClass='warn';badgeTxt=`${pool.runway_days}d`;}
const metaTxt = pool.total_nok
? `${fmtNOK(pool.used_nok)} brukt av ${fmtNOK(pool.total_nok)} — ${fmtNOK(pool.remaining_nok??0)} gjenstår`
: 'Ikke konfigurert';
const warnHtml = pool.warning
? `<div class="pool-warning show ${pool.expires_days!=null&&pool.expires_days<=30?'crit':'warn'}">
<span class="material-symbols-outlined" style="font-size:13px">warning</span>
${pool.warning}
</div>`
: '';
return `<div class="pool-item">
<div class="pool-header">
<span class="pool-label">
<span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;display:inline-block"></span>
${pool.label}
<span class="pool-badge ${badgeClass}">${badgeTxt}</span>
</span>
<span class="pool-meta">${metaTxt}</span>
</div>
<div class="pool-bar-bg">
<div class="pool-bar-fill" style="width:${pct}%;background:${barColor}" data-pct="${pct}"></div>
</div>
${warnHtml}
</div>`;
}).join('');
// Animate bars
setTimeout(()=>{
poolsEl.querySelectorAll('.pool-bar-fill').forEach(el=>{
const target = el.dataset.pct;
el.style.width = '0%';
requestAnimationFrame(()=>{ el.style.width = target+'%'; });
});
}, 80);
} else { } else {
fill.style.width='0%'; poolsEl.innerHTML = '<div class="empty-state" style="padding:12px 0"><span class="material-symbols-outlined">credit_card</span>Ingen kredittdata</div>';
barLbl.textContent = fmtUSD(used)+' brukt';
} }
// --- Advarsel-banner --- // Global advarsel-banner
const warn = document.getElementById('cr-warning'); const warn = document.getElementById('cr-warning');
const warnMsg = document.getElementById('cr-warning-msg'); const warnMsg = document.getElementById('cr-warning-msg');
const rawWarn = d.warning ?? ''; if(topWarning){
if(rawWarn){ warnMsg.textContent = topWarning;
warnMsg.textContent = rawWarn; warn.className = 'credits-warning show crit';
warn.className = 'credits-warning show ' + (runway!=null && runway<30 ? 'crit' : 'med');
} else { } else {
warn.className = 'credits-warning'; warn.className = 'credits-warning';
} }
// --- Kreditter per type (credits_by_type) --- // Free Trial topbar-banner
const types = d.credits_by_type ?? []; const ftBanner = document.getElementById('freetrial-banner');
const typesEl = document.getElementById('cr-types'); const ftMsg = document.getElementById('freetrial-msg');
if(types.length){ if(infraPool && infraPool.expires_days!=null && infraPool.expires_days<=30){
typesEl.innerHTML = ftMsg.textContent = `⚠️ Free Trial utløper om ${infraPool.expires_days} dager (${fmtNOK(infraPool.remaining_nok??0)} gjenstår) — aktiver fakturering for å unngå driftsstopp!`;
'<div style="font-size:11px;font-weight:500;color:var(--muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.06em">Kreditter per type</div>' + ftBanner.classList.add('show');
types.map(t =>
`<div class="svc-row">
<div class="svc-dot" style="background:var(--blue)"></div>
<span class="svc-name">${t.type??t.credit_type??'Ukjent'}</span>
<span class="svc-cost">${fmtUSD(t.amount??t.total_amount??0)}</span>
</div>`
).join('');
} else { } else {
typesEl.innerHTML = ''; ftBanner.classList.remove('show');
} }
} }