Compare commits
46 Commits
2d5e8e12e9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3649cedabe | |||
| 15dda4632f | |||
| 9ddfc00aa0 | |||
| 901f43e497 | |||
| 09663e3dc8 | |||
| b6f1d7e15d | |||
| 07fc9f9050 | |||
| 365c69871e | |||
| ad8e17e163 | |||
| 361ee5b49d | |||
| b692134c58 | |||
| f34ab02477 | |||
| d61c746818 | |||
| 5309db7e12 | |||
| 19b2edccf1 | |||
| a270f60311 | |||
| f9c8f8fd8c | |||
| 4042287957 | |||
| ad5d33e8e1 | |||
| 6dcfd6f17a | |||
| 592505ac21 | |||
| ea38cf998a | |||
| 701f4e8e3e | |||
| 317f900d52 | |||
| 24c4421ba7 | |||
| 8c0c5a9df7 | |||
| 0f7cdbc432 | |||
| ce1980c310 | |||
| 65fdea1e5f | |||
| 076763db63 | |||
| 45d24c394f | |||
| dac70c812e | |||
| 626a244d0c | |||
| bb830a847f | |||
| df7b879b1a | |||
| d6e42d9b7d | |||
| cf2702c70a | |||
| b7381949c6 | |||
| 5c881d0c93 | |||
| 5b59f6c1a8 | |||
| f26ecc8646 | |||
| 084a4003e8 | |||
| 58c564d263 | |||
| 3d3986ae2f | |||
| 5dacbfd1b0 | |||
| 024c7637a5 |
@@ -23,6 +23,8 @@ const REFERRAL_URL = `${API_BASE}/etc/referral`; // POST {referrer,friend_name,f
|
|||||||
const CREDITS_URL = `${API_BASE}/payment/credits`; // hidden form POST → 302 Stripe redirect
|
const CREDITS_URL = `${API_BASE}/payment/credits`; // hidden form POST → 302 Stripe redirect
|
||||||
const INVOICES_URL = `${API_BASE}/invoices`; // GET ?email=
|
const INVOICES_URL = `${API_BASE}/invoices`; // GET ?email=
|
||||||
const INVOICE_EMAIL_URL = `${API_BASE}/user`; // POST {email, invoice_email}
|
const INVOICE_EMAIL_URL = `${API_BASE}/user`; // POST {email, invoice_email}
|
||||||
|
const PORTAL_URL = `${API_BASE}/account/portal`; // GET ?email= → { url }
|
||||||
|
const AFFILIATE_LINKS_URL = `${API_BASE}/affiliate/link`; // GET → [{areas:[{area,text,link}]}]
|
||||||
const CONTRACT_EXTEND_URL = "https://derez.ai"; // ← replace with Stripe payment link
|
const CONTRACT_EXTEND_URL = "https://derez.ai"; // ← replace with Stripe payment link
|
||||||
const CONTRACT_CANCEL_URL = "https://derez.ai"; // ← replace with Stripe cancellation link
|
const CONTRACT_CANCEL_URL = "https://derez.ai"; // ← replace with Stripe cancellation link
|
||||||
|
|
||||||
@@ -46,7 +48,8 @@ let wizardSelectedSkills = new Set();
|
|||||||
let wizardSelectedIntegrations = new Set();
|
let wizardSelectedIntegrations = new Set();
|
||||||
let wizardFieldValues = {}; // { stepIndex: { fieldKey: value } }
|
let wizardFieldValues = {}; // { stepIndex: { fieldKey: value } }
|
||||||
let wizardIntegrationStep = 0; // current index in fields phase
|
let wizardIntegrationStep = 0; // current index in fields phase
|
||||||
let wizardSelectedIntegrationList = []; // ordered list of selected integration objects
|
let wizardSelectedIntegrationList = [];
|
||||||
|
let invoicesData = []; // cached full invoice list for filtering/export
|
||||||
|
|
||||||
/* ─── Debug logger ─────────────────────────────────────────── */
|
/* ─── Debug logger ─────────────────────────────────────────── */
|
||||||
function dbg(label, data) {
|
function dbg(label, data) {
|
||||||
@@ -66,6 +69,15 @@ function normalizeAgents(raw) {
|
|||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
// unwrap double-wrapped array: [[{…}]] → [{…}]
|
// unwrap double-wrapped array: [[{…}]] → [{…}]
|
||||||
if (raw.length > 0 && Array.isArray(raw[0])) return normalizeAgents(raw[0]);
|
if (raw.length > 0 && Array.isArray(raw[0])) return normalizeAgents(raw[0]);
|
||||||
|
// unwrap [{data:[…]}] envelope (agents list with nested data array)
|
||||||
|
if (
|
||||||
|
raw.length > 0 &&
|
||||||
|
raw[0] !== null &&
|
||||||
|
typeof raw[0] === "object" &&
|
||||||
|
Array.isArray(raw[0].data)
|
||||||
|
) {
|
||||||
|
return normalizeAgents(raw[0].data);
|
||||||
|
}
|
||||||
// unwrap n8n {json:{…}} envelope
|
// unwrap n8n {json:{…}} envelope
|
||||||
if (
|
if (
|
||||||
raw.length > 0 &&
|
raw.length > 0 &&
|
||||||
@@ -433,19 +445,28 @@ function populateDemoShell() {
|
|||||||
document.getElementById("user-email-label").textContent = "preview";
|
document.getElementById("user-email-label").textContent = "preview";
|
||||||
renderComment("My AI Assistant");
|
renderComment("My AI Assistant");
|
||||||
|
|
||||||
// Agent tab
|
// Agent tab (first agent — hermes — has BACKUP & SSH, second agent — bell — does not)
|
||||||
document.getElementById("agent-tabs-bar").innerHTML = `
|
document.getElementById("agent-tabs-bar").innerHTML = `
|
||||||
<button class="agent-tab active">
|
<button class="agent-tab active">
|
||||||
<span class="agent-tab-dot"></span>
|
<span class="agent-tab-dot"></span>
|
||||||
<span>hermes</span>
|
<span>hermes</span>
|
||||||
<span class="agent-tab-badge">EU</span>
|
<span class="agent-tab-badge">EU</span>
|
||||||
</button>
|
</button>
|
||||||
<a class="agent-tab-add" href="https://derez.ai/add.html" target="_blank" title="Add agent">+ Add Agent</a>`;
|
<button class="agent-tab">
|
||||||
|
<span class="agent-tab-dot agent-tab-dot--red"></span>
|
||||||
|
<span>bell</span>
|
||||||
|
<span class="agent-tab-badge">EU</span>
|
||||||
|
</button>
|
||||||
|
<a class="agent-tab-add" href="https://derez.ai/add.html?email=${encodeURIComponent(currentEmail || "")}" target="_blank" title="Add agent">+ Add Agent</a>`;
|
||||||
|
|
||||||
// Show agent panel with demo data
|
// Show agent panel with demo data
|
||||||
hide("no-agents");
|
hide("no-agents");
|
||||||
document.getElementById("agent-panel").style.display = "flex";
|
document.getElementById("agent-panel").style.display = "flex";
|
||||||
|
|
||||||
|
// Backups — hide the + button to demo a non-BACKUP agent
|
||||||
|
const makeBtn = document.getElementById("make-backup-btn");
|
||||||
|
if (makeBtn) makeBtn.style.display = "none";
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const keysDot = document.getElementById("keys-status-dot");
|
const keysDot = document.getElementById("keys-status-dot");
|
||||||
keysDot.className = "contract-dot contract-dot--green";
|
keysDot.className = "contract-dot contract-dot--green";
|
||||||
@@ -478,9 +499,9 @@ function populateDemoShell() {
|
|||||||
<span class="server-info-label">Dashboard</span>
|
<span class="server-info-label">Dashboard</span>
|
||||||
<span class="server-info-val"><a href="#" class="server-info-link" onclick="return false">hermes.derez.ai</a></span>
|
<span class="server-info-val"><a href="#" class="server-info-link" onclick="return false">hermes.derez.ai</a></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="server-info-row server-info-row--copy" title="Click to copy">
|
<div class="server-info-row">
|
||||||
<span class="server-info-label">SSH Access</span>
|
<span class="server-info-label">SSH Access</span>
|
||||||
<span class="server-info-val server-info-copyval">ssh root@hermes.derez.ai -p 2201</span>
|
<span class="server-info-val">Upgrade</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="server-info-row">
|
<div class="server-info-row">
|
||||||
<span class="server-info-label">Created</span>
|
<span class="server-info-label">Created</span>
|
||||||
@@ -489,15 +510,15 @@ function populateDemoShell() {
|
|||||||
|
|
||||||
// Contract
|
// Contract
|
||||||
const demoDot = document.getElementById("contract-status-dot");
|
const demoDot = document.getElementById("contract-status-dot");
|
||||||
demoDot.className = "contract-dot contract-dot--green";
|
demoDot.className = "contract-dot contract-dot--red";
|
||||||
demoDot.style.visibility = "visible";
|
demoDot.style.visibility = "visible";
|
||||||
document.getElementById("contract-body").innerHTML = `
|
document.getElementById("contract-body").innerHTML = `
|
||||||
<div class="contract-row">
|
<div class="contract-row">
|
||||||
<div class="contract-info">
|
<div class="contract-info">
|
||||||
<span class="contract-type">Auto - Monthly</span>
|
<span class="contract-type">Trainee \u2013 Monthly</span>
|
||||||
<span class="contract-expires">No expiration</span>
|
<span class="contract-expires">expires 2026-07-10</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" class="btn btn-ghost btn-sm" style="margin-left:auto;flex-shrink:0;" onclick="return false">Cancel</a>
|
<a class="contract-extend-link" href="#" onclick="event.preventDefault();openBillingPortal()">Resubscribe →</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Backups
|
// Backups
|
||||||
@@ -568,17 +589,22 @@ function renderAgentTabs() {
|
|||||||
.map((a) => {
|
.map((a) => {
|
||||||
const uuid = a.UUID || a.uuid || "";
|
const uuid = a.UUID || a.uuid || "";
|
||||||
const server = a.server || "";
|
const server = a.server || "";
|
||||||
|
const expires = a.expires || "";
|
||||||
|
const hasExpiry = expires && expires !== "null" && expires !== "";
|
||||||
|
const dotClass = hasExpiry
|
||||||
|
? "agent-tab-dot agent-tab-dot--red"
|
||||||
|
: "agent-tab-dot";
|
||||||
return `
|
return `
|
||||||
<button class="agent-tab${uuid === activeUUID ? " active" : ""}"
|
<button class="agent-tab${uuid === activeUUID ? " active" : ""}"
|
||||||
onclick="selectAgent('${escAttr(uuid)}')">
|
onclick="selectAgent('${escAttr(uuid)}')">
|
||||||
<span class="agent-tab-dot"></span>
|
<span class="${dotClass}"></span>
|
||||||
<span>${escHtml(uuid)}</span>
|
<span>${escHtml(uuid)}</span>
|
||||||
<span class="agent-tab-badge">${escHtml(server.toUpperCase())}</span>
|
<span class="agent-tab-badge">${escHtml(server.toUpperCase())}</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join("") +
|
.join("") +
|
||||||
`<a class="agent-tab-add" href="https://derez.ai/add.html" target="_blank" title="Add agent">+ Add Agent</a>`;
|
`<a class="agent-tab-add" href="https://derez.ai/add.html?email=${encodeURIComponent(currentEmail || "")}" target="_blank" title="Add agent">+ Add Agent</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAgent(uuid) {
|
function selectAgent(uuid) {
|
||||||
@@ -600,8 +626,7 @@ function selectAgent(uuid) {
|
|||||||
|
|
||||||
hide("wizard-card");
|
hide("wizard-card");
|
||||||
loadKeys(uuid);
|
loadKeys(uuid);
|
||||||
loadBackups(uuid);
|
loadAgentInfo(uuid).then(() => loadBackups(uuid));
|
||||||
loadAgentInfo(uuid);
|
|
||||||
loadContract(uuid);
|
loadContract(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +745,10 @@ function renderAgentInfo(info) {
|
|||||||
const port = stripQuotes(info.ssh_port);
|
const port = stripQuotes(info.ssh_port);
|
||||||
const sshCmd = `ssh root@${activeUUID}.derez.ai -p ${port}`;
|
const sshCmd = `ssh root@${activeUUID}.derez.ai -p ${port}`;
|
||||||
|
|
||||||
|
// Check SSH flag from agent info response
|
||||||
|
const sshRaw = stripQuotes(info.ssh);
|
||||||
|
const hasSSH = sshRaw !== "—" && sshRaw !== "0" && sshRaw !== "false";
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
`<div class="server-info-row">
|
`<div class="server-info-row">
|
||||||
<span class="server-info-label">Agent Type</span>
|
<span class="server-info-label">Agent Type</span>
|
||||||
@@ -733,9 +762,14 @@ function renderAgentInfo(info) {
|
|||||||
: escHtml(domain)
|
: escHtml(domain)
|
||||||
}</span>
|
}</span>
|
||||||
</div>`,
|
</div>`,
|
||||||
`<div class="server-info-row server-info-row--copy" onclick="copySSHAccess()" title="Click to copy">
|
hasSSH
|
||||||
|
? `<div class="server-info-row server-info-row--copy" onclick="copySSHAccess()" title="Click to copy">
|
||||||
<span class="server-info-label">SSH Access</span>
|
<span class="server-info-label">SSH Access</span>
|
||||||
<span class="server-info-val server-info-copyval">${escHtml(sshCmd)}</span>
|
<span class="server-info-val server-info-copyval">${escHtml(sshCmd)}</span>
|
||||||
|
</div>`
|
||||||
|
: `<div class="server-info-row">
|
||||||
|
<span class="server-info-label">SSH Access</span>
|
||||||
|
<span class="server-info-val">Upgrade</span>
|
||||||
</div>`,
|
</div>`,
|
||||||
`<div class="server-info-row">
|
`<div class="server-info-row">
|
||||||
<span class="server-info-label">Created</span>
|
<span class="server-info-label">Created</span>
|
||||||
@@ -849,7 +883,7 @@ function renderKeys(keys) {
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})
|
})
|
||||||
: "\u2014";
|
: "";
|
||||||
const statusColor = k.disabled ? "var(--danger)" : "var(--success)";
|
const statusColor = k.disabled ? "var(--danger)" : "var(--success)";
|
||||||
const statusLabel = k.disabled ? "Disabled" : "Active";
|
const statusLabel = k.disabled ? "Disabled" : "Active";
|
||||||
|
|
||||||
@@ -867,10 +901,9 @@ function renderKeys(keys) {
|
|||||||
<div class="key-stats">
|
<div class="key-stats">
|
||||||
<span>
|
<span>
|
||||||
<span class="key-stat-val">${k.limit_remaining ?? "\u2014"}</span>
|
<span class="key-stat-val">${k.limit_remaining ?? "\u2014"}</span>
|
||||||
<span class="text-muted"> / ${total} credits remaining</span>
|
<span class="text-muted"> / ${total} credits</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="sep-dot">·</span>
|
${exp ? `<span class="sep-dot">·</span><span class="text-muted">${escHtml(exp)}</span>` : ""}
|
||||||
<span class="text-muted">Expires ${exp}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -973,8 +1006,8 @@ async function savePassword() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
const ok = await confirmDialog(
|
const ok = await confirmDialog(
|
||||||
"Update Instance Password",
|
"Your Hermes & Root Password",
|
||||||
"This will immediately update the root password on your server. Make sure to save it somewhere safe. Continue?",
|
"This will immediately update the root password on your server (the dashboard user is <b>root</b>). Make sure to save it somewhere safe.\n\nNote: You will need to restart the server for the dashboard password to take effect. Continue?",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -1084,6 +1117,17 @@ function closeUserPanel() {
|
|||||||
if (codeEl) codeEl.textContent = "—";
|
if (codeEl) codeEl.textContent = "—";
|
||||||
const copyBtn = document.getElementById("acct-affiliate-copy-btn");
|
const copyBtn = document.getElementById("acct-affiliate-copy-btn");
|
||||||
if (copyBtn) copyBtn.style.display = "none";
|
if (copyBtn) copyBtn.style.display = "none";
|
||||||
|
// reset tab to settings
|
||||||
|
const settingsTab = document.getElementById("acct-tab-settings");
|
||||||
|
const affiliateTab = document.getElementById("acct-tab-affiliate");
|
||||||
|
const settingsPane = document.getElementById("acct-pane-settings");
|
||||||
|
const affiliatePane = document.getElementById("acct-pane-affiliate");
|
||||||
|
if (settingsTab && affiliateTab && settingsPane && affiliatePane) {
|
||||||
|
affiliateTab.classList.remove("active");
|
||||||
|
settingsTab.classList.add("active");
|
||||||
|
affiliatePane.style.display = "none";
|
||||||
|
settingsPane.style.display = "flex";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAccountPwSave(value) {
|
function toggleAccountPwSave(value) {
|
||||||
@@ -1169,6 +1213,38 @@ async function saveInvoiceEmail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBillingPortal() {
|
||||||
|
const btn = document.getElementById("portal-btn");
|
||||||
|
if (!currentEmail) {
|
||||||
|
toast("No email found. Please sign in again.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn) btnLoad(btn, "");
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${PORTAL_URL}?email=${encodeURIComponent(currentEmail)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
dbg("\u2190 Portal", data);
|
||||||
|
const portalUrl =
|
||||||
|
data.URL || data.url || (data.data && data.data.url) || data;
|
||||||
|
if (
|
||||||
|
portalUrl &&
|
||||||
|
typeof portalUrl === "string" &&
|
||||||
|
portalUrl.startsWith("http")
|
||||||
|
) {
|
||||||
|
window.open(portalUrl, "_blank");
|
||||||
|
} else {
|
||||||
|
throw new Error("No portal URL returned.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
if (btn) btnReset(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInvoices() {
|
async function loadInvoices() {
|
||||||
const body = document.getElementById("invoices-body");
|
const body = document.getElementById("invoices-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
@@ -1190,7 +1266,10 @@ async function loadInvoices() {
|
|||||||
} else {
|
} else {
|
||||||
data = [];
|
data = [];
|
||||||
}
|
}
|
||||||
renderInvoices(data);
|
invoicesData = data;
|
||||||
|
applyInvoiceFilter();
|
||||||
|
const filterInput = document.getElementById("invoice-filter");
|
||||||
|
if (filterInput) filterInput.value = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dbg("\u2190 Invoices error", err.message);
|
dbg("\u2190 Invoices error", err.message);
|
||||||
body.innerHTML =
|
body.innerHTML =
|
||||||
@@ -1198,6 +1277,22 @@ async function loadInvoices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyInvoiceFilter() {
|
||||||
|
const q = (
|
||||||
|
document.getElementById("invoice-filter").value || ""
|
||||||
|
).toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? invoicesData.filter(
|
||||||
|
(inv) =>
|
||||||
|
(inv.date || "").toLowerCase().includes(q) ||
|
||||||
|
(inv.uuid || "").toLowerCase().includes(q) ||
|
||||||
|
(inv.product || "").toLowerCase().includes(q) ||
|
||||||
|
(inv.price || "").toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: invoicesData;
|
||||||
|
renderInvoices(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
function renderInvoices(data) {
|
function renderInvoices(data) {
|
||||||
const body = document.getElementById("invoices-body");
|
const body = document.getElementById("invoices-body");
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
@@ -1212,6 +1307,7 @@ function renderInvoices(data) {
|
|||||||
<span>Agent</span>
|
<span>Agent</span>
|
||||||
<span>Product</span>
|
<span>Product</span>
|
||||||
<span>Price</span>
|
<span>Price</span>
|
||||||
|
<span>PDF</span>
|
||||||
</div>
|
</div>
|
||||||
${data
|
${data
|
||||||
.map(
|
.map(
|
||||||
@@ -1221,12 +1317,168 @@ function renderInvoices(data) {
|
|||||||
<span class="invoice-uuid">${escHtml(inv.uuid || "—")}</span>
|
<span class="invoice-uuid">${escHtml(inv.uuid || "—")}</span>
|
||||||
<span class="invoice-product">${escHtml(inv.product || "—")}</span>
|
<span class="invoice-product">${escHtml(inv.product || "—")}</span>
|
||||||
<span class="invoice-price">€ ${escHtml(String(inv.price ?? "—"))}</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="invoice-dl-link" title="Download invoice PDF"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : `<span class="invoice-dl-link invoice-dl-link--disabled">—</span>`}</span>
|
||||||
</div>`,
|
</div>`,
|
||||||
)
|
)
|
||||||
.join("")}
|
.join("")}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportInvoicesCSV() {
|
||||||
|
const q = (
|
||||||
|
document.getElementById("invoice-filter").value || ""
|
||||||
|
).toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? invoicesData.filter(
|
||||||
|
(inv) =>
|
||||||
|
(inv.date || "").toLowerCase().includes(q) ||
|
||||||
|
(inv.uuid || "").toLowerCase().includes(q) ||
|
||||||
|
(inv.product || "").toLowerCase().includes(q) ||
|
||||||
|
(inv.price || "").toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: invoicesData;
|
||||||
|
if (!filtered.length) {
|
||||||
|
toast("No invoices to export.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = ["Date,Agent,Product,Price"];
|
||||||
|
for (const inv of filtered) {
|
||||||
|
rows.push(
|
||||||
|
[inv.date || "", inv.uuid || "", inv.product || "", inv.price || ""].join(
|
||||||
|
",",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = new Blob([rows.join("\n")], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "invoices.csv";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Account Panel Tab Switching ─────────────────────────────── */
|
||||||
|
function switchAccountTab(tab) {
|
||||||
|
const settingsTab = document.getElementById("acct-tab-settings");
|
||||||
|
const affiliateTab = document.getElementById("acct-tab-affiliate");
|
||||||
|
const settingsPane = document.getElementById("acct-pane-settings");
|
||||||
|
const affiliatePane = document.getElementById("acct-pane-affiliate");
|
||||||
|
if (!settingsTab || !affiliateTab || !settingsPane || !affiliatePane) return;
|
||||||
|
|
||||||
|
if (tab === "affiliate") {
|
||||||
|
settingsTab.classList.remove("active");
|
||||||
|
affiliateTab.classList.add("active");
|
||||||
|
settingsPane.style.display = "none";
|
||||||
|
affiliatePane.style.display = "flex";
|
||||||
|
loadAffiliateLinks();
|
||||||
|
} else {
|
||||||
|
affiliateTab.classList.remove("active");
|
||||||
|
settingsTab.classList.add("active");
|
||||||
|
affiliatePane.style.display = "none";
|
||||||
|
settingsPane.style.display = "flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAffiliateLinks() {
|
||||||
|
const body = document.getElementById("affiliate-links-body");
|
||||||
|
if (!body) return;
|
||||||
|
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(AFFILIATE_LINKS_URL);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
dbg("\u2190 Affiliate links", data);
|
||||||
|
renderAffiliateLinks(data);
|
||||||
|
} catch (err) {
|
||||||
|
dbg("\u2190 Affiliate links error", err.message);
|
||||||
|
body.innerHTML =
|
||||||
|
'<p class="text-muted" style="font-size:13px;padding:4px 0;">Could not load affiliate links.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAffiliateLinks(data) {
|
||||||
|
const body = document.getElementById("affiliate-links-body");
|
||||||
|
if (!body) return;
|
||||||
|
let areas = [];
|
||||||
|
if (data && Array.isArray(data.areas)) {
|
||||||
|
areas = data.areas;
|
||||||
|
} else if (data && Array.isArray(data.data)) {
|
||||||
|
const first = data.data[0];
|
||||||
|
if (first && Array.isArray(first.areas)) areas = first.areas;
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
const first = data[0];
|
||||||
|
if (first && Array.isArray(first.areas)) areas = first.areas;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
||||||
|
<span class="text-muted" style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">${areas.length} areas</span>
|
||||||
|
<button type="button" id="save-affiliate-btn" class="btn btn-primary btn-sm" onclick="saveAffiliateLinks()" style="display:none;">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="affiliate-areas-list">
|
||||||
|
${areas
|
||||||
|
.map(
|
||||||
|
(a) => `
|
||||||
|
<div class="affiliate-area-card">
|
||||||
|
<div class="affiliate-area-name">${escHtml(a.area)}</div>
|
||||||
|
<div class="form-row" style="margin:0;">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label>Text</label>
|
||||||
|
<input type="text" class="affiliate-input" data-area="${escAttr(a.area)}" data-field="text" value="${escAttr(a.text || "")}" oninput="affiliateAreaChanged()" placeholder="Affiliate text…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin:0;">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label>Link</label>
|
||||||
|
<input type="text" class="affiliate-input" data-area="${escAttr(a.area)}" data-field="link" value="${escAttr(a.link || "")}" oninput="affiliateAreaChanged()" placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function affiliateAreaChanged() {
|
||||||
|
const btn = document.getElementById("save-affiliate-btn");
|
||||||
|
if (btn) btn.style.display = "inline-flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAffiliateLinks() {
|
||||||
|
const btn = document.getElementById("save-affiliate-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
btnLoad(btn, "Saving…");
|
||||||
|
try {
|
||||||
|
const inputs = document.querySelectorAll(".affiliate-input");
|
||||||
|
const map = {};
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const area = inp.dataset.area;
|
||||||
|
const field = inp.dataset.field;
|
||||||
|
if (!map[area]) map[area] = { area, text: "", link: "" };
|
||||||
|
map[area][field] = inp.value;
|
||||||
|
}
|
||||||
|
const areas = Object.values(map);
|
||||||
|
const res = await apiFetch(AFFILIATE_LINKS_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ areas }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
toast("Affiliate links saved.", "success");
|
||||||
|
btn.style.display = "none";
|
||||||
|
loadAffiliateLinks();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
btnReset(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
RESTART
|
RESTART
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
@@ -1279,6 +1531,28 @@ async function loadBackups(uuid) {
|
|||||||
const body = document.getElementById("backups-body");
|
const body = document.getElementById("backups-body");
|
||||||
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
|
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
// Check BACKUP flag from agent info response
|
||||||
|
const backupRaw = activeAgentInfo ? stripQuotes(activeAgentInfo.backup) : "—";
|
||||||
|
const hasBackup =
|
||||||
|
backupRaw !== "—" && backupRaw !== "0" && backupRaw !== "false";
|
||||||
|
const btn = document.getElementById("make-backup-btn");
|
||||||
|
if (btn) {
|
||||||
|
if (hasBackup) {
|
||||||
|
btn.style.display = "";
|
||||||
|
btn.innerHTML = "+";
|
||||||
|
btn.onclick = makeBackup;
|
||||||
|
btn.title = "Make Backup";
|
||||||
|
} else {
|
||||||
|
btn.style.display = "";
|
||||||
|
btn.innerHTML = "+";
|
||||||
|
btn.onclick = openBillingPortal;
|
||||||
|
btn.title = "Upgrade to enable backups";
|
||||||
|
btn.style.fontSize = "";
|
||||||
|
btn.style.lineHeight = "";
|
||||||
|
btn.style.padding = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const _backupsUrl = `${BACKUP_URL}?uuid=${encodeURIComponent(uuid)}`;
|
const _backupsUrl = `${BACKUP_URL}?uuid=${encodeURIComponent(uuid)}`;
|
||||||
dbg("→ List Backups", _backupsUrl);
|
dbg("→ List Backups", _backupsUrl);
|
||||||
@@ -1437,72 +1711,50 @@ function renderContract(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = data[0];
|
const item = data[0];
|
||||||
const type = (item.type || "").trim();
|
const product = stripQuotes(item.product || "");
|
||||||
const expires = item.expires || "";
|
const period = stripQuotes(item.period || "");
|
||||||
|
const type = stripQuotes(item.type || "");
|
||||||
|
|
||||||
// Determine status
|
// Look up expires from the agent data (new field in agents list API)
|
||||||
const t = type.toLowerCase();
|
const agent = agents.find((a) => (a.UUID || a.uuid) === activeUUID);
|
||||||
let expired = false;
|
const rawExpires =
|
||||||
if (expires && expires.trim()) {
|
agent && agent.expires
|
||||||
try {
|
? String(agent.expires).replace(/^"|"$/g, "").trim()
|
||||||
expired = new Date(expires) < new Date();
|
: "";
|
||||||
} catch (_) {}
|
const hasExpiry = rawExpires && rawExpires !== "null" && rawExpires !== "";
|
||||||
}
|
const expires = hasExpiry ? rawExpires : "";
|
||||||
|
|
||||||
let statusClass;
|
const label =
|
||||||
if (!type || expired || t.includes("none")) {
|
product && period
|
||||||
statusClass = "contract-dot--red";
|
? `${product} \u2013 ${period}`
|
||||||
} else if (t.includes("auto")) {
|
: product || period || type || "—";
|
||||||
statusClass = "contract-dot--green";
|
|
||||||
} else {
|
|
||||||
statusClass = "contract-dot--yellow";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the header dot
|
headerDot.className = hasExpiry
|
||||||
headerDot.className = `contract-dot ${statusClass}`;
|
? "contract-dot contract-dot--red"
|
||||||
|
: "contract-dot contract-dot--green";
|
||||||
headerDot.style.visibility = "visible";
|
headerDot.style.visibility = "visible";
|
||||||
|
|
||||||
// Empty type — show placeholder row with extend button on the right
|
let html = `<div class="contract-row">
|
||||||
if (!type) {
|
|
||||||
body.innerHTML = `
|
|
||||||
<div class="contract-row">
|
|
||||||
<div class="contract-info">
|
<div class="contract-info">
|
||||||
<span class="contract-type">No contract</span>
|
<span class="contract-type">${escHtml(label)}</span>`;
|
||||||
<span class="contract-expires">Stripe payments can take up to 24hrs to be recognized.</span>
|
|
||||||
</div>
|
if (hasExpiry) {
|
||||||
<a href="${escAttr(CONTRACT_EXTEND_URL)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="margin-left:auto;flex-shrink:0;">Extend Contract</a>
|
html += `
|
||||||
</div>`;
|
<span class="contract-expires">expires ${escHtml(expires)}</span>`;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expLabel =
|
html += `
|
||||||
expires && expires.trim()
|
|
||||||
? new Date(expires).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})
|
|
||||||
: "No expiration";
|
|
||||||
|
|
||||||
const extendHtml =
|
|
||||||
statusClass === "contract-dot--red"
|
|
||||||
? `<a href="${escAttr(CONTRACT_EXTEND_URL)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="margin-top:8px;display:inline-flex;">Extend Contract</a>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const cancelHtml =
|
|
||||||
statusClass === "contract-dot--green"
|
|
||||||
? `<a href="${escAttr(CONTRACT_CANCEL_URL)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="margin-left:auto;flex-shrink:0;">Cancel</a>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
body.innerHTML = `
|
|
||||||
<div class="contract-row">
|
|
||||||
<div class="contract-info">
|
|
||||||
<span class="contract-type">${escHtml(type)}</span>
|
|
||||||
<span class="contract-expires">${escHtml(expLabel)}</span>
|
|
||||||
${extendHtml}
|
|
||||||
</div>
|
|
||||||
${cancelHtml}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
if (hasExpiry) {
|
||||||
|
html += `
|
||||||
|
<a class="contract-extend-link" href="#" onclick="event.preventDefault();openBillingPortal()">Resubscribe →</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
@@ -1888,7 +2140,7 @@ let _confirmResolve = null;
|
|||||||
|
|
||||||
function confirmDialog(title, message, okLabel = "Confirm") {
|
function confirmDialog(title, message, okLabel = "Confirm") {
|
||||||
document.getElementById("confirm-title").textContent = title;
|
document.getElementById("confirm-title").textContent = title;
|
||||||
document.getElementById("confirm-message").textContent = message;
|
document.getElementById("confirm-message").innerHTML = message;
|
||||||
document.getElementById("confirm-ok-btn").textContent = okLabel;
|
document.getElementById("confirm-ok-btn").textContent = okLabel;
|
||||||
document.getElementById("confirm-backdrop").classList.add("open");
|
document.getElementById("confirm-backdrop").classList.add("open");
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -1925,7 +2177,7 @@ function toast(msg, type = "info", duration = 4000) {
|
|||||||
/* ─── Cookie helpers ───────────────────────────────────────── */
|
/* ─── Cookie helpers ───────────────────────────────────────── */
|
||||||
function setCookie(name, value, days) {
|
function setCookie(name, value, days) {
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Strict`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; domain=.derez.ai; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
return document.cookie.split("; ").reduce((acc, c) => {
|
return document.cookie.split("; ").reduce((acc, c) => {
|
||||||
@@ -1934,7 +2186,7 @@ function getCookie(name) {
|
|||||||
}, null);
|
}, null);
|
||||||
}
|
}
|
||||||
function deleteCookie(name) {
|
function deleteCookie(name) {
|
||||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Strict`;
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.derez.ai; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Authenticated fetch ───────────────────────────────────── */
|
/* ─── Authenticated fetch ───────────────────────────────────── */
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
+140
-25
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>derez.ai — My Portal</title>
|
<title>derez.ai — My Portal</title>
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
@@ -247,6 +248,10 @@
|
|||||||
>
|
>
|
||||||
View plans & pricing →
|
View plans & pricing →
|
||||||
</a>
|
</a>
|
||||||
|
<p class="hire-note">
|
||||||
|
New clients — a reset link has already been sent to
|
||||||
|
your email.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /auth-pane signup -->
|
<!-- /auth-pane signup -->
|
||||||
@@ -493,7 +498,7 @@
|
|||||||
d="M7 11V7a5 5 0 0 1 10 0v4"
|
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Instance Password
|
Your Hermes & Root Password
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pw-wrap">
|
<div class="pw-wrap">
|
||||||
@@ -807,7 +812,24 @@
|
|||||||
>
|
>
|
||||||
<div class="modal account-modal">
|
<div class="modal account-modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title">Account Settings</span>
|
<div class="auth-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="acct-tab-settings"
|
||||||
|
class="auth-switch-btn active"
|
||||||
|
onclick="switchAccountTab('settings')"
|
||||||
|
>
|
||||||
|
Account Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="acct-tab-affiliate"
|
||||||
|
class="auth-switch-btn"
|
||||||
|
onclick="switchAccountTab('affiliate')"
|
||||||
|
>
|
||||||
|
Affiliate Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="modal-close-btn"
|
class="modal-close-btn"
|
||||||
@@ -817,11 +839,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="account-modal-body">
|
<div class="account-modal-body">
|
||||||
<!-- ── Left: settings ───────────────────────── -->
|
<!-- ── Pane: Settings ──────────────────────── -->
|
||||||
|
<div
|
||||||
|
id="acct-pane-settings"
|
||||||
|
class="account-pane"
|
||||||
|
style="display: flex"
|
||||||
|
>
|
||||||
<div class="account-col account-col--settings">
|
<div class="account-col account-col--settings">
|
||||||
<div class="acct-section">
|
<div class="acct-section">
|
||||||
<div class="acct-field-label">Email</div>
|
<div class="acct-field-label">Email</div>
|
||||||
<div class="acct-field-val" id="acct-email-display">
|
<div
|
||||||
|
class="acct-field-val"
|
||||||
|
id="acct-email-display"
|
||||||
|
>
|
||||||
—
|
—
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -861,11 +891,16 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline
|
||||||
|
points="20 6 9 17 4 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="ssh-hint" style="margin-bottom: 0">
|
<p
|
||||||
|
class="ssh-hint"
|
||||||
|
style="margin-bottom: 0"
|
||||||
|
>
|
||||||
Min. 8 characters.
|
Min. 8 characters.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,7 +916,9 @@
|
|||||||
placeholder="invoices@example.com"
|
placeholder="invoices@example.com"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
oninput="
|
oninput="
|
||||||
toggleInvoiceEmailSave(this.value)
|
toggleInvoiceEmailSave(
|
||||||
|
this.value,
|
||||||
|
)
|
||||||
"
|
"
|
||||||
onkeydown="
|
onkeydown="
|
||||||
if (event.key === 'Enter')
|
if (event.key === 'Enter')
|
||||||
@@ -906,7 +943,9 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline
|
||||||
|
points="20 6 9 17 4 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -914,7 +953,70 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="acct-divider"></div>
|
<div class="acct-divider"></div>
|
||||||
<div class="acct-section">
|
<div class="acct-section">
|
||||||
<div class="acct-field-label">Affiliate Code</div>
|
<div class="acct-field-label">
|
||||||
|
Manage Subscriptions
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="acct-affiliate-row"
|
||||||
|
style="justify-content: flex-end"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="portal-btn"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onclick="openBillingPortal()"
|
||||||
|
>
|
||||||
|
Portal →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="acct-divider"></div>
|
||||||
|
</div>
|
||||||
|
<!-- ── Vertical divider ──────────────────────── -->
|
||||||
|
<div class="account-col-divider"></div>
|
||||||
|
<!-- ── Right: invoices ──────────────────────── -->
|
||||||
|
<div class="account-col account-col--invoices">
|
||||||
|
<div class="acct-invoices-toolbar">
|
||||||
|
<div class="acct-invoices-title">Invoices</div>
|
||||||
|
<div class="acct-invoices-actions">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="invoice-filter"
|
||||||
|
class="invoice-filter-input"
|
||||||
|
placeholder="Filter invoices…"
|
||||||
|
oninput="applyInvoiceFilter()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onclick="exportInvoicesCSV()"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="invoices-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ── Pane: Affiliate ─────────────────────── -->
|
||||||
|
<div
|
||||||
|
id="acct-pane-affiliate"
|
||||||
|
class="account-pane"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="account-col"
|
||||||
|
style="
|
||||||
|
flex: 1;
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Affiliate Code -->
|
||||||
|
<div class="acct-section">
|
||||||
|
<div class="acct-field-label">
|
||||||
|
Affiliate Code
|
||||||
|
</div>
|
||||||
<div class="acct-affiliate-row">
|
<div class="acct-affiliate-row">
|
||||||
<span
|
<span
|
||||||
class="acct-affiliate-code"
|
class="acct-affiliate-code"
|
||||||
@@ -932,13 +1034,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="acct-divider"></div>
|
||||||
|
<!-- Affiliate Links Table -->
|
||||||
|
<div
|
||||||
|
class="acct-invoices-toolbar"
|
||||||
|
style="margin-top: 2px"
|
||||||
|
>
|
||||||
|
<div class="acct-invoices-title">
|
||||||
|
Affiliate Links
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="affiliate-links-body">
|
||||||
|
<p
|
||||||
|
class="text-muted"
|
||||||
|
style="font-size: 13px; padding: 4px 0"
|
||||||
|
>
|
||||||
|
Switch to this tab to see available
|
||||||
|
affiliate links.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ── Vertical divider ──────────────────────── -->
|
|
||||||
<div class="account-col-divider"></div>
|
|
||||||
<!-- ── Right: invoices ──────────────────────── -->
|
|
||||||
<div class="account-col account-col--invoices">
|
|
||||||
<div class="acct-invoices-title">Invoices</div>
|
|
||||||
<div id="invoices-body"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -958,16 +1073,6 @@
|
|||||||
<span id="credits-key-name"></span>
|
<span id="credits-key-name"></span>
|
||||||
</p>
|
</p>
|
||||||
<div class="credits-options">
|
<div class="credits-options">
|
||||||
<label class="credits-option">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="credits-amount"
|
|
||||||
value="5.50"
|
|
||||||
/>
|
|
||||||
<span class="credits-option-label"
|
|
||||||
>€ 5.50</span
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<label class="credits-option">
|
<label class="credits-option">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -998,6 +1103,16 @@
|
|||||||
>€ 55.00</span
|
>€ 55.00</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="credits-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="credits-amount"
|
||||||
|
value="110.00"
|
||||||
|
/>
|
||||||
|
<span class="credits-option-label"
|
||||||
|
>€ 110.00</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="credits-fee-note">
|
<p class="credits-fee-note">
|
||||||
A 10% handling fee is added on top of the OpenRouter
|
A 10% handling fee is added on top of the OpenRouter
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "GitHub Repo",
|
||||||
|
"description": "Set up and sync with a GitHub repository — clone, commit, push, and manage code for your Hermes agent.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"label": "Repository URL",
|
||||||
|
"key": "repo_url",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "https://github.com/your-username/your-repo.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "GitHub Username",
|
||||||
|
"key": "git_user",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "your-username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "GitHub Password / Personal Access Token",
|
||||||
|
"key": "git_pass",
|
||||||
|
"type": "password",
|
||||||
|
"placeholder": "ghp_xxxxxxxxxxxx"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"signup_url": "https://semrush.com",
|
||||||
|
"signup_label": "🤝 Need to drive traffic to your site? Create a Semrush account",
|
||||||
|
"prompt": "Create a Hermes agent skill named \"github_repo_setup\".\n\n## Purpose\nSet up a working GitHub repository that the Hermes agent can use to read, write, and manage code, content, or configuration files via git. The repo acts as a persistent file store and version-controlled workspace.\n\n## Credentials\n\nREPO_URL={repo_url}\nGIT_USER={git_user}\nGIT_PASS={git_pass}\n\n## Startup behaviour\n\n1. On first run, check if git is installed. If not, install it:\n apt-get update && apt-get install -y git\n Or the equivalent for the system's package manager.\n\n2. Check if the repo directory (~/repo) already exists and is a git repo.\n cd ~/repo && git status\n\n3. If ~/repo does not exist or is not a git repo:\n - Extract the repo name from REPO_URL (the part before .git).\n - If a remote git URL is provided, authenticate using GIT_USER and GIT_PASS in the URL:\n AUTH_URL=$(echo \"$REPO_URL\" | sed \"s|https://|https://$GIT_USER:$GIT_PASS@|\")\n - Clone the repo:\n git clone \"$AUTH_URL\" ~/repo\n - Set the remote origin to the AUTH_URL for future push/pull:\n git remote set-url origin \"$AUTH_URL\"\n\n4. If ~/repo exists but is not a git repo, print an error and halt.\n\n5. If ~/repo is already a valid git repo and has a remote origin:\n - Re-authenticate the remote with the provided credentials:\n AUTH_URL=$(echo \"$REPO_URL\" | sed \"s|https://|https://$GIT_USER:$GIT_PASS@|\")\n git remote set-url origin \"$AUTH_URL\"\n - Pull the latest changes:\n git pull --ff-only\n - Print the status: \"GitHub repo already set up. Latest changes pulled.\"\n\n6. After clone or pull, print the current git log (last 5 commits):\n git --no-pager log --oneline -5\n\n## Daily usage\n\nWhenever the agent needs to read, write, or edit files:\n\n### Read a file\n cat ~/repo/<path>\n\n### Edit / write a file\n Write the new content, then:\n cd ~/repo\n git add <path>\n git commit -m \"<descriptive message>\"\n git push\n\n### Commit message format\n Always use conventional commits:\n - feat: <description>\n - fix: <description>\n - docs: <description>\n - chore: <description>\n - refactor: <description>\n\n### Push discipline\n- Always pull before push:\n git pull --ff-only && git push\n- If pull fails due to divergent branches, fetch and rebase:\n git fetch origin && git rebase origin/$(git branch --show-current) && git push\n- If rebase has conflicts, abort and notify the user:\n git rebase --abort\n echo \"ERROR: Merge conflict — needs manual resolution.\"\n\n### Create a new branch\n git checkout -b <branch-name>\n git push -u origin <branch-name>\n\n### List branches\n git branch -a\n\n### Working directory discipline\n- All work goes inside ~/repo. Never write files outside ~/repo unless explicitly instructed.\n- After every batch of changes, commit and push.\n\n## Solved cases log\n\nAfter every successfully completed task that involved the GitHub repo, append to ~/repo_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Commands: <the git commands that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/repo_solutions.md at the start of every task — if a matching case exists, reuse it directly."
|
||||||
|
}
|
||||||
@@ -2,13 +2,38 @@
|
|||||||
"name": "IMAP / SMTP Email",
|
"name": "IMAP / SMTP Email",
|
||||||
"description": "Read, send, and manage email from any standard mailbox.",
|
"description": "Read, send, and manage email from any standard mailbox.",
|
||||||
"fields": [
|
"fields": [
|
||||||
{"label": "Email Address", "key": "email", "type": "text", "placeholder": "you@yourdomain.com"},
|
{
|
||||||
{"label": "Your Name", "key": "display_name", "type": "text", "placeholder": "Jane Smith"},
|
"label": "Email Address",
|
||||||
{"label": "IMAP Host", "key": "imap_host", "type": "text", "placeholder": "imap.hostinger.com"},
|
"key": "email",
|
||||||
{"label": "SMTP Host", "key": "smtp_host", "type": "text", "placeholder": "smtp.hostinger.com"},
|
"type": "text",
|
||||||
{"label": "Password", "key": "password", "type": "password", "placeholder": ""}
|
"placeholder": "you@yourdomain.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Your Name",
|
||||||
|
"key": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Jane Smith"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "IMAP Host",
|
||||||
|
"key": "imap_host",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "imap.hostinger.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "SMTP Host",
|
||||||
|
"key": "smtp_host",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "smtp.hostinger.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Password",
|
||||||
|
"key": "password",
|
||||||
|
"type": "password",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"signup_url": "https://hostinger.com",
|
"signup_url": "https://hostinger.com",
|
||||||
"signup_label": "Need a domain or email? Sign up at Hostinger",
|
"signup_label": "🤝 Need a domain or email? Sign up at Hostinger",
|
||||||
"prompt": "Create a skill for IMAP / SMTP Email.\n\nUse the himalaya CLI to read, send, and manage emails.\n\n## Install himalaya (if not already installed)\n\ncurl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | PREFIX=~/.local sh\nexport PATH=\"$HOME/.local/bin:$PATH\" # add to ~/.bashrc if not present\nhimalaya --version\n\n## Write the config file\n\nCreate ~/.config/himalaya/config.toml with exactly this content:\n\n[accounts.default]\ndefault = true\nemail = \"{email}\"\ndisplay-name = \"{display_name}\"\n\nfolder.aliases.inbox = \"INBOX\"\nfolder.aliases.sent = \"Sent\"\nfolder.aliases.drafts = \"Drafts\"\nfolder.aliases.trash = \"Trash\"\n\nbackend.type = \"imap\"\nbackend.host = \"{imap_host}\"\nbackend.port = 993\nbackend.encryption.type = \"tls\"\nbackend.login = \"{email}\"\nbackend.auth.type = \"password\"\nbackend.auth.raw = \"{password}\"\n\nmessage.send.backend.type = \"smtp\"\nmessage.send.backend.host = \"{smtp_host}\"\nmessage.send.backend.port = 587\nmessage.send.backend.encryption.type = \"start-tls\"\nmessage.send.backend.login = \"{email}\"\nmessage.send.backend.auth.type = \"password\"\nmessage.send.backend.auth.raw = \"{password}\"\n\n## Key commands\n\nhimalaya folder list\nhimalaya envelope list\nhimalaya envelope list -f Sent\nhimalaya message read <id>\nhimalaya message reply <id>\nhimalaya message forward <id>\nhimalaya message delete <id>\nhimalaya --output json envelope list\n\n## Sending email\n\nprintf 'From: {display_name} <{email}>\\nTo: recipient@example.com\\nSubject: Hello\\n\\nBody text here.' | himalaya message send\n\n## Solved cases log\n\nEvery time you successfully solve an email task, append a short entry to ~/email_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Key commands: <the himalaya commands or shell pipeline that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/email_solutions.md at the start of each task — if a matching solved case exists, reuse it directly."
|
"prompt": "Create a skill for IMAP / SMTP Email.\n\nUse the himalaya CLI to read, send, and manage emails.\n\n## Install himalaya (if not already installed)\n\ncurl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | PREFIX=~/.local sh\nexport PATH=\"$HOME/.local/bin:$PATH\" # add to ~/.bashrc if not present\nhimalaya --version\n\n## Write the config file\n\nCreate ~/.config/himalaya/config.toml with exactly this content:\n\n[accounts.default]\ndefault = true\nemail = \"{email}\"\ndisplay-name = \"{display_name}\"\n\nfolder.aliases.inbox = \"INBOX\"\nfolder.aliases.sent = \"Sent\"\nfolder.aliases.drafts = \"Drafts\"\nfolder.aliases.trash = \"Trash\"\n\nbackend.type = \"imap\"\nbackend.host = \"{imap_host}\"\nbackend.port = 993\nbackend.encryption.type = \"tls\"\nbackend.login = \"{email}\"\nbackend.auth.type = \"password\"\nbackend.auth.raw = \"{password}\"\n\nmessage.send.backend.type = \"smtp\"\nmessage.send.backend.host = \"{smtp_host}\"\nmessage.send.backend.port = 587\nmessage.send.backend.encryption.type = \"start-tls\"\nmessage.send.backend.login = \"{email}\"\nmessage.send.backend.auth.type = \"password\"\nmessage.send.backend.auth.raw = \"{password}\"\n\n## Key commands\n\nhimalaya folder list\nhimalaya envelope list\nhimalaya envelope list -f Sent\nhimalaya message read <id>\nhimalaya message reply <id>\nhimalaya message forward <id>\nhimalaya message delete <id>\nhimalaya --output json envelope list\n\n## Sending email\n\nprintf 'From: {display_name} <{email}>\\nTo: recipient@example.com\\nSubject: Hello\\n\\nBody text here.' | himalaya message send\n\n## Solved cases log\n\nEvery time you successfully solve an email task, append a short entry to ~/email_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Key commands: <the himalaya commands or shell pipeline that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/email_solutions.md at the start of each task — if a matching solved case exists, reuse it directly."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,7 @@
|
|||||||
"odoo-community.json",
|
"odoo-community.json",
|
||||||
"imap-smtp-email.json",
|
"imap-smtp-email.json",
|
||||||
"n8n-crm.json",
|
"n8n-crm.json",
|
||||||
"nextcloud-sync.json"
|
"nextcloud-sync.json",
|
||||||
|
"n8n-skills.json",
|
||||||
|
"github-repo.json"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,12 +2,32 @@
|
|||||||
"name": "N8N CRM",
|
"name": "N8N CRM",
|
||||||
"description": "Manage contacts and deal stages via your n8n webhook CRM.",
|
"description": "Manage contacts and deal stages via your n8n webhook CRM.",
|
||||||
"fields": [
|
"fields": [
|
||||||
{"label": "Webhook Base URL", "key": "webhook_url", "type": "text", "placeholder": "https://your-n8n.example.com/webhook/crm"},
|
{
|
||||||
{"label": "Auth Header Name", "key": "auth_header_name", "type": "text", "placeholder": "Authorization"},
|
"label": "Webhook Base URL",
|
||||||
{"label": "Auth Header Value", "key": "auth_header_value", "type": "password", "placeholder": ""},
|
"key": "webhook_url",
|
||||||
{"label": "Example Contact JSON (paste one record from your CRM)", "key": "example_json", "type": "textarea", "placeholder": "{\n \"email\": \"alice@example.com\",\n \"name\": \"Alice Smith\",\n \"company\": \"Acme Corp\",\n \"stage\": \"Cold\",\n \"followup\": false,\n \"notes\": \"\"\n}"}
|
"type": "text",
|
||||||
|
"placeholder": "https://your-n8n.example.com/webhook/crm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Auth Header Name",
|
||||||
|
"key": "auth_header_name",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Authorization"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Auth Header Value",
|
||||||
|
"key": "auth_header_value",
|
||||||
|
"type": "password",
|
||||||
|
"placeholder": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Example Contact JSON (paste one record from your CRM)",
|
||||||
|
"key": "example_json",
|
||||||
|
"type": "textarea",
|
||||||
|
"placeholder": "{\n \"email\": \"alice@example.com\",\n \"name\": \"Alice Smith\",\n \"company\": \"Acme Corp\",\n \"stage\": \"Cold\",\n \"followup\": false,\n \"notes\": \"\"\n}"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"signup_url": "https://n8n.io",
|
"signup_url": "https://n8n.io",
|
||||||
"signup_label": "Need a webhook backend? Try n8n — self-host or cloud",
|
"signup_label": "🤝 Need a webhook backend? Try n8n — self-host or cloud",
|
||||||
"prompt": "Create a skill for an N8N CRM backed by a webhook.\n\n## Configuration\n\nWEBHOOK_URL={webhook_url}\nAUTH_HEADER_NAME={auth_header_name}\nAUTH_HEADER_VALUE={auth_header_value}\n\n## Example record schema (auto-detected from user input)\n\n{example_json}\n\nParse the keys of the JSON above to discover the available columns. Always use exactly those column names when building payloads or displaying data. The column named \"email\" is the unique identifier for every contact.\n\n## Stages\n\n| Stage | Meaning |\n|------------|--------------------------------|\n| Cold | No contact so far |\n| Contacted | First message / email sent |\n| Warm | Response received |\n| Won | Deal closed successfully |\n| Lost | Deal closed unsuccessfully |\n\n## API contract\n\n### List contacts\n\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?stage=Warm\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?email=alice@example.com\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?followup=true\"\n\n### Create or update a contact (upsert by email)\n\n curl -s -X POST \"$WEBHOOK_URL\" \\\n -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"email\":\"alice@example.com\",\"name\":\"Alice Smith\",\"stage\":\"Contacted\",\"followup\":false}'\n\n### Delete a contact\n\n curl -s -X DELETE \"$WEBHOOK_URL\" \\\n -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"email\":\"alice@example.com\"}'\n\n## Solved cases log\n\nAfter every successful CRM task, append an entry to ~/crm_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Key commands: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/crm_solutions.md at task start — if a matching case exists, reuse it directly."
|
"prompt": "Create a skill for an N8N CRM backed by a webhook.\n\n## Configuration\n\nWEBHOOK_URL={webhook_url}\nAUTH_HEADER_NAME={auth_header_name}\nAUTH_HEADER_VALUE={auth_header_value}\n\n## Example record schema (auto-detected from user input)\n\n{example_json}\n\nParse the keys of the JSON above to discover the available columns. Always use exactly those column names when building payloads or displaying data. The column named \"email\" is the unique identifier for every contact.\n\n## Stages\n\n| Stage | Meaning |\n|------------|--------------------------------|\n| Cold | No contact so far |\n| Contacted | First message / email sent |\n| Warm | Response received |\n| Won | Deal closed successfully |\n| Lost | Deal closed unsuccessfully |\n\n## API contract\n\n### List contacts\n\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?stage=Warm\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?email=alice@example.com\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?followup=true\"\n\n### Create or update a contact (upsert by email)\n\n curl -s -X POST \"$WEBHOOK_URL\" \\\n -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"email\":\"alice@example.com\",\"name\":\"Alice Smith\",\"stage\":\"Contacted\",\"followup\":false}'\n\n### Delete a contact\n\n curl -s -X DELETE \"$WEBHOOK_URL\" \\\n -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"email\":\"alice@example.com\"}'\n\n## Solved cases log\n\nAfter every successful CRM task, append an entry to ~/crm_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Key commands: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/crm_solutions.md at task start — if a matching case exists, reuse it directly."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "N8N Skills",
|
||||||
|
"description": "Load and execute skills from n8n's official skill library via the n8n REST API.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"label": "n8n URL",
|
||||||
|
"key": "n8n_url",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "https://your-n8n.example.com \u2014 API endpoint is usually at <domain>/api/v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "n8n API Key",
|
||||||
|
"key": "n8n_api_key",
|
||||||
|
"type": "password",
|
||||||
|
"placeholder": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"signup_url": "https://n8n.io/?via=derez",
|
||||||
|
"signup_label": "🤝 Need n8n? Sign up via our affiliate link",
|
||||||
|
"prompt": "Create a skill that integrates with n8n's skill library and REST API.\n\n## Configuration\n\nN8N_URL={n8n_url}\nN8N_API_KEY={n8n_api_key}\n\n## Skill library\n\nYou have access to n8n's official skill library at https://github.com/n8n-io/skills\n\nAt the start of every task, clone or pull the latest version of this repo to discover available skills.\n\n if [ ! -d /tmp/n8n-skills ]; then\n git clone --depth 1 https://github.com/n8n-io/skills /tmp/n8n-skills\n else\n cd /tmp/n8n-skills && git pull --ff-only\n fi\n\nRead the README.md and INDEX.md files in that repo to understand what skills are available. Each skill has a dedicated directory with a README.md describing its purpose and usage. Choose the most relevant skill(s) for the user's request.\n\n## API basics\n\nAll calls go to {n8n_url}/rest/... with the header:\n X-N8N-API-KEY: {n8n_api_key}\n\nThe n8n REST API documentation is available at {n8n_url}/api/v1/openapi.json — fetch it if you need to discover available endpoints.\n\n### Common operations\n\nList workflows:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/workflows'\n\nGet a workflow by ID:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/workflows/<id>'\n\nList executions:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/executions'\n\nList credentials:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/credentials'\n\nList tags:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/tags'\n\n## Workflow skill execution pattern\n\nWhen a skill from the library maps to a workflow to run:\n\n1. Read the skill's README.md to understand the inputs, outputs, and workflow structure.\n2. Check if a matching workflow already exists in n8n (use the list endpoint).\n3. If it does, create an execution:\n\n curl -s -X POST '{n8n_url}/rest/workflows/<id>/run' \\\n -H 'X-N8N-API-KEY: {n8n_api_key}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"data\": <input_data>}'\n\n4. If it doesn't, create a new workflow using the skill's specification:\n\n curl -s -X POST '{n8n_url}/rest/workflows' \\\n -H 'X-N8N-API-KEY: {n8n_api_key}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\": \"<skill-name>\", \"nodes\": [...], \"connections\": {...}}'\n\n5. Poll execution status:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/executions/<id>' | jq '.data'\n\n## Solved cases log\n\nAfter every successful n8n task, append to ~/n8n_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Skill used: <skill path in the library>\n Key commands: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/n8n_solutions.md at task start — if a matching case exists, reuse it directly."
|
||||||
|
}
|
||||||
@@ -2,12 +2,32 @@
|
|||||||
"name": "Nextcloud Sync",
|
"name": "Nextcloud Sync",
|
||||||
"description": "Connect to a Nextcloud instance and sync folders via WebDAV — list, download, upload, and mirror files automatically.",
|
"description": "Connect to a Nextcloud instance and sync folders via WebDAV — list, download, upload, and mirror files automatically.",
|
||||||
"fields": [
|
"fields": [
|
||||||
{"label": "Nextcloud URL", "key": "nc_url", "type": "text", "placeholder": "https://cloud.example.com"},
|
{
|
||||||
{"label": "Username", "key": "nc_user", "type": "text", "placeholder": "admin"},
|
"label": "Nextcloud URL",
|
||||||
{"label": "Password / App Token", "key": "nc_pass", "type": "password", "placeholder": ""},
|
"key": "nc_url",
|
||||||
{"label": "Directory to sync (optional — leave blank for all)", "key": "nc_dir", "type": "text", "placeholder": "/Documents"}
|
"type": "text",
|
||||||
|
"placeholder": "https://cloud.example.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Username",
|
||||||
|
"key": "nc_user",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Password / App Token",
|
||||||
|
"key": "nc_pass",
|
||||||
|
"type": "password",
|
||||||
|
"placeholder": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Directory to sync (optional — leave blank for all)",
|
||||||
|
"key": "nc_dir",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "/Documents"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"signup_url": "https://nextcloud.com/de/",
|
"signup_url": "https://nextcloud.com/de/",
|
||||||
"signup_label": "Don't have Nextcloud yet? Get started at nextcloud.com",
|
"signup_label": "🤝 Don't have Nextcloud yet? Get started at nextcloud.com",
|
||||||
"prompt": "You are a Nextcloud file-sync agent. Your only tools are curl, jq, and standard POSIX shell utilities. Never write Python or Node scripts. Every task must be solved with shell one-liners or short shell scripts.\n\n## Credentials\n\nNC_URL={nc_url}\nNC_USER={nc_user}\nNC_PASS={nc_pass}\nNC_DIR={nc_dir}\n\n## WebDAV base path\n\nAll WebDAV operations use:\n WEBDAV=\"$NC_URL/remote.php/dav/files/$NC_USER\"\n\nIf NC_DIR is set and non-empty, the sync root is:\n SYNC_ROOT=\"$WEBDAV$NC_DIR\"\nOtherwise the sync root is the full remote home:\n SYNC_ROOT=\"$WEBDAV\"\n\n## Common operations\n\n### List folder contents (one level)\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X PROPFIND \\\n -H 'Depth: 1' \\\n -H 'Content-Type: application/xml' \\\n --data '<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/></d:prop></d:propfind>' \\\n \"$SYNC_ROOT/\" \\\n | grep -oP '(?<=<d:href>)[^<]+'\n\n### Recursively list all remote paths\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X PROPFIND \\\n -H 'Depth: infinity' \\\n -H 'Content-Type: application/xml' \\\n --data '<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:href/><d:resourcetype/></d:prop></d:propfind>' \\\n \"$SYNC_ROOT/\" \\\n | grep -oP '(?<=<d:href>)[^<]+'\n\n### Download a single file\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -o \"/local/path/filename.ext\" \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/filename.ext\"\n\n### Upload a single file\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -T \"/local/path/filename.ext\" \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/filename.ext\"\n\n### Create a remote directory\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X MKCOL \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/new_folder\"\n\n### Delete a remote file or folder\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X DELETE \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/item\"\n\n## Sync strategy\n\nWhen asked to sync a folder:\n1. List all remote paths under SYNC_ROOT recursively.\n2. Mirror the directory tree locally, creating subdirectories as needed.\n3. For each remote file, compare the Last-Modified header against the local mtime (use `date -r` or `stat -c %Y`). Download only if remote is newer or file is absent locally.\n4. For uploads, scan the local tree and PUT any file that is newer locally than the remote copy or does not yet exist remotely.\n5. Never delete files on either side without explicit user confirmation.\n\n## App tokens (recommended over plain password)\n\nGenerate an app token at: $NC_URL/settings/user/security\nUse the token in place of the password — it can be revoked without changing the account password.\n\n## Solved cases log\n\nAfter every successful sync or file operation, append to ~/nextcloud_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Remote path: <SYNC_ROOT value used>\n Key commands: <the curl pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/nextcloud_solutions.md at task start — if a matching case exists, reuse it directly."
|
"prompt": "\n Create a Hermes agent skill named \"nextcloud_exchange_bisync\".\n \n Purpose:\n Create a skill that synchronizes data bidirectionally between a user's Nextcloud instance and a Nextcloud directory named \"exchange\" using rclone.\n \n Skill requirements:\n \n 1. Startup behavior:\n - When the skill starts, print a clear status message:\n \"Starting Nextcloud exchange synchronization...\"\n - Check whether rclone is installed.\n - If rclone is missing, install it automatically using the official rclone installation method.\n - Verify that rclone is available before continuing.\n \n 2. \"Credentials\"\n Your credentials are below. store them for future use\n Credentials\n NC_URL={nc_url}\nNC_USER={nc_user}\nNC_PASS={nc_pass}\n \n 3. rclone configuration:\n - Automatically create rclone WebDAV remotes for Nextcloud.\n - Configure Nextcloud as the WebDAV provider.\n - Use:\n vendor=nextcloud\n - Store configuration in the standard rclone config location.\n - Reuse existing configuration if already present.\n\n \n 4. Synchronization:\n rclone bisync /root/exchange/ nextcloud:/{nc_dir} --resync\n you need to start the sync manually. do this before every access to the directory and after any changes you want to sync.\n \n 5. Safety:\n - Enable checks before syncing.\n - Detect access problems before starting.\n - Preserve timestamps.\n - Preserve empty directories.\n - Avoid accidental deletion unless confirmed by bisync logic.\n - Produce readable logs.\n \n 6. Completion:\n When finished:\n - Print:\n \"Nextcloud exchange synchronization completed.\"\n - Provide a short summary:\n - files checked\n - changes transferred\n - errors if any\n \n 7. Error handling:\n - Fail cleanly.\n - Explain missing credentials.\n - Explain connection failures.\n - Explain rclone errors.\n - Return non-zero status on failure.\n \n Generate:\n - The complete Hermes skill definition.\n - The executable script.\n - Configuration schema.\n - Required environment variables.\n - Usage example."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,32 @@
|
|||||||
"name": "ODOO Community",
|
"name": "ODOO Community",
|
||||||
"description": "Automate ERP tasks — sales, accounting, and inventory — via REST API.",
|
"description": "Automate ERP tasks — sales, accounting, and inventory — via REST API.",
|
||||||
"fields": [
|
"fields": [
|
||||||
{"label": "Site URL", "key": "site_url", "type": "text", "placeholder": "https://ODOO4projects.com"},
|
{
|
||||||
{"label": "Username", "key": "username", "type": "text", "placeholder": "admin"},
|
"label": "Site URL",
|
||||||
{"label": "Password / API Key", "key": "api_key", "type": "password", "placeholder": ""},
|
"key": "site_url",
|
||||||
{"label": "Database Name", "key": "db_name", "type": "text", "placeholder": "mycompany"}
|
"type": "text",
|
||||||
|
"placeholder": "https://ODOO4projects.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Username",
|
||||||
|
"key": "username",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Password / API Key",
|
||||||
|
"key": "api_key",
|
||||||
|
"type": "password",
|
||||||
|
"placeholder": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Database Name",
|
||||||
|
"key": "db_name",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "mycompany"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"signup_url": "https://ODOO4projects.com",
|
"signup_url": "https://ODOO4projects.com",
|
||||||
"signup_label": "Need ODOO Community hosting? Sign up here",
|
"signup_label": "🤝 Need ODOO Community hosting? Sign up here",
|
||||||
"prompt": "create a skill for ODOO community. You are an Odoo automation agent. Your only tools are curl and jq. Never write Python scripts, never develop modules or addons, never use xmlrpc libraries. Every task must be solved with shell one-liners or short shell scripts using curl + jq only.\n\n## Credentials\n\nSITE_URL={site_url}\nUSER={username}\nAPI_KEY={api_key}\nDB={db_name}\n\n## API call pattern\n\nEvery call is a POST to {site_url}/json/2/<model>/<method>\nRequired headers on every request:\n Authorization: Bearer {api_key}\n X-Openerp-Database: {db_name}\n Content-Type: application/json\n\nBody: a FLAT JSON object of the method arguments (NOT JSON-RPC — no \"params\" wrapper).\nResponse: the raw return value directly (no \"result\" wrapper).\n\n## Always request only the fields you need\n\nNever call search_read without a \"fields\" list. Fetching all fields is wasteful and slow.\nDecide upfront which fields the task actually requires, then pass exactly those.\n\n curl -s -X POST '{site_url}/json/2/sale.order/search_read' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"domain\": [[\"state\",\"=\",\"sale\"]], \"fields\": [\"name\",\"partner_id\",\"amount_total\"], \"limit\": 20}' \\\n | jq '.[] | {order: .name, customer: .partner_id[1], total: .amount_total}'\n\nUse fields_get only when you genuinely do not know a model's field names:\n curl -s -X POST '{site_url}/json/2/account.move/fields_get' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"attributes\": [\"string\",\"type\"]}' \\\n | jq 'to_entries[] | {field: .key, label: .value.string, type: .value.type}'\n\n## First-run: build your API digest\n\nIf ~/odoo_digest.md does NOT exist yet, run this once and write the output to the file:\n\n curl -s '{site_url}/json/2/doc' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n | jq '[to_entries[] | {model: .key, label: .value.string}]' > /tmp/odoo_models.json\n\nFrom /tmp/odoo_models.json, write ~/odoo_digest.md containing:\n - The standard ORM methods available on every model\n - A compact table of installed business models with their technical name and human label\n - The exact one-liner curl template for POST calls\n - Any non-standard or custom routes found in /doc\n\nOn every future task, read ~/odoo_digest.md first.\n\n## Solved cases log\n\nAfter successfully solving any business task, append to ~/odoo_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Models: <models used>\n Fields queried: <only the fields that were actually needed>\n Key calls: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/odoo_solutions.md at task start — if a matching case exists, reuse it directly."
|
"prompt": "create a skill for ODOO community. You are an Odoo automation agent. Your only tools are curl and jq. Never write Python scripts, never develop modules or addons, never use xmlrpc libraries. Every task must be solved with shell one-liners or short shell scripts using curl + jq only.\n\n## Credentials\n\nSITE_URL={site_url}\nUSER={username}\nAPI_KEY={api_key}\nDB={db_name}\n\n## API call pattern\n\nEvery call is a POST to {site_url}/json/2/<model>/<method>\nRequired headers on every request:\n Authorization: Bearer {api_key}\n X-Openerp-Database: {db_name}\n Content-Type: application/json\n\nBody: a FLAT JSON object of the method arguments (NOT JSON-RPC — no \"params\" wrapper).\nResponse: the raw return value directly (no \"result\" wrapper).\n\n## Always request only the fields you need\n\nNever call search_read without a \"fields\" list. Fetching all fields is wasteful and slow.\nDecide upfront which fields the task actually requires, then pass exactly those.\n\n curl -s -X POST '{site_url}/json/2/sale.order/search_read' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"domain\": [[\"state\",\"=\",\"sale\"]], \"fields\": [\"name\",\"partner_id\",\"amount_total\"], \"limit\": 20}' \\\n | jq '.[] | {order: .name, customer: .partner_id[1], total: .amount_total}'\n\nUse fields_get only when you genuinely do not know a model's field names:\n curl -s -X POST '{site_url}/json/2/account.move/fields_get' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"attributes\": [\"string\",\"type\"]}' \\\n | jq 'to_entries[] | {field: .key, label: .value.string, type: .value.type}'\n\n## First-run: build your API digest\n\nIf ~/odoo_digest.md does NOT exist yet, run this once and write the output to the file:\n\n curl -s '{site_url}/json/2/doc' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n | jq '[to_entries[] | {model: .key, label: .value.string}]' > /tmp/odoo_models.json\n\nFrom /tmp/odoo_models.json, write ~/odoo_digest.md containing:\n - The standard ORM methods available on every model\n - A compact table of installed business models with their technical name and human label\n - The exact one-liner curl template for POST calls\n - Any non-standard or custom routes found in /doc\n\nOn every future task, read ~/odoo_digest.md first.\n\n## Solved cases log\n\nAfter successfully solving any business task, append to ~/odoo_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Models: <models used>\n Fields queried: <only the fields that were actually needed>\n Key calls: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/odoo_solutions.md at task start — if a matching case exists, reuse it directly."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>derez.ai — Reset Password</title>
|
<title>derez.ai — Reset Password</title>
|
||||||
<style>
|
<style>
|
||||||
/* ─── Reset ─────────────────────────────────────────── */
|
/* ─── Reset ─────────────────────────────────────────── */
|
||||||
|
|||||||
+141
-16
@@ -709,6 +709,11 @@ body::after {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-tab-dot--red {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-tab-badge {
|
.agent-tab-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -727,16 +732,16 @@ body::after {
|
|||||||
.agent-tab-add {
|
.agent-tab-add {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 7px;
|
||||||
width: 28px;
|
padding: 6px 14px;
|
||||||
height: 28px;
|
border-radius: 20px;
|
||||||
border-radius: 50%;
|
border: 1px solid var(--border);
|
||||||
border: 1px dashed var(--border);
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 18px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.15s,
|
border-color 0.15s,
|
||||||
@@ -744,9 +749,9 @@ body::after {
|
|||||||
background 0.15s;
|
background 0.15s;
|
||||||
}
|
}
|
||||||
.agent-tab-add:hover {
|
.agent-tab-add:hover {
|
||||||
border-color: var(--accent);
|
border-color: rgba(79, 142, 247, 0.4);
|
||||||
color: var(--accent);
|
color: var(--text);
|
||||||
background: var(--accent-dim);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs-loading,
|
.tabs-loading,
|
||||||
@@ -1448,6 +1453,28 @@ body::after {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-modal .modal-header .auth-switch {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-modal .modal-header .auth-switch-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-modal .modal-header .auth-switch-btn.active {
|
||||||
|
background: var(--bg-inner);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-btn {
|
.modal-close-btn {
|
||||||
@@ -1467,7 +1494,12 @@ body::after {
|
|||||||
|
|
||||||
.account-modal-body {
|
.account-modal-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 320px;
|
height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-pane {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-col {
|
.account-col {
|
||||||
@@ -1535,12 +1567,44 @@ body::after {
|
|||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.acct-invoices-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.acct-invoices-title {
|
.acct-invoices-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
margin-bottom: 14px;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acct-invoices-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-filter-input {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
width: 140px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-filter-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-list {
|
.invoice-list {
|
||||||
@@ -1553,7 +1617,7 @@ body::after {
|
|||||||
|
|
||||||
.invoice-row {
|
.invoice-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100px 1fr 1fr 80px;
|
grid-template-columns: 100px 1fr 1fr 80px 60px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
@@ -1609,6 +1673,26 @@ body::after {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoice-dl {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-dl-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-dl-link:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-dl-link--disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Credits Modal ────────────────────────────────────────── */
|
/* ─── Credits Modal ────────────────────────────────────────── */
|
||||||
.credits-key-label {
|
.credits-key-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1792,8 +1876,8 @@ body::after {
|
|||||||
}
|
}
|
||||||
.chat-msg-user {
|
.chat-msg-user {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
background: var(--accent);
|
background: var(--bg-hover);
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
border-bottom-right-radius: 3px;
|
border-bottom-right-radius: 3px;
|
||||||
}
|
}
|
||||||
.chat-msg-bot {
|
.chat-msg-bot {
|
||||||
@@ -1947,6 +2031,14 @@ body.auth-layout .auth-pane:last-child {
|
|||||||
.hire-link:hover {
|
.hire-link:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hire-note {
|
||||||
|
margin: auto 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
body.auth-layout .auth-forms-divider {
|
body.auth-layout .auth-forms-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
@@ -2117,11 +2209,12 @@ body.auth-layout #signin-btn {
|
|||||||
|
|
||||||
.contract-extend-link {
|
.contract-extend-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 5px;
|
margin-left: auto;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract-extend-link:hover {
|
.contract-extend-link:hover {
|
||||||
@@ -2158,6 +2251,38 @@ body.auth-layout #signin-btn {
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Affiliate Area Cards ───────────────────────────────────── */
|
||||||
|
.affiliate-areas-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-area-card {
|
||||||
|
background: var(--bg-inner);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-area-card .form-group input {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-area-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Wizard (Integrations setup) ─────────────────────────────── */
|
/* ─── Wizard (Integrations setup) ─────────────────────────────── */
|
||||||
#wizard-card {
|
#wizard-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user