feat(CG5-onboard): klient-onboarding wizard + backend-ruter kobler til /notify/webhook og /notify/email

This commit is contained in:
chrischristiansen-glitch 2026-06-12 17:10:56 +02:00
parent 722f2fa328
commit 50490b071d

522
static/onboard.html Normal file
View File

@ -0,0 +1,522 @@
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>CostGuard — Kom i gang</title>
<style>
:root{
--bg:#0a0a0f;--bg-2:#11131a;--bg-3:#161922;
--border:rgba(255,255,255,0.08);--border-2:rgba(255,255,255,0.14);
--text:#e8eaf0;--text-dim:#8b909c;--text-faint:#5a5f6b;
--accent:#7c5cff;--accent-2:#00d4a8;
--green:#3ecf8e;--danger:#ff5c7c;--amber:#ffb547;
--radius:12px;
--sans:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;
--mono:'SF Mono',ui-monospace,'JetBrains Mono',Menlo,monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{min-height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased}
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
input,select,textarea{font-family:inherit}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
/* TOP */
.topbar{
display:flex;align-items:center;gap:14px;
padding:14px 32px;border-bottom:1px solid var(--border);
background:rgba(10,10,15,.95);backdrop-filter:blur(16px);
position:sticky;top:0;z-index:10;
}
.logo{
width:32px;height:32px;border-radius:10px;
background:linear-gradient(135deg,var(--accent),var(--accent-2));
display:flex;align-items:center;justify-content:center;
font-weight:900;font-size:13px;color:#050509;
}
.brand{font-size:15px;font-weight:800;letter-spacing:-.02em}
.brand-sub{font-size:11px;color:var(--text-faint)}
/* LAYOUT */
.page{max-width:680px;margin:0 auto;padding:48px 24px 80px;}
/* STEP INDICATOR */
.step-bar{
display:flex;align-items:center;gap:0;margin-bottom:48px;
}
.step-item{
display:flex;flex-direction:column;align-items:center;flex:1;
position:relative;
}
.step-item:not(:last-child)::after{
content:'';position:absolute;top:15px;left:50%;right:-50%;
height:1px;background:var(--border-2);
z-index:0;
}
.step-item.done::after{background:var(--accent)}
.step-num{
width:30px;height:30px;border-radius:50%;
border:1px solid var(--border-2);background:var(--bg-2);
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:700;color:var(--text-faint);
position:relative;z-index:1;
transition:background .2s,border-color .2s,color .2s;
}
.step-item.active .step-num{
background:var(--accent);border-color:var(--accent);color:#fff;
box-shadow:0 0 0 4px rgba(124,92,255,.2);
}
.step-item.done .step-num{
background:var(--green);border-color:var(--green);color:#050509;
}
.step-label{
font-size:10px;color:var(--text-faint);margin-top:6px;
font-weight:600;text-transform:uppercase;letter-spacing:.05em;
white-space:nowrap;
}
.step-item.active .step-label,.step-item.done .step-label{color:var(--text-dim)}
/* STEP PANELS */
.step-panel{display:none;animation:fadeIn .22s ease}
.step-panel.visible{display:block}
.step-title{font-size:24px;font-weight:800;letter-spacing:-.02em;margin-bottom:6px}
.step-sub{font-size:14px;color:var(--text-dim);margin-bottom:32px;line-height:1.6}
/* FORM */
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:20px}
.label{font-size:12px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em}
.label span{font-weight:400;color:var(--text-faint);text-transform:none;letter-spacing:0}
.input{
padding:11px 14px;border-radius:10px;
background:var(--bg-2);border:1px solid var(--border-2);
color:var(--text);font-size:14px;
outline:none;transition:border-color .15s,box-shadow .15s;
width:100%;
}
.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,255,.15)}
.input.err{border-color:var(--danger);box-shadow:0 0 0 3px rgba(255,92,124,.12)}
.field-err{font-size:11px;color:var(--danger);margin-top:-4px}
/* CHANNEL CARDS */
.ch-grid{display:flex;flex-direction:column;gap:10px;margin-bottom:28px}
.ch-card{
display:flex;align-items:flex-start;gap:14px;
padding:16px 18px;border-radius:var(--radius);
background:var(--bg-2);border:1px solid var(--border);
cursor:pointer;transition:border-color .15s,background .15s;
}
.ch-card.selected{
border-color:var(--accent);background:rgba(124,92,255,.07);
}
.ch-card .ch-ico{font-size:22px;flex-shrink:0;margin-top:1px}
.ch-card .ch-info{flex:1}
.ch-card .ch-name{font-size:14px;font-weight:700}
.ch-card .ch-desc{font-size:12px;color:var(--text-dim);margin-top:3px}
.ch-card .ch-tier{
font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;
background:var(--bg-3);color:var(--text-faint);border:1px solid var(--border);
margin-top:6px;display:inline-block;
}
.ch-check{
width:20px;height:20px;border-radius:50%;
border:2px solid var(--border-2);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:11px;margin-top:2px;
transition:background .15s,border-color .15s;
}
.ch-card.selected .ch-check{
background:var(--accent);border-color:var(--accent);color:#fff;
}
/* CHANNEL DETAIL FIELDS */
.ch-detail{display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)}
.ch-card.selected .ch-detail{display:block}
/* BUTTONS */
.btn-row{display:flex;gap:12px;align-items:center;margin-top:32px}
.btn-primary{
padding:11px 24px;border-radius:99px;
background:linear-gradient(135deg,var(--accent),var(--accent-2));
color:#050509;font-weight:700;font-size:14px;
display:inline-flex;align-items:center;gap:8px;
box-shadow:0 8px 24px rgba(0,0,0,.4);
transition:opacity .15s;
}
.btn-primary:disabled{opacity:.4;cursor:not-allowed}
.btn-secondary{
padding:10px 20px;border-radius:99px;
border:1px solid var(--border-2);background:var(--bg-2);
color:var(--text-dim);font-size:14px;font-weight:600;
transition:background .14s,color .14s;
}
.btn-secondary:hover{background:var(--bg-3);color:var(--text)}
.spinner{
width:14px;height:14px;border-radius:50%;
border:2px solid rgba(0,0,0,.2);border-top-color:#050509;
animation:spin .6s linear infinite;
}
/* TEST RESULT */
.test-result{
margin-top:14px;padding:12px 16px;border-radius:10px;
font-size:13px;font-weight:500;
display:none;
}
.test-result.ok{background:rgba(62,207,142,.1);border:1px solid rgba(62,207,142,.25);color:var(--green)}
.test-result.err{background:rgba(255,92,124,.1);border:1px solid rgba(255,92,124,.25);color:var(--danger)}
.test-result.show{display:block}
/* SUMMARY / DONE */
.summary-card{
background:var(--bg-2);border:1px solid var(--border);
border-radius:var(--radius);padding:24px;
margin-bottom:24px;
}
.summary-row{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px}
.summary-row:last-child{border-bottom:none}
.summary-key{color:var(--text-dim)}
.summary-val{font-weight:600;text-align:right;max-width:60%;word-break:break-all}
.done-check{
width:72px;height:72px;border-radius:50%;
background:rgba(62,207,142,.12);border:2px solid rgba(62,207,142,.3);
display:flex;align-items:center;justify-content:center;
font-size:34px;margin:0 auto 20px;
}
.done-title{text-align:center;font-size:22px;font-weight:800;margin-bottom:8px}
.done-sub{text-align:center;font-size:14px;color:var(--text-dim);margin-bottom:28px;line-height:1.6}
/* TOAST */
#toast{
position:fixed;bottom:24px;right:24px;
background:var(--bg-2);border:1px solid var(--border-2);
border-radius:10px;padding:12px 18px;font-size:13px;
box-shadow:0 8px 32px rgba(0,0,0,.5);
opacity:0;transform:translateY(8px);
transition:opacity .2s,transform .2s;pointer-events:none;z-index:100;
}
#toast.show{opacity:1;transform:none}
</style>
</head>
<body>
<div class="topbar">
<div class="logo">CG</div>
<div>
<div class="brand">CostGuard</div>
<div class="brand-sub">Kom i gang</div>
</div>
</div>
<main class="page">
<!-- STEP INDICATOR -->
<div class="step-bar" id="step-bar">
<div class="step-item active" data-step="0"><div class="step-num">1</div><div class="step-label">Bedrift</div></div>
<div class="step-item" data-step="1"><div class="step-num">2</div><div class="step-label">Varslingskanal</div></div>
<div class="step-item" data-step="2"><div class="step-num">3</div><div class="step-label">Test & bekreft</div></div>
<div class="step-item" data-step="3"><div class="step-num">4</div><div class="step-label">Ferdig</div></div>
</div>
<!-- STEP 1: BEDRIFT -->
<div class="step-panel visible" id="panel-0">
<div class="step-title">Hvem er du?</div>
<div class="step-sub">Grunnleggende info om bedriften din og GCP-prosjektet CostGuard skal overvåke.</div>
<div class="field">
<div class="label">Bedriftsnavn</div>
<input class="input" id="company" type="text" placeholder="Vauco AS" autocomplete="organization">
</div>
<div class="field">
<div class="label">Kontaktperson <span>(ditt navn)</span></div>
<input class="input" id="contact" type="text" placeholder="Chris Christiansen" autocomplete="name">
</div>
<div class="field">
<div class="label">E-postadresse</div>
<input class="input" id="email" type="email" placeholder="chris@bedrift.no" autocomplete="email">
</div>
<div class="field">
<div class="label">GCP-prosjekt ID</div>
<input class="input" id="gcp_project" type="text" placeholder="mitt-gcp-prosjekt-123" spellcheck="false">
</div>
<div class="field">
<div class="label">Estimert månedlig GCP-spend <span>(USD)</span></div>
<select class="input" id="gcp_spend">
<option value="">Velg range...</option>
<option value="starter">$0 $3 000 (Starter)</option>
<option value="guard">$3 000 $15 000 (Guard)</option>
<option value="shield">$15 000 $50 000 (Shield)</option>
<option value="enterprise">Over $50 000 (Enterprise)</option>
</select>
</div>
<div class="btn-row">
<button class="btn-primary" onclick="nextStep(0)">Neste →</button>
</div>
</div>
<!-- STEP 2: VARSLINGSKANAL -->
<div class="step-panel" id="panel-1">
<div class="step-title">Hvor vil du at Jason skal nå deg?</div>
<div class="step-sub">Velg én eller flere kanaler. CostGuard leverer direkte dit du allerede er.</div>
<div class="ch-grid">
<div class="ch-card" id="ch-webhook" onclick="toggleChannel('webhook')">
<div class="ch-ico">🔗</div>
<div class="ch-info">
<div class="ch-name">Webhook <span style="font-size:11px;font-weight:400;color:var(--text-faint)">(Slack · Teams · Google Chat · Discord)</span></div>
<div class="ch-desc">Jason poster meldinger direkte i din eksisterende kanal.</div>
<div class="ch-tier">Starter+</div>
<div class="ch-detail">
<div class="field" style="margin-bottom:0">
<div class="label">Webhook URL</div>
<input class="input" id="webhook_url" type="url" placeholder="https://hooks.slack.com/services/...">
</div>
</div>
</div>
<div class="ch-check" id="check-webhook"></div>
</div>
<div class="ch-card" id="ch-email" onclick="toggleChannel('email')">
<div class="ch-ico">📧</div>
<div class="ch-info">
<div class="ch-name">E-post</div>
<div class="ch-desc">Daglig digest + varsler ved spikes. Sendes via SendGrid.</div>
<div class="ch-tier">Starter+</div>
<div class="ch-detail">
<div class="field" style="margin-bottom:0">
<div class="label">Mottaker-e-post <span>(forhåndsutfylt fra forrige steg)</span></div>
<input class="input" id="email_to" type="email" placeholder="alert@bedrift.no">
</div>
</div>
</div>
<div class="ch-check" id="check-email"></div>
</div>
<div class="ch-card" style="opacity:.55;cursor:not-allowed" title="Krever Guard-tier">
<div class="ch-ico">📱</div>
<div class="ch-info">
<div class="ch-name">SMS <span style="font-size:11px;font-weight:400;color:var(--text-faint)">(kun kritiske spikes)</span></div>
<div class="ch-desc">Twilio-integrasjon — kommer i Guard-tier.</div>
<div class="ch-tier" style="color:var(--amber);border-color:rgba(255,181,71,.25)">Guard+ · Coming soon</div>
</div>
<div class="ch-check"></div>
</div>
</div>
<div class="btn-row">
<button class="btn-secondary" onclick="prevStep(1)">← Tilbake</button>
<button class="btn-primary" onclick="nextStep(1)">Neste →</button>
</div>
</div>
<!-- STEP 3: TEST & BEKREFT -->
<div class="step-panel" id="panel-2">
<div class="step-title">Test varslingene</div>
<div class="step-sub">Send en testmelding for å bekrefte at alt er koblet opp riktig — før vi aktiverer CostGuard.</div>
<div id="test-webhook-wrap" style="display:none;margin-bottom:20px">
<div class="summary-card" style="margin-bottom:0">
<div class="summary-row"><span class="summary-key">🔗 Webhook</span><span class="summary-val" id="test-webhook-url-disp"></span></div>
</div>
<button class="btn-secondary" style="margin-top:12px" onclick="testChannel('webhook')" id="test-wh-btn">Send testmelding til webhook</button>
<div class="test-result" id="test-webhook-result"></div>
</div>
<div id="test-email-wrap" style="display:none;margin-bottom:20px">
<div class="summary-card" style="margin-bottom:0">
<div class="summary-row"><span class="summary-key">📧 E-post</span><span class="summary-val" id="test-email-to-disp"></span></div>
</div>
<button class="btn-secondary" style="margin-top:12px" onclick="testChannel('email')" id="test-em-btn">Send test-e-post</button>
<div class="test-result" id="test-email-result"></div>
</div>
<p style="font-size:12px;color:var(--text-faint);margin-top:8px">Du kan hoppe over testingen og aktivere direkte.</p>
<div class="btn-row">
<button class="btn-secondary" onclick="prevStep(2)">← Tilbake</button>
<button class="btn-primary" id="activate-btn" onclick="activate()">Aktiver CostGuard ✓</button>
</div>
</div>
<!-- STEP 4: FERDIG -->
<div class="step-panel" id="panel-3">
<div class="done-check"></div>
<div class="done-title">CostGuard er aktivert!</div>
<div class="done-sub">Jason overvåker nå <span id="done-project" style="color:var(--accent);font-weight:700"></span>.<br>Du mottar første rapport innen 24 timer.</div>
<div class="summary-card">
<div class="summary-row"><span class="summary-key">Bedrift</span><span class="summary-val" id="done-company"></span></div>
<div class="summary-row"><span class="summary-key">Kontakt</span><span class="summary-val" id="done-contact"></span></div>
<div class="summary-row"><span class="summary-key">GCP-prosjekt</span><span class="summary-val" id="done-gcp"></span></div>
<div class="summary-row"><span class="summary-key">Tier</span><span class="summary-val" id="done-tier"></span></div>
<div class="summary-row"><span class="summary-key">Kanaler</span><span class="summary-val" id="done-channels"></span></div>
</div>
<div class="btn-row" style="justify-content:center">
<a href="/static/costguard-dashboard.html" class="btn-primary" style="text-decoration:none">Gå til dashboard →</a>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const TIERS = {starter:'Starter ($499/mnd)',guard:'Guard ($999/mnd)',shield:'Shield ($1.999/mnd)',enterprise:'Enterprise ($3.500+/mnd)'}
let selectedChannels = new Set()
let state = {}
function toast(msg, dur=3200){
const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show')
setTimeout(()=>t.classList.remove('show'),dur)
}
function setStep(to){
document.querySelectorAll('.step-panel').forEach(p=>p.classList.remove('visible'))
document.getElementById('panel-'+to).classList.add('visible')
document.querySelectorAll('.step-item').forEach((el,i)=>{
el.classList.toggle('active', i===to)
el.classList.toggle('done', i<to)
})
window.scrollTo({top:0,behavior:'smooth'})
}
function prevStep(from){setStep(from-1)}
function nextStep(from){
if(from===0){
const company=document.getElementById('company').value.trim()
const contact=document.getElementById('contact').value.trim()
const email=document.getElementById('email').value.trim()
const gcp=document.getElementById('gcp_project').value.trim()
const spend=document.getElementById('gcp_spend').value
const errors=[]
if(!company) errors.push('company')
if(!contact) errors.push('contact')
if(!email||!/^[^@]+@[^@]+\.[^@]+$/.test(email)) errors.push('email')
if(!gcp) errors.push('gcp_project')
if(!spend) errors.push('gcp_spend')
document.querySelectorAll('#panel-0 .input').forEach(el=>el.classList.remove('err'))
errors.forEach(id=>document.getElementById(id)?.classList.add('err'))
if(errors.length){toast('⚠️ Fyll ut alle feltene');return}
state={company,contact,email,gcp_project:gcp,tier:spend}
// Pre-fill email channel
document.getElementById('email_to').value=email
setStep(1)
} else if(from===1){
if(selectedChannels.size===0){toast('⚠️ Velg minst én varslingskanal');return}
if(selectedChannels.has('webhook')&&!document.getElementById('webhook_url').value.trim()){
document.getElementById('webhook_url').classList.add('err')
toast('⚠️ Skriv inn webhook URL');return
}
if(selectedChannels.has('email')&&!document.getElementById('email_to').value.trim()){
document.getElementById('email_to').classList.add('err')
toast('⚠️ Skriv inn e-postadresse');return
}
state.webhook_url = selectedChannels.has('webhook')?document.getElementById('webhook_url').value.trim():null
state.email_to = selectedChannels.has('email')?document.getElementById('email_to').value.trim():null
// Populate test step
const wWrap=document.getElementById('test-webhook-wrap')
const eWrap=document.getElementById('test-email-wrap')
if(state.webhook_url){
wWrap.style.display='block'
document.getElementById('test-webhook-url-disp').textContent=state.webhook_url
} else { wWrap.style.display='none' }
if(state.email_to){
eWrap.style.display='block'
document.getElementById('test-email-to-disp').textContent=state.email_to
} else { eWrap.style.display='none' }
setStep(2)
}
}
function toggleChannel(key){
const card=document.getElementById('ch-'+key)
if(selectedChannels.has(key)){selectedChannels.delete(key);card.classList.remove('selected')}
else{selectedChannels.add(key);card.classList.add('selected')}
}
async function testChannel(type){
const resultEl=document.getElementById('test-'+type+'-result')
const btn=document.getElementById('test-'+(type==='webhook'?'wh':'em')+'-btn')
btn.disabled=true;btn.textContent='Sender...'
resultEl.className='test-result';resultEl.textContent=''
try{
let body, url
if(type==='webhook'){
url='/notify/webhook'
body=JSON.stringify({
url: state.webhook_url,
event: 'custom',
title: 'CostGuard — Testmelding',
body: `Hei fra CostGuard! Webhook-integrasjon for ${state.company} (${state.gcp_project}) er koblet opp og fungerer.`,
payload: {bedrift:state.company, tier:TIERS[state.tier]||state.tier, prosjekt:state.gcp_project}
})
} else {
url='/notify/email'
body=JSON.stringify({
to: state.email_to,
event: 'custom',
subject: `✅ CostGuard er koblet opp — ${state.company}`,
body_html: `<div style='font-family:sans-serif;max-width:600px;margin:0 auto;padding:32px'><h2>CostGuard er koblet opp ✅</h2><p>Hei ${state.contact},</p><p>CostGuard overvåker nå <strong>${state.gcp_project}</strong>. Tier: <strong>${TIERS[state.tier]||state.tier}</strong>.</p><p>Du mottar første rapport innen 24 timer.</p><hr><p style='font-size:12px;color:#999'>CostGuard by Vauco AS</p></div>`
})
}
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body})
const data=await r.json()
if(r.ok&&data.status==='ok'){
resultEl.className='test-result ok show'
resultEl.textContent='✅ Testmelding sendt!'
} else {
resultEl.className='test-result err show'
resultEl.textContent='❌ Feil: '+(data.detail||data.error||r.status)
}
}catch(e){
resultEl.className='test-result err show'
resultEl.textContent='❌ Nettverksfeil: '+e.message
}finally{
btn.disabled=false
btn.textContent=type==='webhook'?'Send testmelding til webhook':'Send test-e-post'
}
}
async function activate(){
const btn=document.getElementById('activate-btn')
btn.disabled=true
btn.innerHTML='<div class="spinner"></div> Aktiverer...'
try{
const payload={
company: state.company,
contact: state.contact,
email: state.email,
gcp_project:state.gcp_project,
tier: state.tier,
channels: [...selectedChannels],
webhook_url:state.webhook_url||null,
email_to: state.email_to||null,
}
const r=await fetch('/onboard/complete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
const data=await r.json()
if(!r.ok) throw new Error(data.detail||data.error||r.status)
// Populate done screen
document.getElementById('done-project').textContent=state.gcp_project
document.getElementById('done-company').textContent=state.company
document.getElementById('done-contact').textContent=state.contact+' ('+state.email+')'
document.getElementById('done-gcp').textContent=state.gcp_project
document.getElementById('done-tier').textContent=TIERS[state.tier]||state.tier
document.getElementById('done-channels').textContent=[...selectedChannels].join(' + ')||'—'
setStep(3)
}catch(e){
toast('❌ Aktivering feilet: '+e.message)
btn.disabled=false
btn.textContent='Aktiver CostGuard ✓'
}
}
</script>
</body>
</html>