invoice download

This commit is contained in:
oliver
2026-06-10 16:44:33 -03:00
parent cf2702c70a
commit 8be61e7704
2 changed files with 15 additions and 943 deletions
+10 -942
View File
@@ -1238,6 +1238,7 @@ function renderInvoices(data) {
<span>Agent</span>
<span>Product</span>
<span>Price</span>
<span></span>
</div>
${data
.map(
@@ -1247,6 +1248,7 @@ function renderInvoices(data) {
<span class="invoice-uuid">${escHtml(inv.uuid || "—")}</span>
<span class="invoice-product">${escHtml(inv.product || "—")}</span>
<span class="invoice-price">&euro;&thinsp;${escHtml(String(inv.price ?? "—"))}</span>
<span class="invoice-dl">${inv.invoice_link ? `<a href="${escAttr(inv.invoice_link)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="font-size:11px;padding:3px 8px;">Download</a>` : ""}</span>
</div>`,
)
.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 = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6"/>
<path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.36-3.36L23 10M1 14l5.13 4.36A9 9 0 0 0 20.49 15"/>
</svg> Restart Agent`;
}
}
/* ═══════════════════════════════════════════════════════════════
BACKUPS — load, render, create, restore
═══════════════════════════════════════════════════════════════ */
async function loadBackups(uuid) {
const body = document.getElementById("backups-body");
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
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 = `<p class="inline-error">${escHtml(err.message)}</p>`;
}
}
function renderBackups(backups) {
const body = document.getElementById("backups-body");
if (!backups.length) {
body.innerHTML = `
<div class="backup-empty">
<svg width="30" height="30" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" style="opacity:.3">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
No backups yet. Click <strong>Make Backup</strong> to create one.
</div>`;
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 `
<div class="backup-row">
<div class="backup-info">
<div class="backup-date">${escHtml(start)}</div>
</div>
<button class="btn btn-ghost btn-sm"
data-backup-id="${id}"
data-backup-name="${nm}"
onclick="restoreBackupFromBtn(this)">
Restore
</button>
</div>`;
})
.join("");
body.innerHTML = `<div class="backups-list">${rows}</div>`;
}
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 = '<div class="loading-row"><div class="spinner"></div></div>';
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 = `<p class="inline-error" style="padding:14px 16px">${escHtml(err.message)}</p>`;
}
}
function renderContract(data) {
const body = document.getElementById("contract-body");
const headerDot = document.getElementById("contract-status-dot");
if (!data || !data.length) {
body.innerHTML =
'<p class="inline-error" style="padding:14px 16px">No contract data.</p>';
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 = `
<div class="contract-row">
<div class="contract-info">
<span class="contract-type">${escHtml(label)}</span>
</div>
</div>`;
}
/* ═══════════════════════════════════════════════════════════════
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 =
'<div class="loading-row"><div class="spinner"></div></div>';
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 = `<p class="inline-error" style="padding:10px 16px">${escHtml(err.message)}</p>`;
return;
}
renderWizardSkills();
} else if (wizardPhase === "integrations") {
body.innerHTML =
'<div class="loading-row"><div class="spinner"></div></div>';
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 = `<p class="inline-error" style="padding:10px 16px">${escHtml(err.message)}</p>`;
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 `<label class="wizard-list-item" data-name="${escAttr((s.name || "").toLowerCase())}" data-desc="${escAttr((s.description || "").toLowerCase())}">
<input type="checkbox" ${wizardSelectedSkills.has(i) ? "checked" : ""}
onchange="wizardToggleSkill(${i},this.checked)">
<span class="wizard-list-item-content">
<span class="wizard-list-item-name">${escHtml(s.name || file)}</span>
${s.description ? `<span class="wizard-list-item-desc">${escHtml(s.description)}</span>` : ""}
</span>
</label>`;
})
.join("");
document.getElementById("wizard-body").innerHTML = `
<div style="padding:12px 16px 4px">
<input class="wizard-search" type="text" placeholder="Search skills…"
oninput="wizardFilter(this,'wizard-skills-list')">
<div class="wizard-list" id="wizard-skills-list">${items}</div>
</div>`;
}
function renderWizardIntegrations() {
const items = (wizardIntegrationsData || [])
.map((file, i) => {
const s = wizardIntegrationsCache[file] || {};
return `<label class="wizard-list-item" data-name="${escAttr((s.name || "").toLowerCase())}" data-desc="${escAttr((s.description || "").toLowerCase())}">
<input type="checkbox" ${wizardSelectedIntegrations.has(i) ? "checked" : ""}
onchange="wizardToggleIntegration(${i},this.checked)">
<span class="wizard-list-item-content">
<span class="wizard-list-item-name">${escHtml(s.name || file)}</span>
${s.description ? `<span class="wizard-list-item-desc">${escHtml(s.description)}</span>` : ""}
</span>
</label>`;
})
.join("");
document.getElementById("wizard-body").innerHTML = `
<div style="padding:12px 16px 4px">
<input class="wizard-search" type="text" placeholder="Search integrations…"
oninput="wizardFilter(this,'wizard-integrations-list')">
<div class="wizard-list" id="wizard-integrations-list">${items}</div>
</div>`;
}
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"
? `<textarea placeholder="${escAttr(f.placeholder || "")}" rows="6"
oninput="wizardSetField(${wizardIntegrationStep},'${escAttr(f.key)}',this.value)">${escHtml(val)}</textarea>`
: `<input type="${escAttr(f.type || "text")}"
placeholder="${escAttr(f.placeholder || "")}"
value="${escAttr(val)}"
oninput="wizardSetField(${wizardIntegrationStep},'${escAttr(f.key)}',this.value)">`;
return `<div class="form-group"><label>${escHtml(f.label)}</label>${input}</div>`;
})
.join("");
document.getElementById("wizard-body").innerHTML = `
<div class="wizard-fields" style="padding:12px 16px 4px">
${fields}
<p class="wizard-signup-hint"><a href="${escAttr(
integration.signup_url,
)}" target="_blank" rel="noopener">${escHtml(integration.signup_label)}</a></p>
</div>`;
}
function renderWizardReview() {
const prompt = buildWizardPrompt();
document.getElementById("wizard-body").innerHTML = `
<div class="wizard-review-area" style="padding:12px 16px 4px">
<p class="wizard-review-intro">Your init prompt is ready! Copy it and paste it directly into your <strong>Hermes</strong> instance — it will automatically install and configure all selected skills and integrations for you.</p>
<textarea class="wizard-review-textarea" id="wizard-prompt-textarea" readonly>${escHtml(prompt)}</textarea>
</div>`;
}
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 = `
<div style="padding:24px 20px 8px;display:flex;flex-direction:column;align-items:center;gap:14px;text-align:center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
stroke="var(--success)" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<p style="font-size:14px;font-weight:600;color:var(--text);margin:0">
Your init prompt is in the clipboard.
</p>
<p style="font-size:13px;color:var(--text-muted);margin:0;max-width:300px;line-height:1.6">
Open your agents console and paste the prompt
(<strong style="color:var(--text)">Ctrl&nbsp;+&nbsp;V</strong> or
<strong style="color:var(--text)">Cmd&nbsp;+&nbsp;V</strong>)
to apply the configuration.
</p>
</div>`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escAttr(s) {
return escHtml(s).replace(/'/g, "&#39;");
}
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 = `<div class="spinner-sm"></div> ${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)";
}