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 = `
+
+
+
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
+
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); }