diff --git a/services/ui/routes.ts b/services/ui/routes.ts index 3c5f56f..3f0d8c2 100644 --- a/services/ui/routes.ts +++ b/services/ui/routes.ts @@ -112,6 +112,26 @@ export function createRoutes(): Hono { } }); + // --- Credit top-up proxy (forwards to vers-landing) --- + + routes.post("/ui/api/topup-credits", async (c) => { + try { + const body = await c.req.arrayBuffer(); + const resp = await fetch("https://vers.sh/api/reef/topup-credits", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body, + }); + const text = await resp.text(); + return c.body(text, resp.status as any, { "Content-Type": "application/json" }); + } catch (e) { + return c.json({ error: "Top-up proxy error", details: String(e) }, 502); + } + }); + // --- API proxy (injects bearer token so browser never needs it) --- routes.all("/ui/api/*", async (c) => { diff --git a/services/ui/static/app.js b/services/ui/static/app.js index 516298d..3cd5a51 100644 --- a/services/ui/static/app.js +++ b/services/ui/static/app.js @@ -528,6 +528,67 @@ function finishConversation(conversationId, fallbackSummary) { addConversationMessage(conversationId, 'assistant', fallbackSummary); } conversation.working = false; + + // Show credit top-up widget when credits are exhausted + const text = (conversation.messages.length > 0 && conversation.messages[conversation.messages.length - 1]?.dataset?.rawContent) || fallbackSummary || ''; + if (text && text.includes('No credits available')) { + showCreditTopup(conversationId); + } +} + +function showCreditTopup(conversationId) { + const el = document.createElement('div'); + el.className = 'credit-topup'; + el.innerHTML = ` +
Credits exhausted
+
+ Add credits to continue: +
+ + + + +
+
+
+ `; + el.querySelectorAll('.credit-btn').forEach((btn) => { + btn.addEventListener('click', () => handleCreditTopup(el, Number(btn.dataset.cents), conversationId)); + }); + const messagesEl = $('branch-messages'); + if (messagesEl) { + messagesEl.appendChild(el); + autoScroll($('branch-scroll')); + } +} + +async function handleCreditTopup(widget, amountCents, conversationId) { + const statusEl = widget.querySelector('.credit-topup-status'); + const buttons = widget.querySelectorAll('.credit-btn'); + buttons.forEach((b) => { b.disabled = true; }); + statusEl.textContent = 'Charging card...'; + statusEl.className = 'credit-topup-status'; + + try { + const resp = await fetch(`${API}/topup-credits`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amountCents }), + }); + const data = await resp.json(); + if (!resp.ok) { + statusEl.textContent = data.error || 'Top-up failed'; + statusEl.className = 'credit-topup-status error'; + buttons.forEach((b) => { b.disabled = false; }); + return; + } + statusEl.textContent = `Added $${(amountCents / 100).toFixed(2)} in credits. You can continue working.`; + statusEl.className = 'credit-topup-status success'; + } catch (err) { + statusEl.textContent = 'Network error — try again'; + statusEl.className = 'credit-topup-status error'; + buttons.forEach((b) => { b.disabled = false; }); + } } async function setConversationClosed(conversationId, closed) { @@ -1688,6 +1749,62 @@ function syncViewportMode() { if (mobileMq.addEventListener) mobileMq.addEventListener('change', syncViewportMode); else if (mobileMq.addListener) mobileMq.addListener(syncViewportMode); +// ============================================================================= +// Credits menu (header) +// ============================================================================= + +(function initCreditsMenu() { + const trigger = $('credits-trigger'); + const dropdown = $('credits-dropdown'); + if (!trigger || !dropdown) return; + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.classList.toggle('open'); + }); + + document.addEventListener('click', () => dropdown.classList.remove('open')); + dropdown.addEventListener('click', (e) => e.stopPropagation()); + + dropdown.querySelectorAll('.credit-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const cents = Number(btn.dataset.cents); + const statusEl = $('credits-dropdown-status'); + const buttons = dropdown.querySelectorAll('.credit-btn'); + buttons.forEach((b) => { b.disabled = true; }); + statusEl.textContent = 'Charging card...'; + statusEl.className = 'credits-dropdown-status'; + + fetch(`${API}/topup-credits`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amountCents: cents }), + }) + .then((r) => r.json().then((d) => ({ ok: r.ok, data: d }))) + .then(({ ok, data }) => { + if (!ok) { + statusEl.textContent = data.error || 'Top-up failed'; + statusEl.className = 'credits-dropdown-status error'; + buttons.forEach((b) => { b.disabled = false; }); + } else { + statusEl.textContent = `Added $${(cents / 100).toFixed(2)}`; + statusEl.className = 'credits-dropdown-status success'; + setTimeout(() => { + dropdown.classList.remove('open'); + statusEl.textContent = ''; + buttons.forEach((b) => { b.disabled = false; }); + }, 2000); + } + }) + .catch(() => { + statusEl.textContent = 'Network error'; + statusEl.className = 'credits-dropdown-status error'; + buttons.forEach((b) => { b.disabled = false; }); + }); + }); + }); +})(); + // ============================================================================= // Init // ============================================================================= diff --git a/services/ui/static/index.html b/services/ui/static/index.html index 1524f1a..54b7501 100644 --- a/services/ui/static/index.html +++ b/services/ui/static/index.html @@ -18,6 +18,19 @@

▸ reef

connecting +
+ +
+
Add LLM credits
+
+ + + + +
+
+
+
diff --git a/services/ui/static/style.css b/services/ui/static/style.css index acef77d..b96c6fa 100644 --- a/services/ui/static/style.css +++ b/services/ui/static/style.css @@ -1043,3 +1043,73 @@ header h1 { font-size: 10px; } } + +/* Credits menu (header) */ +.credits-menu { position: relative; margin-left: 8px; } +.credits-trigger { + padding: 3px 10px; + background: transparent; + border: 1px solid var(--accent-dim); + border-radius: 4px; + color: var(--accent); + font-family: var(--font); + font-size: 11px; + cursor: pointer; + transition: background 0.15s; +} +.credits-trigger:hover { background: var(--accent-dim); color: var(--bg); } +.credits-dropdown { + display: none; + position: absolute; + right: 0; top: calc(100% + 6px); + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 8px; + padding: 12px 14px; + min-width: 220px; + z-index: 100; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); +} +.credits-dropdown.open { display: block; } +.credits-dropdown-title { color: var(--text-bright); font-weight: 600; margin-bottom: 8px; font-size: 12px; } +.credits-dropdown-amounts { display: flex; gap: 6px; flex-wrap: wrap; } +.credits-dropdown-status { font-size: 11px; color: var(--text-dim); margin-top: 8px; min-height: 1em; } +.credits-dropdown-status.success { color: var(--accent); } +.credits-dropdown-status.error { color: var(--error); } + +/* Credit top-up widget (inline, on exhaustion) */ +.credit-topup { + margin: 12px 0; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--warn); + border-radius: 8px; +} +.credit-topup-header { + color: var(--warn); + font-weight: 600; + margin-bottom: 8px; +} +.credit-topup-body { display: flex; flex-direction: column; gap: 8px; } +.credit-topup-body span { color: var(--text-dim); } +.credit-topup-amounts { display: flex; gap: 8px; flex-wrap: wrap; } +.credit-btn { + padding: 6px 14px; + background: transparent; + border: 1px solid var(--accent-dim); + border-radius: 4px; + color: var(--accent); + font-family: var(--font); + font-size: 13px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.credit-btn:hover:not(:disabled) { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--bg); +} +.credit-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.credit-topup-status { font-size: 12px; color: var(--text-dim); min-height: 1em; } +.credit-topup-status.success { color: var(--accent); } +.credit-topup-status.error { color: var(--error); }