invoice download
This commit is contained in:
@@ -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">€ ${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 agent’s console and paste the prompt
|
||||
(<strong style="color:var(--text)">Ctrl + V</strong> or
|
||||
<strong style="color:var(--text)">Cmd + 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, "&")
|
||||
.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 = `<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)";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user