diff --git a/app.js b/app.js index dd92abb..987bd9f 100644 --- a/app.js +++ b/app.js @@ -1238,6 +1238,7 @@ function renderInvoices(data) { Agent Product Price + ${data .map( @@ -1247,6 +1248,7 @@ function renderInvoices(data) { ${escHtml(inv.uuid || "—")} ${escHtml(inv.product || "—")} € ${escHtml(String(inv.price ?? "—"))} + ${inv.invoice_link ? `Download` : ""} `, ) .join("")} @@ -1270,12 +1272,16 @@ function exportInvoicesCSV() { toast("No invoices to export.", "warning"); return; } - const rows = ["Date,Agent,Product,Price"]; + const rows = ["Date,Agent,Product,Price,Invoice Link"]; for (const inv of filtered) { rows.push( - [inv.date || "", inv.uuid || "", inv.product || "", inv.price || ""].join( - ",", - ), + [ + inv.date || "", + inv.uuid || "", + inv.product || "", + inv.price || "", + inv.invoice_link || "", + ].join(","), ); } const blob = new Blob([rows.join("\n")], { type: "text/csv" }); @@ -1288,941 +1294,3 @@ function exportInvoicesCSV() { document.body.removeChild(a); URL.revokeObjectURL(url); } - -/* ═══════════════════════════════════════════════════════════════ - RESTART -═══════════════════════════════════════════════════════════════ */ -async function doRestart() { - const ok = await confirmDialog( - "Restart Agent", - "Your agent will be unavailable for a few minutes while it restarts. This will interrupt any active sessions. Continue?", - ); - if (!ok) return; - - const btn = document.getElementById("restart-btn"); - btnLoad(btn, "Restarting\u2026"); - - try { - dbg("→ Restart Agent", RESTART_URL); - const res = await apiFetch(RESTART_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - uuid: activeUUID, - email: currentEmail, - action: "restart", - }), - }); - if (!res.ok) - throw new Error(`Restart request failed (HTTP ${res.status}).`); - toast( - "Restart initiated \u2014 your agent will be back shortly.", - "success", - ); - } catch (err) { - toast(err.message, "error"); - } finally { - // Rebuild button with its SVG icon - btn.disabled = false; - btn.innerHTML = ` - - - - Restart Agent`; - } -} - -/* ═══════════════════════════════════════════════════════════════ - BACKUPS — load, render, create, restore -═══════════════════════════════════════════════════════════════ */ -async function loadBackups(uuid) { - const body = document.getElementById("backups-body"); - body.innerHTML = '
'; - - try { - const _backupsUrl = `${BACKUP_URL}?uuid=${encodeURIComponent(uuid)}`; - dbg("→ List Backups", _backupsUrl); - const res = await apiFetch(_backupsUrl); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - let data = []; - try { - data = await res.json(); - } catch (_) {} - dbg("← List Backups", data); - // Response: { uuid, backup_path, archives: [{name, start, end, id}] } - const archives = - data && Array.isArray(data.archives) - ? data.archives - : Array.isArray(data) - ? data - : []; - renderBackups(archives); - } catch (err) { - body.innerHTML = `

${escHtml(err.message)}

`; - } -} - -function renderBackups(backups) { - const body = document.getElementById("backups-body"); - - if (!backups.length) { - body.innerHTML = ` -
- - - - - - No backups yet. Click Make Backup to create one. -
`; - return; - } - - const rows = [...backups] - .sort((a, b) => { - const da = new Date( - a.start || a.created_at || a.createdAt || a.date || a.timestamp || 0, - ); - const db = new Date( - b.start || b.created_at || b.createdAt || b.date || b.timestamp || 0, - ); - return db - da; - }) - .map((b) => { - const start = formatBackupDate(b.start); - const id = escAttr(String(b.id || b.name || "")); - const nm = escAttr(String(b.name || b.id || start)); - return ` -
-
-
${escHtml(start)}
-
- -
`; - }) - .join(""); - - body.innerHTML = `
${rows}
`; -} - -async function makeBackup() { - const btn = document.getElementById("make-backup-btn"); - btnLoad(btn, "…"); - try { - dbg("→ Make Backup", BACKUP_URL); - const res = await apiFetch(BACKUP_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ uuid: activeUUID }), - }); - if (!res.ok) - throw new Error(`Failed to create backup (HTTP ${res.status}).`); - toast("Backup created successfully!", "success"); - } catch (err) { - toast(err.message, "error"); - } finally { - btnReset(btn); - await loadBackups(activeUUID); - } -} - -function restoreBackupFromBtn(btn) { - restoreBackup({ id: btn.dataset.backupId, name: btn.dataset.backupName }); -} - -async function restoreBackup(backup) { - const ok = await confirmDialog( - "Restore Backup", - `Restore "${backup.name}"? Your server data will be replaced with this backup and the agent will restart briefly.`, - ); - if (!ok) return; - - try { - dbg("→ Restore Backup", BACKUP_URL); - const res = await apiFetch(BACKUP_URL, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - uuid: activeUUID, - email: currentEmail, - action: "restore", - archive_name: backup.name, - }), - }); - if (!res.ok) throw new Error(`Restore failed (HTTP ${res.status}).`); - toast( - "Restore initiated \u2014 your agent will be back shortly.", - "success", - ); - } catch (err) { - toast(err.message, "error"); - } -} - -/* ═══════════════════════════════════════════════════════════════ - CONTRACT -═══════════════════════════════════════════════════════════════ */ -async function loadContract(uuid) { - const body = document.getElementById("contract-body"); - body.innerHTML = '
'; - document.getElementById("contract-status-dot").style.visibility = "hidden"; - try { - const url = `${CONTRACT_URL}?uuid=${encodeURIComponent(uuid)}`; - dbg("\u2192 Contract", url); - const res = await apiFetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - dbg("\u2190 Contract", data); - renderContract(Array.isArray(data) ? data : [data]); - } catch (err) { - body.innerHTML = `

${escHtml(err.message)}

`; - } -} - -function renderContract(data) { - const body = document.getElementById("contract-body"); - const headerDot = document.getElementById("contract-status-dot"); - - if (!data || !data.length) { - body.innerHTML = - '

No contract data.

'; - return; - } - - headerDot.className = "contract-dot contract-dot--green"; - headerDot.style.visibility = "visible"; - - const item = data[0]; - const product = stripQuotes(item.product || ""); - const period = stripQuotes(item.period || ""); - const type = stripQuotes(item.type || ""); - - const label = - product && period - ? `${product} \u2013 ${period}` - : product || period || type || "—"; - - body.innerHTML = ` -
-
- ${escHtml(label)} -
-
`; -} - -/* ═══════════════════════════════════════════════════════════════ - WIZARD (Integrations setup) -═══════════════════════════════════════════════════════════════ */ -function loadWizard(info) { - // Check both key casings; strip n8n's extra surrounding quotes before comparing - const rawVal = info.wizzard ?? info.WIZZARD; - const strVal = String(rawVal ?? "") - .replace(/^"|"$/g, "") - .trim() - .toLowerCase(); - const disabled = rawVal === false || strVal === "false"; - - const tb = document.getElementById("wizard-toggle-btn"); - if (disabled) { - hide("wizard-card"); - if (tb) tb.textContent = "Integrations"; - return; - } - // Reset selection state each time (JSON data cached across calls) - wizardPhase = "skills"; - wizardSelectedSkills = new Set(); - wizardSelectedIntegrations = new Set(); - wizardFieldValues = {}; - wizardIntegrationStep = 0; - wizardSelectedIntegrationList = []; - show("wizard-card"); - if (tb) tb.textContent = "Backup"; - renderWizardStep(); -} - -async function renderWizardStep() { - const body = document.getElementById("wizard-body"); - - if (wizardPhase === "skills") { - body.innerHTML = - '
'; - try { - if (!wizardSkillsData) { - const res = await fetch("skills/index.json"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - wizardSkillsData = await res.json(); - } - await Promise.all( - wizardSkillsData.map(async (file) => { - if (!wizardSkillsCache[file]) { - const res = await fetch(`skills/${file}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - wizardSkillsCache[file] = await res.json(); - } - }), - ); - } catch (err) { - body.innerHTML = `

${escHtml(err.message)}

`; - return; - } - renderWizardSkills(); - } else if (wizardPhase === "integrations") { - body.innerHTML = - '
'; - try { - if (!wizardIntegrationsData) { - const res = await fetch("integrations/index.json"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - wizardIntegrationsData = await res.json(); - } - await Promise.all( - wizardIntegrationsData.map(async (file) => { - if (!wizardIntegrationsCache[file]) { - const res = await fetch(`integrations/${file}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - wizardIntegrationsCache[file] = await res.json(); - } - }), - ); - } catch (err) { - body.innerHTML = `

${escHtml(err.message)}

`; - return; - } - renderWizardIntegrations(); - } else if (wizardPhase === "fields") { - renderWizardFields(); - } else if (wizardPhase === "review") { - renderWizardReview(); - } - - updateWizardNav(); -} - -function renderWizardSkills() { - const items = (wizardSkillsData || []) - .map((file, i) => { - const s = wizardSkillsCache[file] || {}; - return ``; - }) - .join(""); - - document.getElementById("wizard-body").innerHTML = ` -
- -
${items}
-
`; -} - -function renderWizardIntegrations() { - const items = (wizardIntegrationsData || []) - .map((file, i) => { - const s = wizardIntegrationsCache[file] || {}; - return ``; - }) - .join(""); - - document.getElementById("wizard-body").innerHTML = ` -
- -
${items}
-
`; -} - -function renderWizardFields() { - const file = wizardSelectedIntegrationList[wizardIntegrationStep]; - const integration = file ? wizardIntegrationsCache[file] : null; - if (!integration) { - wizardPhase = "review"; - renderWizardStep(); - return; - } - const saved = wizardFieldValues[wizardIntegrationStep] || {}; - const fields = integration.fields - .map((f) => { - const val = saved[f.key] || ""; - const input = - f.type === "textarea" - ? `` - : ``; - return `
${input}
`; - }) - .join(""); - - document.getElementById("wizard-body").innerHTML = ` -
- ${fields} -

${escHtml(integration.signup_label)}

-
`; -} - -function renderWizardReview() { - const prompt = buildWizardPrompt(); - document.getElementById("wizard-body").innerHTML = ` -
-

Your init prompt is ready! Copy it and paste it directly into your Hermes instance — it will automatically install and configure all selected skills and integrations for you.

- -
`; -} - -function buildWizardPrompt() { - const parts = []; - - const skillLines = []; - for (const i of wizardSelectedSkills) { - const file = wizardSkillsData && wizardSkillsData[i]; - const skill = file && wizardSkillsCache[file]; - if (skill && skill.prompt) skillLines.push(skill.prompt); - } - if (skillLines.length) parts.push("## Skills\n\n" + skillLines.join("\n\n")); - - const intLines = []; - for ( - let stepIdx = 0; - stepIdx < wizardSelectedIntegrationList.length; - stepIdx++ - ) { - const file = wizardSelectedIntegrationList[stepIdx]; - const integration = wizardIntegrationsCache[file]; - if (!integration) continue; - const values = wizardFieldValues[stepIdx] || {}; - let prompt = integration.prompt || ""; - for (const [k, v] of Object.entries(values)) { - prompt = prompt.split(`{${k}}`).join(v || `{${k}}`); - } - intLines.push(`### ${integration.name}\n${prompt}`); - } - if (intLines.length) - parts.push("## Integrations\n\n" + intLines.join("\n\n")); - - return parts.join("\n\n---\n\n") || "(No skills or integrations selected.)"; -} - -function updateWizardNav() { - const backBtn = document.getElementById("wizard-back-btn"); - const nextBtn = document.getElementById("wizard-next-btn"); - const cardTitle = document.getElementById("wizard-card-title"); - - let label = ""; - if (wizardPhase === "skills") label = "Select Skills"; - else if (wizardPhase === "integrations") label = "Select Integrations"; - else if (wizardPhase === "fields") { - const file = wizardSelectedIntegrationList[wizardIntegrationStep]; - const int = file ? wizardIntegrationsCache[file] : null; - label = int ? int.name : ""; - } else if (wizardPhase === "review") label = "Review Init Prompt"; - if (cardTitle) cardTitle.textContent = label; - - if (backBtn) { - backBtn.style.display = wizardPhase === "skills" ? "none" : ""; - backBtn.onclick = wizardBack; - } - if (nextBtn) { - nextBtn.textContent = - wizardPhase === "review" ? "Copy & Finish" : "Next \u2192"; - nextBtn.onclick = wizardNext; - } -} - -function wizardToggleSkill(index, checked) { - if (checked) wizardSelectedSkills.add(index); - else wizardSelectedSkills.delete(index); -} - -function wizardToggleIntegration(index, checked) { - if (checked) wizardSelectedIntegrations.add(index); - else wizardSelectedIntegrations.delete(index); -} - -function wizardSetField(stepKey, fieldKey, value) { - if (!wizardFieldValues[stepKey]) wizardFieldValues[stepKey] = {}; - wizardFieldValues[stepKey][fieldKey] = value; -} - -function wizardFilter(input, listId) { - const q = input.value.toLowerCase().trim(); - const list = document.getElementById(listId); - if (!list) return; - list.querySelectorAll(".wizard-list-item").forEach((item) => { - const name = (item.dataset.name || "").toLowerCase(); - const desc = (item.dataset.desc || "").toLowerCase(); - item.style.display = - !q || name.includes(q) || desc.includes(q) ? "" : "none"; - }); -} - -function wizardNext() { - if (wizardPhase === "skills") { - wizardPhase = "integrations"; - renderWizardStep(); - } else if (wizardPhase === "integrations") { - wizardSelectedIntegrationList = (wizardIntegrationsData || []).filter( - (_, i) => wizardSelectedIntegrations.has(i), - ); - wizardIntegrationStep = 0; - wizardPhase = wizardSelectedIntegrationList.length ? "fields" : "review"; - renderWizardStep(); - } else if (wizardPhase === "fields") { - wizardIntegrationStep++; - if (wizardIntegrationStep >= wizardSelectedIntegrationList.length) { - wizardPhase = "review"; - } - renderWizardStep(); - } else if (wizardPhase === "review") { - wizardCopyAndFinish(); - } -} - -function wizardBack() { - if (wizardPhase === "integrations") { - wizardPhase = "skills"; - renderWizardStep(); - } else if (wizardPhase === "fields") { - if (wizardIntegrationStep > 0) { - wizardIntegrationStep--; - } else { - wizardPhase = "integrations"; - } - renderWizardStep(); - } else if (wizardPhase === "review") { - if (wizardSelectedIntegrationList.length > 0) { - wizardPhase = "fields"; - wizardIntegrationStep = wizardSelectedIntegrationList.length - 1; - } else { - wizardPhase = "integrations"; - } - renderWizardStep(); - } -} - -async function wizardCopyAndFinish() { - const textarea = document.getElementById("wizard-prompt-textarea"); - if (!textarea) return; - - let copied = false; - try { - await navigator.clipboard.writeText(textarea.value); - copied = true; - } catch (_) { - toast("Could not copy to clipboard — please copy manually.", "error"); - return; - } - - // Mark wizard complete via the agent-info endpoint (non-fatal) - try { - await apiFetch(AGENT_INFO_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - uuid: activeUUID, - key: "WIZZARD", - value: "false", - }), - }); - } catch (_) { - /* non-fatal */ - } - - // Show success screen inside the wizard instead of closing immediately - const cardTitle = document.getElementById("wizard-card-title"); - if (cardTitle) cardTitle.textContent = "Prompt Copied"; - - document.getElementById("wizard-body").innerHTML = ` -
- - - - -

- Your init prompt is in the clipboard. -

-

- Open your agent’s console and paste the prompt - (Ctrl + V or - Cmd + V) - to apply the configuration. -

-
`; - - const backBtn = document.getElementById("wizard-back-btn"); - const nextBtn = document.getElementById("wizard-next-btn"); - if (backBtn) backBtn.style.display = "none"; - if (nextBtn) { - nextBtn.textContent = "Done"; - nextBtn.onclick = () => { - hide("wizard-card"); - const tb = document.getElementById("wizard-toggle-btn"); - if (tb) tb.textContent = "Integrations"; - }; - } -} - -function wizardDone() { - hide("wizard-card"); - const tb = document.getElementById("wizard-toggle-btn"); - if (tb) tb.textContent = "Integrations"; -} - -/* ═══════════════════════════════════════════════════════════════ - CONFIRM DIALOG -═══════════════════════════════════════════════════════════════ */ -let _confirmResolve = null; - -function confirmDialog(title, message, okLabel = "Confirm") { - document.getElementById("confirm-title").textContent = title; - document.getElementById("confirm-message").textContent = message; - document.getElementById("confirm-ok-btn").textContent = okLabel; - document.getElementById("confirm-backdrop").classList.add("open"); - return new Promise((resolve) => { - _confirmResolve = resolve; - }); -} - -function confirmClose(result) { - document.getElementById("confirm-backdrop").classList.remove("open"); - if (_confirmResolve) { - _confirmResolve(result); - _confirmResolve = null; - } -} - -/* ═══════════════════════════════════════════════════════════════ - TOAST -═══════════════════════════════════════════════════════════════ */ -function toast(msg, type = "info", duration = 4000) { - const el = document.createElement("div"); - el.className = `toast toast-${type}`; - el.textContent = msg; - document.getElementById("toast-container").appendChild(el); - setTimeout(() => { - el.style.opacity = "0"; - el.style.transform = "translateX(50px)"; - setTimeout(() => el.remove(), 320); - }, duration); -} - -/* ═══════════════════════════════════════════════════════════════ - UTILITIES -═══════════════════════════════════════════════════════════════ */ -/* ─── Cookie helpers ───────────────────────────────────────── */ -function setCookie(name, value, days) { - const expires = new Date(Date.now() + days * 864e5).toUTCString(); - document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Strict`; -} -function getCookie(name) { - return document.cookie.split("; ").reduce((acc, c) => { - const [k, ...rest] = c.split("="); - return k === name ? decodeURIComponent(rest.join("=")) : acc; - }, null); -} -function deleteCookie(name) { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Strict`; -} - -/* ─── Authenticated fetch ───────────────────────────────────── */ -// Wraps fetch() for all webhook calls — injects session header when logged in. -async function apiFetch(url, options = {}) { - const headers = { - ...(options.headers || {}), - ...(currentSession ? { "X-Session-Id": currentSession } : {}), - }; - const res = await fetch(url, { ...options, headers }); - if (res.status === 401) { - deleteCookie("al_session"); - deleteCookie("al_email"); - localStorage.removeItem("al_email"); - location.reload(); - } - return res; -} - -function escHtml(s) { - if (s == null) return ""; - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function escAttr(s) { - return escHtml(s).replace(/'/g, "'"); -} - -function isValidEmail(s) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); -} - -// Format backup start "2026-06-01T17:55:07.000000" → "2026-06-01 17:55" -function formatBackupDate(s) { - if (!s) return "—"; - // Slice to minute precision and swap T for a space - const safe = String(s).slice(0, 16).replace("T", " "); - return safe.length >= 16 ? safe : "—"; -} - -function formatDate(d) { - if (!d) return "\u2014"; - try { - return new Date(d).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - } catch (_) { - return String(d); - } -} - -function show(id) { - document.getElementById(id).style.display = ""; -} -function hide(id) { - document.getElementById(id).style.display = "none"; -} - -/* Button loading helpers */ -function btnLoad(btn, label) { - btn.disabled = true; - btn._origHTML = btn.innerHTML; - btn.innerHTML = `
${label}`; -} - -function btnReset(btn) { - btn.disabled = false; - btn.innerHTML = btn._origHTML || ""; -} - -/* Auth error helpers */ -function showAuthError(id, msg) { - const el = document.getElementById(id); - el.textContent = msg; - el.style.display = "flex"; -} - -function hideAuthError(id) { - document.getElementById(id).style.display = "none"; - if (id === "signin-error") { - const wrap = document.getElementById("reset-pw-wrap"); - if (wrap) wrap.style.display = "none"; - } -} - -function showResetOption() { - const wrap = document.getElementById("reset-pw-wrap"); - if (!wrap) return; - // Return to idle state in case it was previously used - document.getElementById("reset-pw-idle").style.display = "flex"; - document.getElementById("reset-pw-sent").style.display = "none"; - wrap.style.display = "block"; -} - -async function sendPasswordReset() { - const email = document.getElementById("signin-email").value.trim(); - if (!email) { - toast("Enter your email address above first.", "warning"); - return; - } - const btn = document.getElementById("reset-pw-btn"); - btnLoad(btn, "Sending…"); - try { - await fetch(PW_RESET_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - } catch (_) { - // Always show sent — never reveal whether the address exists - } finally { - // Hide the red error panel — it's no longer relevant once reset is requested - document.getElementById("signin-error").style.display = "none"; - document.getElementById("reset-pw-idle").style.display = "none"; - document.getElementById("reset-pw-sent").style.display = "flex"; - } -} - -/* ═══════════════════════════════════════════════════════════════ - TALK TO US -═══════════════════════════════════════════════════════════════ */ -function toggleTalk() { - talkOpen = !talkOpen; - document.getElementById("talk-panel").classList.toggle("open", talkOpen); - document - .getElementById("talk-toggle-btn") - .classList.toggle("active", talkOpen); - const chev = document.getElementById("talk-chevron"); - chev.style.transform = talkOpen ? "rotate(180deg)" : ""; - - if (talkOpen) { - if (!chatId) { - chatId = String(Math.floor(100000 + Math.random() * 900000)); - appendChatMsg("bot", "Hi! How can we help you today?"); - } - document.getElementById("refer-own-email").textContent = currentEmail || ""; - setTimeout(() => document.getElementById("chat-input").focus(), 280); - } -} - -function appendChatMsg(from, text) { - const msgs = document.getElementById("chat-msgs"); - const div = document.createElement("div"); - div.className = `chat-msg chat-msg-${from}`; - div.textContent = text; - msgs.appendChild(div); - msgs.scrollTop = msgs.scrollHeight; -} - -function handleChatKey(e) { - if (e.key === "Enter") { - e.preventDefault(); - sendChat(); - } -} - -async function sendChat() { - const input = document.getElementById("chat-input"); - const btn = document.getElementById("chat-send-btn"); - const msg = input.value.trim(); - if (!msg) return; - - appendChatMsg("user", msg); - input.value = ""; - btnLoad(btn, "…"); - - try { - dbg("→ Chat", CHAT_URL); - const res = await apiFetch(CHAT_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: msg, - sessionId: chatId, - email: currentEmail, - }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - dbg("← Chat", json); - const first = Array.isArray(json) ? json[0] : json; - const reply = - typeof json === "string" - ? json - : (first && - (first.output || - first.message || - first.reply || - first.response || - first.text)) || - JSON.stringify(json); - appendChatMsg("bot", reply); - } catch (err) { - appendChatMsg("bot", `Sorry, something went wrong (${err.message}).`); - } finally { - btnReset(btn); - document.getElementById("chat-input").focus(); - } -} - -async function sendReferral() { - const nameInput = document.getElementById("name"); - const input = document.getElementById("refer-email"); - const btn = document.getElementById("refer-send-btn"); - const name = nameInput.value.trim(); - const email = input.value.trim(); - if (!name) { - toast("Please enter your friend\u2019s name.", "warning"); - return; - } - if (!email) { - toast("Please enter your friend\u2019s email.", "warning"); - return; - } - if (!isValidEmail(email)) { - toast("Please enter a valid email address.", "warning"); - return; - } - - btnLoad(btn, "Sending\u2026"); - try { - dbg("\u2192 Referral", REFERRAL_URL); - const res = await apiFetch(REFERRAL_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - referrer: currentEmail, - friend_name: name, - friend_email: email, - uuid: activeUUID, - }), - }); - if (!res.ok) throw new Error(`Failed to send invite (HTTP ${res.status}).`); - const json = await res.json().catch(() => null); - dbg("\u2190 Referral", json); - const first = Array.isArray(json) ? json[0] : json; - const result = first && first.result; - const success = - typeof result === "string" && - result.toLowerCase().includes("invite send"); - showReferralResult( - success ? result : "This email address has already been claimed.", - success, - ); - if (success) { - input.value = ""; - nameInput.value = ""; - } - } catch (err) { - toast(err.message, "error"); - } finally { - btnReset(btn); - } -} - -function showReferralResult(text, success = true) { - let el = document.getElementById("refer-result"); - if (!el) { - el = document.createElement("p"); - el.id = "refer-result"; - el.className = "refer-result"; - document - .getElementById("refer-send-btn") - .closest(".refer-input-row") - .insertAdjacentElement("afterend", el); - } - el.textContent = text; - el.style.color = success ? "var(--success)" : "var(--warning)"; -} diff --git a/styles.css b/styles.css index 035b9e1..c612919 100644 --- a/styles.css +++ b/styles.css @@ -1585,7 +1585,7 @@ body::after { .invoice-row { display: grid; - grid-template-columns: 100px 1fr 1fr 80px; + grid-template-columns: 100px 1fr 1fr 80px 80px; gap: 10px; align-items: center; padding: 7px 10px; @@ -1641,6 +1641,10 @@ body::after { white-space: nowrap; } +.invoice-dl { + text-align: right; +} + /* ─── Credits Modal ────────────────────────────────────────── */ .credits-key-label { font-size: 12px;