Compare commits

..

17 Commits

Author SHA1 Message Date
oliver 2176b6b2fb management console 2026-05-27 18:30:35 -03:00
oliver 66821b7276 dashboard 2026-04-27 05:59:28 -03:00
oliver 9aac7eba2a check 2026-04-15 19:01:33 -03:00
oliver 5c5015883d Fix caching 2026-04-15 18:47:48 -03:00
Your Name 1693e003c8 todo 2026-04-15 10:27:54 -03:00
Your Name c06fa1d91f remove double call 2026-04-11 17:07:08 -03:00
Your Name 11586c08b6 Update upsell.html 2026-04-11 10:59:35 -03:00
Your Name 2ebc7d1f96 Update upsell.html 2026-04-11 10:55:48 -03:00
Your Name c9d6c937f8 Update upsell.html 2026-04-11 10:38:10 -03:00
Your Name 8f68959256 fix 2026-04-11 09:31:36 -03:00
Your Name 8af11561d0 design 2026-04-11 09:27:42 -03:00
Your Name 5e94b9d40c Update upsell.html 2026-04-11 07:30:37 -03:00
Your Name 66bffcfcbc Update upsell.html 2026-04-11 07:25:14 -03:00
Your Name a7db4ebe67 Update upsell.html 2026-04-11 07:16:47 -03:00
Your Name a8a12d63ba Update upsell.html 2026-04-11 07:13:54 -03:00
Your Name 97b1d256ea u 2026-04-11 06:59:52 -03:00
Your Name a1473d9930 update 2026-04-11 06:27:37 -03:00
4 changed files with 2632 additions and 228 deletions
+686
View File
@@ -0,0 +1,686 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Dashboard</title>
<style>
body {
margin: 0;
width: 1920px;
height: 1200px;
font-family: Arial, sans-serif;
background: #2f3e4e;
color: #e6edf3;
overflow: hidden;
}
.dashboard {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 20px;
}
.box {
background: #3b4c5e;
border-radius: 14px;
padding: 18px;
box-sizing: border-box;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.25);
position: relative;
overflow: hidden;
}
.title {
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
opacity: 0.9;
}
.status-light {
position: absolute;
top: 18px;
right: 18px;
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.green {
background: #3ad29f;
}
.yellow {
background: #f5c542;
}
.red {
background: #e05d5d;
}
.green-text {
color: #3ad29f;
}
.yellow-text {
color: #f5c542;
}
.red-text {
color: #e05d5d;
}
/* SERVER GRID */
.servers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.server {
background: #44586d;
border-radius: 10px;
padding: 12px;
position: relative;
}
.server-name {
font-size: 15px;
margin-bottom: 8px;
opacity: 0.85;
font-weight: bold;
padding-right: 22px;
}
.server .status-light {
top: 12px;
right: 12px;
}
.metrics {
font-size: 12px;
line-height: 1.7;
opacity: 0.85;
}
.metric-row {
display: flex;
justify-content: space-between;
align-items: center;
}
/* MEETINGS */
.meetings-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.meeting {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
border: 2px solid transparent;
position: relative;
}
.meeting.soon {
border: 2px solid #e05d5d;
box-shadow: 0 0 8px rgba(224, 93, 93, 0.4);
}
.meeting-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.meeting-name {
font-size: 13px;
font-weight: bold;
opacity: 0.95;
flex: 1;
}
.meeting-badges {
display: flex;
gap: 4px;
margin-left: 6px;
}
.badge {
font-size: 9px;
padding: 2px 5px;
border-radius: 4px;
font-weight: bold;
white-space: nowrap;
}
.badge-odoo {
background: #714b67;
color: #e6edf3;
}
.badge-brandize {
background: #1a6fa0;
color: #e6edf3;
}
.time {
font-size: 11px;
opacity: 0.65;
margin-top: 3px;
}
/* TODOS */
.todos-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.todo {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 13px;
opacity: 0.9;
display: flex;
align-items: center;
gap: 8px;
}
.todo.done-todo {
opacity: 0.5;
text-decoration: line-through;
}
.todo-check {
width: 14px;
height: 14px;
border-radius: 3px;
border: 2px solid #8aa0b5;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.todo-check.checked {
background: #3ad29f;
border-color: #3ad29f;
}
/* LATEST SIGNUPS */
.signups-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.signup-item {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 12px;
}
.signup-email {
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
opacity: 0.95;
}
.signup-meta {
display: flex;
gap: 8px;
opacity: 0.7;
flex-wrap: wrap;
}
.signup-tag {
background: #2f3e4e;
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
color: #3ad29f;
}
.signup-server {
font-size: 11px;
opacity: 0.7;
}
/* CURRENT TESTS */
.test-box {
background: #44586d;
border-radius: 10px;
padding: 12px;
font-size: 12px;
line-height: 1.8;
}
.test-row {
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 3px 0;
}
.test-label {
opacity: 0.6;
font-size: 11px;
}
.test-value {
font-size: 11px;
text-align: right;
max-width: 65%;
word-break: break-all;
}
.status-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: bold;
background: #3ad29f33;
color: #3ad29f;
}
.status-badge.dev {
background: #f5c54233;
color: #f5c542;
}
/* LOADING / ERROR */
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
opacity: 0.5;
letter-spacing: 1px;
}
.error-msg {
color: #e05d5d;
font-size: 13px;
opacity: 0.85;
padding: 12px;
}
</style>
</head>
<body>
<div class="dashboard" id="dashboard">
<div class="loading" style="grid-column: 1/-1; grid-row: 1/-1">
Loading dashboard…
</div>
</div>
<script>
const WEBHOOK_URL =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d";
const REFRESH_INTERVAL_MS = 60 * 1000; // refresh every 60 seconds
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mbToGb(mb) {
return (mb / 1024).toFixed(1);
}
function pct(free, total) {
return total > 0 ? (free / total) * 100 : 0;
}
function metricColor(freePct) {
if (freePct < 10) return "red";
if (freePct < 20) return "yellow";
return "green";
}
function worstColor(colors) {
if (colors.includes("red")) return "red";
if (colors.includes("yellow")) return "yellow";
return "green";
}
// Parse the wall-clock date/time directly from an ISO-8601 string that
// already carries an offset (e.g. "2026-03-31T10:00:00-03:00").
// We never let the browser re-interpret it into its own timezone.
function parseISOParts(dateTimeStr) {
// Matches: YYYY-MM-DDTHH:MM:SS(±HH:MM or Z)
const m = dateTimeStr.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/,
);
if (!m) return null;
return {
year: parseInt(m[1], 10),
month: parseInt(m[2], 10),
day: parseInt(m[3], 10),
hour: parseInt(m[4], 10),
minute: parseInt(m[5], 10),
};
}
function formatLocalTime(dateTimeStr) {
const p = parseISOParts(dateTimeStr);
if (!p) return "";
const h = String(p.hour).padStart(2, "0");
const m = String(p.minute).padStart(2, "0");
return `${h}:${m}`;
}
function formatLocalDate(dateTimeStr) {
const p = parseISOParts(dateTimeStr);
if (!p) return "";
// Build a UTC date just for the locale label so day/month names are correct
const d = new Date(Date.UTC(p.year, p.month - 1, p.day));
return d.toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
function isToday(dateTimeStr) {
const now = new Date();
const p = parseISOParts(dateTimeStr);
if (!p) return false;
return (
p.year === now.getFullYear() &&
p.month === now.getMonth() + 1 &&
p.day === now.getDate()
);
}
function isSoonOrOngoing(startStr, endStr) {
const now = new Date();
const start = new Date(startStr);
const end = new Date(endStr);
const diffMin = (start - now) / 60000;
if (now >= start && now <= end) return true;
if (diffMin >= 0 && diffMin <= 30) return true;
return false;
}
// ---------------------------------------------------------------------------
// Server cards
// ---------------------------------------------------------------------------
function buildServerCard(s) {
const ramPct = pct(s.free_ram, s.available_ram);
const hddPct = pct(s.free_hdd, s.available_hdd);
const ramColor = metricColor(ramPct);
const hddColor = metricColor(hddPct);
const lightColor = worstColor([ramColor, hddColor]);
const displayNames = {
saopaulo: "São Paulo",
manchester: "Manchester",
mumbai: "Mumbai",
sydney: "Sydney",
meppel: "Meppel",
boston: "Boston",
};
const displayName =
displayNames[s.Server] ||
s.Server.charAt(0).toUpperCase() + s.Server.slice(1);
return {
lightColor,
html: `
<div class="server">
<div class="server-name">${displayName}</div>
<div class="status-light ${lightColor}"></div>
<div class="metrics">
<div class="metric-row">
<span>RAM:</span>
<span class="${ramColor}-text">${ramPct.toFixed(0)}% free (${mbToGb(s.free_ram)} GB)</span>
</div>
<div class="metric-row">
<span>HDD:</span>
<span class="${hddColor}-text">${hddPct.toFixed(0)}% free (${mbToGb(s.free_hdd)} GB)</span>
</div>
</div>
</div>
`,
};
}
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------
function parseMeetings(calEvents, calName) {
const seen = new Set();
const result = [];
for (const e of calEvents) {
const ev = e.json || e;
if (seen.has(ev.id)) continue;
seen.add(ev.id);
result.push({ ...ev, _calName: calName });
}
return result;
}
function buildMeetingCard(m) {
const startStr =
(m.start && (m.start.dateTime || m.start.date)) || "";
const endStr = (m.end && (m.end.dateTime || m.end.date)) || "";
const soon =
startStr && endStr
? isSoonOrOngoing(startStr, endStr)
: false;
const today = startStr ? isToday(startStr) : false;
const badges = (m._cals || [])
.map((c) => {
if (c === "ODOO4projects")
return `<span class="badge badge-odoo">ODOO4</span>`;
if (c === "Brandize")
return `<span class="badge badge-brandize">Brandize</span>`;
return "";
})
.join("");
const dateLabel =
today || !startStr
? ""
: `<span style="font-size:10px;opacity:0.55;margin-right:4px;">${formatLocalDate(startStr)}</span>`;
const timeLabel =
startStr && endStr
? `${formatLocalTime(startStr)} ${formatLocalTime(endStr)}`
: "";
return `
<div class="meeting${soon ? " soon" : ""}">
<div class="meeting-header">
<div class="meeting-name">${m.summary || "Meeting"}</div>
<div class="meeting-badges">${badges}</div>
</div>
<div class="time">${dateLabel}${timeLabel}</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Todos
// ---------------------------------------------------------------------------
function buildTodoItem(t) {
const done = !!t.done;
const checkInner = done
? `<svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>`
: "";
return `
<div class="todo${done ? " done-todo" : ""}">
<div class="todo-check${done ? " checked" : ""}">${checkInner}</div>
<span>${t.name || ""}</span>
${t.Minutes != null ? `<span style="margin-left:auto;opacity:0.55;font-size:12px;flex-shrink:0;">${t.Minutes}</span>` : ""}
</div>
`;
}
// ---------------------------------------------------------------------------
// Latest Signup item
// ---------------------------------------------------------------------------
function buildSignupItem(ct) {
if (!ct) return "";
const tagBadges = (ct.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => `<span class="signup-tag">${t}</span>`)
.join("");
return `
<div class="signup-item">
<div class="signup-email">${ct.email || ""}</div>
<div class="signup-meta">
<span class="signup-server">&#128421; ${ct.server || ""}</span>
${tagBadges}
<span style="opacity:0.5;font-size:10px;">${ct.status || ""}</span>
</div>
<div style="margin-top:4px;opacity:0.55;font-size:10px;">
${ct.Domains ? ct.Domains : ""}${ct.expires ? " &middot; expires " + ct.expires : ""}
</div>
</div>
`;
}
function buildSignupItems(raw) {
if (!raw) return { html: "", count: 0 };
const items = Array.isArray(raw) ? raw : [raw];
const html = items.map(buildSignupItem).join("");
return { html, count: items.length };
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render(data) {
const serverStatusRaw = data["Server Status"] || [];
const calOdoo = data["Calender ODOO4projects"] || [];
const calBrandize = data["Calender Brandize"] || [];
const todos = data["Todos"] || [];
const currentTestsRaw = data["Current Tests"] || null;
// Deduplicate servers by Server key
const serverMap = {};
for (const s of serverStatusRaw) {
if (!serverMap[s.Server]) serverMap[s.Server] = s;
}
const servers = Object.values(serverMap);
// Server cards
const serverCards = servers.map(buildServerCard);
const overallColor = worstColor(
serverCards.map((c) => c.lightColor),
);
const serverCardsHtml = serverCards.map((c) => c.html).join("");
// Meetings merge & deduplicate across both calendars
const odooMeetings = parseMeetings(calOdoo, "ODOO4projects");
const brandizeMeetings = parseMeetings(calBrandize, "Brandize");
const allMeetings = [...odooMeetings, ...brandizeMeetings];
const meetingById = {};
for (const m of allMeetings) {
if (!meetingById[m.id]) {
meetingById[m.id] = { ...m, _cals: [m._calName] };
} else if (!meetingById[m.id]._cals.includes(m._calName)) {
meetingById[m.id]._cals.push(m._calName);
}
}
const dedupedMeetings = Object.values(meetingById).sort(
(a, b) => {
const aStart =
(a.start && (a.start.dateTime || a.start.date)) ||
"";
const bStart =
(b.start && (b.start.dateTime || b.start.date)) ||
"";
return new Date(aStart) - new Date(bStart);
},
);
const meetingsHtml = dedupedMeetings
.map(buildMeetingCard)
.join("");
const todosHtml = todos.map(buildTodoItem).join("");
const totalMinutes = todos.reduce(
(sum, t) => sum + (t.Minutes || 0),
0,
);
const totalHours = (totalMinutes / 60)
.toFixed(1)
.replace(/\.0$/, "");
const signups = buildSignupItems(currentTestsRaw);
document.getElementById("dashboard").innerHTML = `
<!-- SERVER STATUS -->
<div class="box" style="grid-column:1; grid-row:1/3;">
<div class="title">Server Status</div>
<div class="status-light ${overallColor}"></div>
<div class="servers">
${serverCardsHtml}
</div>
</div>
<!-- TODAY'S MEETINGS -->
<div class="box" style="grid-column:2; grid-row:1;">
<div class="title">Meetings</div>
<div class="meetings-scroll">
${meetingsHtml || '<div style="opacity:0.4;font-size:13px;">No meetings</div>'}
</div>
</div>
<!-- LATEST SIGNUPS -->
<div class="box" style="grid-column:3; grid-row:1/3;">
<div class="title">Latest Signups <span style="font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;margin-left:6px;">${signups.count}</span></div>
<div class="signups-scroll">
${signups.html || '<div style="opacity:0.4;font-size:13px;">No signups</div>'}
</div>
</div>
<!-- TODOS -->
<div class="box" style="grid-column:2; grid-row:2;">
<div class="title" style="display:flex;align-items:center;">Today's Todos<span style="margin-left:auto;font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;">${totalHours}h</span></div>
<div class="todos-scroll">
${todosHtml || '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Fetch & refresh loop
// ---------------------------------------------------------------------------
async function loadData() {
try {
const res = await fetch(WEBHOOK_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
} catch (err) {
document.getElementById("dashboard").innerHTML = `
<div class="error-msg" style="grid-column:1/-1;grid-row:1/-1;padding:40px;">
&#9888; Failed to load dashboard data: ${err.message}<br>
<span style="font-size:11px;opacity:0.6;">Retrying in ${REFRESH_INTERVAL_MS / 1000}s…</span>
</div>
`;
}
}
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script>
</body>
</html>
+820
View File
@@ -0,0 +1,820 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Server Manager · my-biz.app</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #080c14;
--bg2: #0d1220;
--surface: #111827;
--surface2: #161f30;
--border: #1e2d45;
--border-hi: #243450;
--glow: #3b82f6;
--glow2: #06b6d4;
--text: #e2e8f0;
--text-muted: #64748b;
--text-subtle: #334155;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-dim: rgba(59,130,246,.12);
--cyan: #06b6d4;
--cyan-dim: rgba(6,182,212,.1);
--success: #10b981;
--success-dim: rgba(16,185,129,.12);
--warn: #f59e0b;
--warn-dim: rgba(245,158,11,.12);
--error: #ef4444;
--error-dim: rgba(239,68,68,.12);
--radius-sm: 5px;
--radius: 8px;
--radius-lg: 12px;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
html { font-size: 15px; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 99px; }
/* ── Header ── */
header {
background: rgba(8,12,20,.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 0 1.5rem;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-size: .9rem;
font-weight: 700;
letter-spacing: -.02em;
display: flex;
align-items: center;
gap: .3rem;
color: var(--text);
}
.logo .dot { color: var(--accent); }
.logo .sep { color: var(--text-subtle); font-weight: 300; margin: 0 .35rem; }
.logo .sub { font-weight: 400; color: var(--text-muted); font-size: .82rem; }
.uuid-chip {
display: inline-flex;
align-items: center;
gap: .4rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: .28rem .65rem;
font-size: .72rem;
font-family: var(--mono);
color: var(--text-muted);
cursor: pointer;
transition: border-color .15s, color .15s;
}
.uuid-chip:hover { border-color: var(--accent); color: var(--accent); }
.uuid-chip svg { width: 11px; height: 11px; flex-shrink: 0; }
/* ── Main ── */
main {
max-width: 860px;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
/* ── Overlay / Modal ── */
.overlay {
position: fixed;
inset: 0;
background: rgba(5,8,16,.75);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.overlay.hidden { display: none; }
.modal {
background: var(--surface);
border: 1px solid var(--border-hi);
border-radius: var(--radius-lg);
box-shadow: 0 0 0 1px rgba(59,130,246,.08), 0 24px 48px rgba(0,0,0,.6);
width: 100%;
max-width: 400px;
overflow: hidden;
}
.modal-header {
padding: 1.5rem 1.5rem .75rem;
text-align: center;
border-bottom: 1px solid var(--border);
}
.modal-logo {
font-size: 1rem;
font-weight: 700;
letter-spacing: -.02em;
color: var(--text);
margin-bottom: 1rem;
}
.modal-logo .dot { color: var(--accent); }
.modal-header h2 { font-size: 1rem; font-weight: 600; color: var(--text); }
.modal-header p { font-size: .8rem; color: var(--text-muted); margin-top: .3rem; }
.modal-body { padding: 1.25rem 1.5rem 1.5rem; }
/* ── Form elements ── */
.field { margin-bottom: .9rem; }
.field:last-of-type { margin-bottom: 0; }
label {
display: block;
font-size: .72rem;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: .3rem;
}
input[type="text"],
input[type="email"],
input[type="date"],
input[type="number"],
input[type="password"],
textarea,
select {
width: 100%;
padding: .5rem .75rem;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: .85rem;
color: var(--text);
outline: none;
transition: border-color .15s, box-shadow .15s;
-webkit-appearance: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
}
textarea { resize: vertical; min-height: 64px; }
select {
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .7rem center;
padding-right: 2rem;
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4rem;
padding: .5rem 1rem;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: .82rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all .15s;
outline: none;
white-space: nowrap;
}
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #fff;
box-shadow: 0 0 16px rgba(59,130,246,.3);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
box-shadow: 0 0 24px rgba(59,130,246,.45);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
}
.btn-ghost:hover:not(:disabled) { background: var(--surface2); color: var(--text); }
.btn-full { width: 100%; margin-top: .75rem; padding: .6rem; font-size: .875rem; border-radius: var(--radius); }
/* ── Alert ── */
.alert {
display: flex;
align-items: flex-start;
gap: .6rem;
padding: .65rem .85rem;
border-radius: var(--radius-sm);
font-size: .8rem;
margin-bottom: .9rem;
border: 1px solid;
}
.alert svg { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; }
.alert-error { background: var(--error-dim); color: var(--error); border-color: rgba(239,68,68,.25); }
.alert-success { background: var(--success-dim); color: var(--success); border-color: rgba(16,185,129,.25); }
/* ── Spinner ── */
.spinner {
width: 13px; height: 13px;
border: 2px solid rgba(255,255,255,.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin .55s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Toast ── */
#toast {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 500;
min-width: 240px;
max-width: 340px;
}
@keyframes fadeUp {
from { opacity:0; transform:translateY(6px); }
to { opacity:1; transform:translateY(0); }
}
/* ══════════════════════════════════════════
READ-ONLY INFO PANEL
══════════════════════════════════════════ */
.info-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: .1rem 0;
}
.info-row {
display: contents;
}
.info-block {
padding: .5rem .75rem;
border-right: 1px solid var(--border);
}
.info-block:last-child,
.info-block:nth-child(4n) { border-right: none; }
.info-block .i-label {
font-size: .65rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--text-subtle);
margin-bottom: .2rem;
}
.info-block .i-val {
font-size: .82rem;
font-weight: 500;
color: var(--text);
font-family: var(--mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.info-block .i-val.long {
font-size: .72rem;
}
.info-block .i-val.dim { color: var(--text-muted); font-family: inherit; }
/* divider between rows inside the panel */
.info-divider {
grid-column: 1 / -1;
height: 1px;
background: var(--border);
margin: .25rem 0;
}
/* status badge */
.badge {
display: inline-flex;
align-items: center;
gap: .28rem;
padding: .18rem .5rem;
border-radius: 99px;
font-size: .68rem;
font-weight: 600;
letter-spacing: .04em;
font-family: inherit;
}
.badge-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.badge.active { background: var(--success-dim); color: var(--success); }
.badge.test { background: var(--warn-dim); color: var(--warn); }
.badge.inactive,
.badge.suspended{ background: var(--error-dim); color: var(--error); }
/* ══════════════════════════════════════════
EDIT CARD
══════════════════════════════════════════ */
.edit-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.edit-card-header {
padding: .75rem 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface2);
}
.edit-card-title {
font-size: .78rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: .06em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: .5rem;
}
.edit-card-title svg { width: 14px; height: 14px; stroke: var(--accent); }
.edit-body {
padding: 1rem 1.25rem;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: .6rem 1rem;
}
.edit-body .span2 { grid-column: span 2; }
.edit-body .span3 { grid-column: span 3; }
.compact-field label {
font-size: .65rem;
margin-bottom: .22rem;
}
.compact-field input,
.compact-field textarea,
.compact-field select {
padding: .38rem .6rem;
font-size: .82rem;
}
.compact-field textarea { min-height: 56px; }
.edit-footer {
padding: .75rem 1.25rem;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface2);
gap: .75rem;
}
/* ── Misc ── */
.hidden { display: none !important; }
.mono { font-family: var(--mono); }
/* glow accent line on top of cards */
.glow-top {
position: relative;
}
.glow-top::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), var(--cyan), transparent);
opacity: .6;
}
</style>
</head>
<body>
<!-- ══ UUID MODAL ══ -->
<div class="overlay" id="uuidOverlay">
<div class="modal glow-top">
<div class="modal-header">
<div class="modal-logo">my-biz<span class="dot">.</span>app</div>
<h2>Server Manager</h2>
<p>Enter your UUID to load server data.</p>
</div>
<div class="modal-body">
<div id="uuidError" class="alert alert-error hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="uuidErrorMsg">Could not fetch server data.</span>
</div>
<div class="field">
<label for="uuidInput">UUID</label>
<input type="text" id="uuidInput" placeholder="006-003-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" spellcheck="false" style="font-family:var(--mono);font-size:.8rem" />
</div>
<button class="btn btn-primary btn-full" id="uuidSubmitBtn" onclick="submitUUID()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
Load Server
</button>
</div>
</div>
</div>
<!-- ══ AUTH MODAL ══ -->
<div class="overlay hidden" id="authOverlay">
<div class="modal glow-top">
<div class="modal-header">
<div class="modal-logo">my-biz<span class="dot">.</span>app</div>
<h2>Authentication Required</h2>
<p>Enter your credentials to continue.</p>
</div>
<div class="modal-body">
<div class="field">
<label for="authUser">Username</label>
<input type="text" id="authUser" placeholder="username" autocomplete="username" />
</div>
<div class="field">
<label for="authPass">Password</label>
<input type="password" id="authPass" placeholder="••••••••" autocomplete="current-password" />
</div>
<button class="btn btn-primary btn-full" id="authSubmitBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Connect
</button>
</div>
</div>
</div>
<!-- ══ HEADER ══ -->
<header>
<div class="logo">
my-biz<span class="dot">.</span>app
<span class="sep">/</span>
<span class="sub">Server Manager</span>
</div>
<div id="uuidChip" class="uuid-chip hidden" onclick="resetUUID()" title="Click to change">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span id="uuidChipLabel"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
</div>
</header>
<!-- ══ MAIN ══ -->
<main>
<!-- READ-ONLY INFO PANEL -->
<div id="infoPanel" class="info-panel glow-top hidden">
<div class="info-block">
<div class="i-label">ID</div>
<div class="i-val" id="i_ID"></div>
</div>
<div class="info-block">
<div class="i-label">Status</div>
<div class="i-val" id="i_status"></div>
</div>
<div class="info-block">
<div class="i-label">Created</div>
<div class="i-val dim" id="i_created"></div>
</div>
<div class="info-block">
<div class="i-label">Expires</div>
<div class="i-val dim" id="i_expires"></div>
</div>
<div class="info-divider"></div>
<div class="info-block" style="grid-column:span 4">
<div class="i-label">UUID</div>
<div class="i-val long" id="i_UUID"></div>
</div>
<div class="info-divider"></div>
<div class="info-block">
<div class="i-label">HDD</div>
<div class="i-val" id="i_hdd"></div>
</div>
<div class="info-block">
<div class="i-label">Workers</div>
<div class="i-val" id="i_workers"></div>
</div>
<div class="info-block">
<div class="i-label">Backup Slots</div>
<div class="i-val" id="i_backup_slots"></div>
</div>
<div class="info-block">
<div class="i-label">Domain Slots</div>
<div class="i-val" id="i_domains_slots"></div>
</div>
<div class="info-divider"></div>
<div class="info-block">
<div class="i-label">Git</div>
<div class="i-val" id="i_git"></div>
</div>
<div class="info-block" style="grid-column:span 2">
<div class="i-label">Domains</div>
<div class="i-val dim" id="i_domains"></div>
</div>
<div class="info-block">
<div class="i-label">Image</div>
<div class="i-val dim" id="i_image"></div>
</div>
</div>
<!-- EDIT CARD -->
<div id="editCard" class="edit-card glow-top hidden">
<div class="edit-card-header">
<div class="edit-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
Edit Configuration
</div>
<div id="formAlert" class="hidden" style="margin:0;font-size:.75rem"></div>
</div>
<div class="edit-body">
<!-- Row 1: email, tags -->
<div class="compact-field">
<label for="f_email">Email</label>
<input type="email" id="f_email" placeholder="user@example.com" />
</div>
<div class="compact-field">
<label for="f_tags">Tags</label>
<input type="text" id="f_tags" placeholder="odoo_19, production" />
</div>
<!-- Row 2: env list -->
<div class="compact-field span3">
<label for="f_env_list">Environment Variables</label>
<textarea id="f_env_list" placeholder="KEY=value&#10;OTHER=value"></textarea>
</div>
<!-- Row 3: UTM all in one line -->
<div class="compact-field">
<label for="f_utm_source">UTM Source</label>
<input type="text" id="f_utm_source" placeholder="google" />
</div>
<div class="compact-field">
<label for="f_utm_medium">UTM Medium</label>
<input type="text" id="f_utm_medium" placeholder="cpc" />
</div>
<div class="compact-field">
<label for="f_utm_campaign">UTM Campaign</label>
<input type="text" id="f_utm_campaign" placeholder="launch_2026" />
</div>
<!-- Row 4: comment -->
<div class="compact-field span3">
<label for="f_comment">Comment</label>
<textarea id="f_comment" placeholder="Internal notes…" style="min-height:48px"></textarea>
</div>
<!-- Row 4: history -->
<div class="compact-field span3">
<label for="f_history">History</label>
<textarea id="f_history" placeholder="Change history…" style="min-height:48px"></textarea>
</div>
</div>
<div class="edit-footer">
<button class="btn btn-ghost" onclick="resetUUID()" style="font-size:.75rem">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5"/><path d="m12 5-7 7 7 7"/></svg>
Change UUID
</button>
<button class="btn btn-primary" id="saveBtn" onclick="saveServer()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Changes
</button>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const WEBHOOK = 'https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/6ffa7889-993b-4b12-b7de-29d743f956dd';
let currentUUID = '';
let credentials = null;
let serverData = null;
const $ = id => document.getElementById(id);
/* ── Auth ── */
function authHeaders() {
if (!credentials) return {};
return { 'Authorization': 'Basic ' + btoa(credentials.user + ':' + credentials.pass) };
}
function showAuthOverlay(onSuccess) {
$('authOverlay').classList.remove('hidden');
$('authUser').focus();
$('authSubmitBtn').onclick = () => {
const user = $('authUser').value.trim();
const pass = $('authPass').value;
if (!user) return;
credentials = { user, pass };
$('authOverlay').classList.add('hidden');
onSuccess();
};
}
async function apiFetch(method, body) {
const opts = {
method,
cache: 'no-store',
credentials: 'omit',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const url = method === 'GET'
? WEBHOOK + '?uuid=' + encodeURIComponent(currentUUID) + '&_t=' + Date.now()
: WEBHOOK;
const res = await fetch(url, opts);
if (res.status === 401 || res.status === 403) throw new Error('AUTH');
if (!res.ok) throw new Error('HTTP ' + res.status);
const text = await res.text();
try { return text ? JSON.parse(text) : {}; } catch { return {}; }
}
async function withAuth(fn) {
try { return await fn(); }
catch (e) {
if (e.message === 'AUTH' || e.message.includes('NetworkError') || e.message.includes('Failed to fetch')) {
return new Promise((resolve, reject) => {
showAuthOverlay(async () => {
try { resolve(await fn()); } catch (e2) { reject(e2); }
});
});
}
throw e;
}
}
/* ── Toast ── */
function showToast(msg, type = 'success') {
$('toast').innerHTML = `<div class="alert alert-${type}" style="box-shadow:0 8px 32px rgba(0,0,0,.5);animation:fadeUp .2s ease">${msg}</div>`;
setTimeout(() => { $('toast').innerHTML = ''; }, 4000);
}
/* ── UUID submit ── */
async function submitUUID() {
const uuid = $('uuidInput').value.trim();
$('uuidError').classList.add('hidden');
if (!uuid) { flashField('uuidInput'); return; }
currentUUID = uuid;
const btn = $('uuidSubmitBtn');
btn.disabled = true;
btn.innerHTML = `<div class="spinner"></div> Loading…`;
try {
const raw = await withAuth(() => apiFetch('GET'));
const record = Array.isArray(raw) ? raw[0]?.json : raw?.json ?? raw;
if (!record) throw new Error('Unexpected response format.');
serverData = record;
populateAll(record);
$('uuidOverlay').classList.add('hidden');
$('uuidChip').classList.remove('hidden');
$('uuidChipLabel').textContent = uuid.length > 30 ? uuid.slice(0, 30) + '…' : uuid;
$('infoPanel').classList.remove('hidden');
$('editCard').classList.remove('hidden');
} catch (err) {
currentUUID = '';
$('uuidErrorMsg').textContent = err.message || 'Could not load server data.';
$('uuidError').classList.remove('hidden');
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg> Load Server`;
}
}
function flashField(id) {
const el = $(id);
el.style.borderColor = 'var(--error)';
el.focus();
setTimeout(() => { el.style.borderColor = ''; }, 1800);
}
/* ── Populate ── */
function populateAll(d) {
// Info panel (read-only)
const status = (d.status || '').toLowerCase();
const statusLabels = { active: 'Active', test: 'Test', inactive: 'Inactive', suspended: 'Suspended' };
$('i_ID').textContent = d.ID ?? '—';
$('i_status').innerHTML = `<span class="badge ${status}"><span class="badge-dot"></span>${statusLabels[status] ?? status}</span>`;
$('i_created').textContent = d.created || '—';
$('i_expires').textContent = d.expires || '—';
$('i_UUID').textContent = d.UUID || '—';
$('i_hdd').textContent = d.hdd != null ? d.hdd + ' GB' : '—';
$('i_workers').textContent = d.workers ?? '—';
$('i_backup_slots').textContent= d.backup_slots ?? '—';
$('i_domains_slots').textContent= d.domains_slots ?? '—';
$('i_git').textContent = d.git ? 'Enabled' : 'Disabled';
$('i_domains').textContent = d.domains || '—';
$('i_image').textContent = d.image || '—';
// Edit form
const set = (id, val) => { const el = $(id); if (el) el.value = val ?? ''; };
set('f_email', d.email);
set('f_tags', d.tags);
set('f_env_list', d.env_list);
set('f_comment', d.comment);
set('f_utm_source', d.utm_source);
set('f_utm_medium', d.utm_medium);
set('f_utm_campaign', d.utm_campaign);
set('f_history', d.history);
}
/* ── Save ── */
async function saveServer() {
const btn = $('saveBtn');
btn.disabled = true;
btn.innerHTML = `<div class="spinner"></div> Saving…`;
$('formAlert').classList.add('hidden');
const payload = {
...serverData,
email: $('f_email').value,
tags: $('f_tags').value || null,
env_list: $('f_env_list').value || null,
comment: $('f_comment').value || null,
utm_source: $('f_utm_source').value || null,
utm_medium: $('f_utm_medium').value || null,
utm_campaign: $('f_utm_campaign').value || null,
history: $('f_history').value || null,
};
try {
await withAuth(() => apiFetch('POST', { json: payload }));
// Update status badge in info panel live
serverData = { ...serverData, ...payload };
const status = payload.status.toLowerCase();
const statusLabels = { active: 'Active', test: 'Test', inactive: 'Inactive', suspended: 'Suspended' };
$('i_status').innerHTML = `<span class="badge ${status}"><span class="badge-dot"></span>${statusLabels[status] ?? status}</span>`;
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg> Save Changes`;
showToast(`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="flex-shrink:0"><polyline points="20 6 9 17 4 12"/></svg> <span><strong>Saved</strong> — server record updated.</span>`);
} catch (err) {
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg> Save Changes`;
const a = $('formAlert');
a.className = 'alert alert-error';
a.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span>${err.message}</span>`;
a.classList.remove('hidden');
}
}
/* ── Reset ── */
function resetUUID() {
currentUUID = '';
credentials = null;
serverData = null;
$('uuidInput').value = '';
$('authUser').value = '';
$('authPass').value = '';
$('uuidError').classList.add('hidden');
$('infoPanel').classList.add('hidden');
$('editCard').classList.add('hidden');
$('uuidChip').classList.add('hidden');
$('formAlert').classList.add('hidden');
$('uuidOverlay').classList.remove('hidden');
const btn = $('uuidSubmitBtn');
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg> Load Server`;
}
/* ── Keyboard ── */
$('uuidInput').addEventListener('keydown', e => { if (e.key === 'Enter') submitUUID(); });
$('authUser').addEventListener('keydown', e => { if (e.key === 'Enter') $('authPass').focus(); });
$('authPass').addEventListener('keydown', e => { if (e.key === 'Enter') $('authSubmitBtn').click(); });
</script>
</body>
</html>
+720
View File
@@ -0,0 +1,720 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo List</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 0.05em;
margin-bottom: 1.5rem;
color: #818cf8;
}
/* Auth Modal */
#auth-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
align-items: center;
justify-content: center;
}
#auth-overlay.visible {
display: flex;
}
#auth-box {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 2rem;
width: 320px;
display: flex;
flex-direction: column;
gap: 1rem;
}
#auth-box h2 {
font-size: 1.1rem;
color: #f87171;
text-align: center;
}
#auth-box p {
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
}
#auth-box input {
padding: 0.55rem 0.75rem;
border-radius: 6px;
border: 1px solid #475569;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
width: 100%;
}
#auth-box input:focus {
outline: none;
border-color: #818cf8;
}
#auth-box button {
padding: 0.55rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
font-weight: 600;
transition: background 0.15s;
}
#auth-box button:hover {
background: #6366f1;
}
/* Card */
#app {
width: 100%;
max-width: 820px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
/* Toolbar */
#toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.2rem;
border-bottom: 1px solid #334155;
background: #1e293b;
gap: 0.75rem;
}
#toolbar span {
font-size: 0.8rem;
color: #64748b;
}
#btn-refresh,
#btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
border-radius: 7px;
border: none;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
transition:
background 0.15s,
transform 0.1s;
}
#btn-refresh {
background: #334155;
color: #94a3b8;
}
#btn-refresh:hover {
background: #475569;
color: #e2e8f0;
}
#btn-add {
background: #4f46e5;
color: #fff;
}
#btn-add:hover {
background: #6366f1;
}
#btn-add:active,
#btn-refresh:active {
transform: scale(0.96);
}
/* Status */
#status {
font-size: 0.75rem;
padding: 0.4rem 1.2rem;
min-height: 28px;
display: flex;
align-items: center;
gap: 0.5rem;
color: #64748b;
border-bottom: 1px solid #1e293b;
background: #162032;
transition: color 0.3s;
}
#status.ok {
color: #34d399;
}
#status.error {
color: #f87171;
}
#status.info {
color: #818cf8;
}
/* Spinner */
.spin {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Table */
#table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead {
background: #162032;
position: sticky;
top: 0;
z-index: 1;
}
thead th {
padding: 0.65rem 0.75rem;
text-align: left;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
white-space: nowrap;
}
thead th.center {
text-align: center;
}
tbody tr {
border-bottom: 1px solid #1e293b;
transition: background 0.12s;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: #243049;
}
tbody tr.saving {
opacity: 0.6;
pointer-events: none;
}
td {
padding: 0.5rem 0.75rem;
vertical-align: middle;
}
td.center {
text-align: center;
}
/* Checkbox */
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #6366f1;
cursor: pointer;
}
/* Text inputs in table */
td input[type="text"],
td input[type="number"] {
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
color: #e2e8f0;
padding: 0.3rem 0.4rem;
font-size: 0.85rem;
width: 100%;
transition:
border-color 0.15s,
background 0.15s;
}
td input[type="text"]:hover,
td input[type="number"]:hover {
border-color: #334155;
background: #0f172a;
}
td input[type="text"]:focus,
td input[type="number"]:focus {
outline: none;
border-color: #6366f1;
background: #0f172a;
}
td.done-row input[type="text"] {
text-decoration: line-through;
color: #64748b;
}
/* Minutes column */
td input[type="number"] {
width: 70px;
}
/* Delete button */
.btn-del {
padding: 0.3rem 0.65rem;
background: transparent;
border: 1px solid #7f1d1d;
color: #f87171;
border-radius: 6px;
font-size: 0.78rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
white-space: nowrap;
}
.btn-del:hover {
background: #7f1d1d;
color: #fff;
}
/* Empty state */
#empty {
display: none;
text-align: center;
padding: 3rem 1rem;
color: #475569;
font-size: 0.9rem;
}
#empty svg {
margin-bottom: 0.75rem;
opacity: 0.3;
}
/* New row highlight */
@keyframes highlight {
from {
background: #312e81;
}
to {
background: transparent;
}
}
tbody tr.new-row {
animation: highlight 1.2s ease-out;
}
</style>
</head>
<body>
<!-- Auth Modal -->
<div id="auth-overlay">
<div id="auth-box">
<h2>🔒 Authentication Required</h2>
<p>
A connection error occurred. Please enter your credentials.
</p>
<input
type="text"
id="auth-user"
placeholder="Username"
autocomplete="username"
/>
<input
type="password"
id="auth-pass"
placeholder="Password"
autocomplete="current-password"
/>
<button id="auth-submit">Connect</button>
</div>
</div>
<!-- Main App -->
<div id="app">
<div id="toolbar">
<div style="display: flex; gap: 0.5rem; align-items: center">
<button id="btn-refresh">⟳ Refresh</button>
<span id="count"></span>
</div>
<button id="btn-add">+ New Todo</button>
</div>
<div id="status"></div>
<div id="table-wrap">
<table>
<thead>
<tr>
<th class="center">Done</th>
<th>Name</th>
<th class="center">Today</th>
<th class="center">Minutes</th>
<th class="center">Delete</th>
</tr>
</thead>
<tbody id="todo-body"></tbody>
</table>
<div id="empty">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M9 12l2 2 4-4" />
</svg>
<div>No todos yet. Create one!</div>
</div>
</div>
</div>
<script>
const API =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/45d6f0be-6242-4cba-89ff-d1f0497591d1";
let credentials = null; // { user, pass }
let pendingSave = {}; // debounce timers per row id
let isFetching = false;
// ── Auth helpers ──────────────────────────────────────────────────────────
function authHeaders() {
if (!credentials) return {};
const b64 = btoa(credentials.user + ":" + credentials.pass);
return { Authorization: "Basic " + b64 };
}
function showAuth(onSuccess) {
const overlay = document.getElementById("auth-overlay");
overlay.classList.add("visible");
document.getElementById("auth-user").focus();
document.getElementById("auth-submit").onclick = () => {
const user = document
.getElementById("auth-user")
.value.trim();
const pass = document.getElementById("auth-pass").value;
if (!user) return;
credentials = { user, pass };
overlay.classList.remove("visible");
onSuccess();
};
}
// ── Status bar ────────────────────────────────────────────────────────────
function setStatus(msg, type = "", spin = false) {
const el = document.getElementById("status");
el.className = type;
el.innerHTML =
(spin ? '<span class="spin"></span> ' : "") + msg;
}
// ── API calls ─────────────────────────────────────────────────────────────
async function apiFetch(method, body) {
const opts = {
method,
cache: "no-store",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
};
if (body !== undefined) opts.body = JSON.stringify(body);
const url = method === "GET" ? API + "?_t=" + Date.now() : API;
const res = await fetch(url, opts);
if (res.status === 401 || res.status === 403)
throw new Error("AUTH");
if (!res.ok) throw new Error("HTTP " + res.status);
const text = await res.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function withAuth(fn) {
try {
return await fn();
} catch (e) {
if (
e.message === "AUTH" ||
e.message.includes("NetworkError") ||
e.message.includes("Failed to fetch")
) {
return new Promise((resolve, reject) => {
showAuth(async () => {
try {
resolve(await fn());
} catch (e2) {
reject(e2);
}
});
});
}
throw e;
}
}
// ── Load todos ────────────────────────────────────────────────────────────
async function loadTodos() {
if (isFetching) return;
isFetching = true;
setStatus("Loading…", "info", true);
try {
const data = await withAuth(() => apiFetch("GET"));
const items = Array.isArray(data)
? data
: data.items ||
data.result ||
data.data ||
(data &&
typeof data === "object" &&
data.id !== undefined
? [data]
: []);
renderTable(items);
setStatus("Loaded " + items.length + " item(s)", "ok");
document.getElementById("count").textContent =
items.length + " items";
} catch (e) {
setStatus("Error: " + e.message, "error");
} finally {
isFetching = false;
}
}
// ── Render table ──────────────────────────────────────────────────────────
function renderTable(items) {
const tbody = document.getElementById("todo-body");
const empty = document.getElementById("empty");
tbody.innerHTML = "";
if (!items.length) {
empty.style.display = "block";
return;
}
empty.style.display = "none";
items.forEach((item) => tbody.appendChild(buildRow(item)));
}
function buildRow(item, isNew = false) {
const id = item.id ?? item._id ?? item.ID ?? "";
const toBool = (v) => v === 1 || v === true || v === "1";
const done = toBool(item.done);
const name = item.name ?? item.title ?? "";
const today = toBool(item.today ?? item.Today);
const mins = item.minutes ?? item.Minutes ?? item.mins ?? "";
const tr = document.createElement("tr");
tr.dataset.id = id;
if (isNew) tr.classList.add("new-row");
if (done)
tr.querySelector && setTimeout(() => styleRow(tr, done), 0);
tr.innerHTML = `
<td class="center">
<input type="checkbox" class="f-done" ${done ? "checked" : ""} title="Done"/>
</td>
<td class="${done ? "done-row" : ""}">
<input type="text" class="f-name" value="${escHtml(name)}" placeholder="Task name…"/>
</td>
<td class="center">
<input type="checkbox" class="f-today" ${today ? "checked" : ""} title="Today"/>
</td>
<td class="center">
<input type="number" class="f-mins" value="${escHtml(String(mins))}" min="0" placeholder="0"/>
</td>
<td class="center">
<button class="btn-del" title="Delete">🗑 Delete</button>
</td>
`;
// Events: checkboxes → save immediately
tr.querySelector(".f-done").addEventListener("change", (e) => {
styleRow(tr, e.target.checked);
scheduleSave(tr);
});
tr.querySelector(".f-today").addEventListener("change", () =>
scheduleSave(tr),
);
// Events: text inputs → debounce save
tr.querySelector(".f-name").addEventListener("input", () =>
scheduleSave(tr, 600),
);
tr.querySelector(".f-mins").addEventListener("input", () =>
scheduleSave(tr, 600),
);
// Delete
tr.querySelector(".btn-del").addEventListener("click", () =>
deleteTodo(tr),
);
return tr;
}
function styleRow(tr, done) {
const nameTd = tr.querySelector("td:nth-child(2)");
if (done) nameTd.classList.add("done-row");
else nameTd.classList.remove("done-row");
}
// ── Save (POST) ───────────────────────────────────────────────────────────
function scheduleSave(tr, delay = 0) {
const id = tr.dataset.id;
clearTimeout(pendingSave[id]);
pendingSave[id] = setTimeout(() => saveTodo(tr), delay);
}
async function saveTodo(tr) {
const id = tr.dataset.id;
const done = tr.querySelector(".f-done").checked;
const name = tr.querySelector(".f-name").value;
const today = tr.querySelector(".f-today").checked;
const mins = tr.querySelector(".f-mins").value;
const payload = {
id,
done,
name,
today,
minutes: mins === "" ? null : Number(mins),
};
tr.classList.add("saving");
setStatus("Saving…", "info", true);
try {
await withAuth(() => apiFetch("POST", payload));
setStatus("Saved ✓", "ok");
} catch (e) {
setStatus("Save failed: " + e.message, "error");
} finally {
tr.classList.remove("saving");
}
}
// ── Delete ────────────────────────────────────────────────────────────────
async function deleteTodo(tr) {
const id = tr.dataset.id;
const name = tr.querySelector(".f-name").value || "this item";
if (!confirm('Delete "' + name + '"?')) return;
setStatus("Deleting…", "info", true);
try {
await withAuth(() => apiFetch("DELETE", { id }));
tr.style.transition = "opacity 0.25s";
tr.style.opacity = "0";
setTimeout(() => {
tr.remove();
const count =
document.querySelectorAll("#todo-body tr").length;
document.getElementById("count").textContent =
count + " items";
if (!count)
document.getElementById("empty").style.display =
"block";
setStatus("Deleted ✓", "ok");
}, 260);
} catch (e) {
setStatus("Delete failed: " + e.message, "error");
}
}
// ── Create new todo ───────────────────────────────────────────────────────
async function createTodo() {
const payload = {
done: false,
name: "",
today: false,
minutes: null,
};
setStatus("Creating…", "info", true);
try {
const res = await withAuth(() => apiFetch("POST", payload));
// Try to get id from response
const newItem = {
id: res.id ?? res._id ?? res.ID ?? "tmp_" + Date.now(),
done: false,
name: "",
today: false,
minutes: "",
};
const tbody = document.getElementById("todo-body");
document.getElementById("empty").style.display = "none";
const tr = buildRow(newItem, true);
tbody.prepend(tr);
tr.querySelector(".f-name").focus();
const count =
document.querySelectorAll("#todo-body tr").length;
document.getElementById("count").textContent =
count + " items";
setStatus("New todo created", "ok");
// Remove animation class after it plays
setTimeout(() => tr.classList.remove("new-row"), 1300);
} catch (e) {
setStatus("Create failed: " + e.message, "error");
}
}
// ── Utils ─────────────────────────────────────────────────────────────────
function escHtml(s) {
return (s ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ── Wire up buttons ───────────────────────────────────────────────────────
document
.getElementById("btn-refresh")
.addEventListener("click", loadTodos);
document
.getElementById("btn-add")
.addEventListener("click", createTodo);
// Enter key in auth form
document
.getElementById("auth-pass")
.addEventListener("keydown", (e) => {
if (e.key === "Enter")
document.getElementById("auth-submit").click();
});
// ── Initial load ──────────────────────────────────────────────────────────
loadTodos();
</script>
</body>
</html>
+371 -193
View File
@@ -1,269 +1,447 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Check and Upgrade Your Plan</title> <title>Your Plan</title>
<style> <style>
body { body {
font-family: "Inter", "Segoe UI", Arial, sans-serif; font-family: "Inter", "Segoe UI", Arial, sans-serif;
background-color: #f4f5f7; background-color: #f4f5f7;
color: #333; color: #333;
margin: 0; margin: 0;
padding: 40px; padding: 32px 16px;
display: flex;
justify-content: center;
} }
.summary-container { .page-title {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
padding: 40px;
max-width: 720px;
width: 100%;
}
h2 {
text-align: center; text-align: center;
color: #262626; color: #262626;
margin-bottom: 4px; font-size: 1.45em;
font-weight: 600; font-weight: 700;
}
/* New small centered contract name */
.contract-name {
text-align: center;
font-size: 0.85em;
color: #666;
margin-bottom: 25px;
}
.subtitle {
text-align: center;
font-size: 0.9em;
color: #777;
margin-bottom: 30px;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.grid-item {
background: #fafafa;
border: 1px solid #e2e2e2;
border-radius: 8px;
padding: 15px 20px;
}
.grid-item.full {
grid-column: 1 / -1;
}
.label {
font-weight: 600;
font-size: 0.9em;
color: #555;
margin-bottom: 6px; margin-bottom: 6px;
} }
.value { .page-subtitle {
font-size: 1.1em;
color: #222;
}
.footer {
text-align: center; text-align: center;
font-size: 0.9em; font-size: 1.05em;
color: #888; color: #444;
margin-top: 30px; margin-bottom: 32px;
letter-spacing: 0.01em;
line-height: 1.7;
font-weight: 400;
} }
/* Button styling */ .plans-row {
.actions { display: flex;
flex-wrap: wrap;
gap: 18px;
justify-content: center;
align-items: stretch;
}
.plan-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
padding: 26px 22px 20px;
width: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-top: 40px; text-align: center;
gap: 15px; position: relative;
transition:
box-shadow 0.2s,
border-color 0.2s,
transform 0.2s;
} }
.action-row { .plan-card:hover {
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.11);
transform: scale(1.03);
}
#card-ontherise {
border: 3px dotted #e0e0e0;
}
.plan-card.current {
border-color: #4caf50;
box-shadow:
0 0 0 3px rgba(76, 175, 80, 0.2),
0 4px 16px rgba(76, 175, 80, 0.15);
}
.plan-badge {
position: absolute;
top: -13px;
left: 50%;
transform: translateX(-50%);
background: #4caf50;
color: #fff;
font-size: 0.68em;
font-weight: 700;
padding: 3px 11px;
border-radius: 20px;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(76, 175, 80, 0.35);
letter-spacing: 0.01em;
}
.plan-name {
font-size: 1.05em;
font-weight: 700;
color: #262626;
margin-bottom: 14px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.plan-price {
font-size: 1.55em;
font-weight: 800;
color: #875a7b;
line-height: 1.1;
margin-bottom: 2px;
}
.plan-price-period {
font-size: 0.75em;
color: #aaa;
margin-bottom: 14px;
}
.plan-tagline {
font-size: 0.8em;
color: #666;
font-style: italic;
margin-bottom: 16px;
line-height: 1.5;
min-height: 52px;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
gap: 20px;
flex-wrap: wrap;
} }
.action-btn { .plan-btn {
background-color: #875A7B; /* Odoo purple */ background-color: #875a7b;
color: white; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 7px;
padding: 10px 22px; padding: 9px 0;
font-size: 15px; font-size: 0.88em;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease; width: 100%;
min-width: 180px; margin-bottom: 18px;
transition:
background-color 0.2s,
transform 0.1s;
letter-spacing: 0.01em;
} }
.action-btn:hover { .plan-btn:hover:not(:disabled) {
background-color: #744e6a; background-color: #744e6a;
transform: translateY(-1px); transform: translateY(-1px);
} }
.action-btn.secondary { .plan-btn:disabled {
background-color: #6c757d; background-color: #bdbdbd;
} color: #fff;
.action-btn.secondary:hover {
background-color: #5a636a;
}
.action-btn:disabled {
background-color: #ccc;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.plan-btn.is-current-plan:disabled {
background-color: #4caf50;
}
.plan-features {
list-style: none;
padding: 0;
margin: 0;
width: 100%;
text-align: left;
}
.plan-features li {
font-size: 0.78em;
color: #555;
padding: 5px 0 5px 18px;
border-top: 1px solid #f2f2f2;
position: relative;
line-height: 1.4;
}
.plan-features li:first-child {
border-top: none;
}
.plan-features li::before {
content: "";
position: absolute;
left: 0;
color: #875a7b;
font-weight: 700;
}
.plan-guarantee {
font-size: 0.68em;
color: #bbb;
margin-top: auto;
padding-top: 14px;
font-style: italic;
min-height: 18px;
}
.footer-uuid {
text-align: center;
font-size: 0.75em;
color: #ccc;
margin-top: 40px;
user-select: all;
}
</style> </style>
</head> </head>
<body> <body>
<div class="summary-container"> <div class="page-subtitle">
<h2>Check and Upgrade Your Plan</h2> You <b>immediately</b> unlock all new features and higher limits.<br />
<div class="contract-name" id="contractName">Loading...</div> Any extra time is <b>added</b> to your existing subscription.
<div class="subtitle">
Choose an upgrade. With this upgade you only pay for the <b>remaining days</b> of your current contract. Due to technical reasons, the <b>total price</b> will be included in the payment link sent to your email. If you dont want to upgrade, simply dont complete the payment. Current yearly prices are listed on our homepage, and new features will apply when you extend your contract next time.
</div> </div>
<div class="grid"> <div class="plans-row" id="plansRow">
<div class="grid-item"> <!-- ── Trial ─────────────────────────────────────────────────── -->
<div class="label">GIT</div> <div class="plan-card" id="card-trial">
<div class="value" id="gitValue">Loading...</div> <div class="plan-name">Trial</div>
<div class="plan-price">4 weeks free</div>
<div class="plan-price-period">&nbsp;</div>
<div class="plan-tagline">
"I want to see what ODOO can do for my company."
</div>
<button
class="plan-btn is-current-plan"
id="btn-trial"
disabled
>
Trial
</button>
<ul class="plan-features">
<li>ODOO CE hosting</li>
<li>0 Backup Slots</li>
<li>No Domain Mapping</li>
<li>No module installation</li>
</ul>
<div class="plan-guarantee">&nbsp;</div>
</div> </div>
<div class="grid-item"> <!-- ── Side Hustle ───────────────────────────────────────────── -->
<div class="label">Domains</div> <div class="plan-card" id="card-sidehustle">
<div class="value" id="domainsValue">Loading...</div> <div class="plan-name">Side Hustle</div>
<div class="plan-price">$214.00</div>
<div class="plan-price-period">/ year</div>
<div class="plan-tagline">
"ODOO Standard .. that is all my business needs"
</div>
<button class="plan-btn" id="btn-sidehustle">
Upgrade Now
</button>
<ul class="plan-features">
<li>ODOO CE hosting</li>
<li>Space for 1 Backup</li>
<li>Connect 1 domain with SSL</li>
<li>No module installation</li>
<li>One Year of Sercice</li>
</ul>
</div> </div>
<div class="grid-item"> <!-- ── On the Rise ───────────────────────────────────────────── -->
<div class="label">Backup Slots</div> <div class="plan-card" id="card-ontherise">
<div class="value" id="backupValue">Loading...</div> <div class="plan-name">On the Rise</div>
<div class="plan-price">$395.00</div>
<div class="plan-price-period">/ year</div>
<div class="plan-tagline">
"I need some modules and professional reports"
</div>
<button class="plan-btn" id="btn-ontherise">Upgrade Now</button>
<ul class="plan-features">
<li>ODOO CE hosting</li>
<li>Space for 4 Backups</li>
<li>Connect 3 domain with SSL</li>
<li>Install custom modules</li>
<li>One Year of Sercice</li>
</ul>
</div> </div>
<div class="grid-item"> <!-- ── Powerhouse ────────────────────────────────────────────── -->
<div class="label">Workers</div> <div class="plan-card" id="card-powerhouse">
<div class="value" id="workersValue">Loading...</div> <div class="plan-name">Powerhouse</div>
<div class="plan-price">$595.00</div>
<div class="plan-price-period">/ year</div>
<div class="plan-tagline">
"ODOO Enterprise is a must have!"
</div> </div>
<button class="plan-btn" id="btn-powerhouse">
<div class="grid-item full"> Upgrade Now
<div class="label">HDD (MB)</div> </button>
<div class="value" id="hddValue">Loading...</div> <ul class="plan-features">
</div> <li>All of On the Rise</li>
<li>ODOO Enterprise Ready</li>
<div class="grid-item full"> <li>2 Workers</li>
<div class="label">Expires</div> <li>One Year of Sercice</li>
<div class="value" id="expiresValue">Loading...</div> </ul>
</div> </div>
</div> </div>
<!-- Buttons --> <div class="footer-uuid">UUID: <span id="uuidText"></span></div>
<div class="actions">
<div class="action-row">
<button class="action-btn" id="upgradeRiseBtn">Upgrade to "On the Rise"</button>
<button class="action-btn" id="upgradePowerhouseBtn">Upgrade to "Powerhouse"</button>
</div>
<div class="action-row">
<button class="action-btn secondary" id="addDomainBtn">Add a Domain</button>
<button class="action-btn secondary" id="addBackupsBtn">Add 5 Backup Slots</button>
</div>
</div>
<div class="footer" id="uuidDisplay">
UUID: <span id="uuidText">Loading...</span>
</div>
</div>
<script> <script>
const webhookUrl = "https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/0c8536be-d175-4740-8e78-123159193b23"; // ── Webhooks ─────────────────────────────────────────────────────────────
const webhook_buy = "https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/2366cc41-bfd9-41c0-b9b4-bea1e60726f1"; const getContract_Webhook =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/0c8536be-d175-4740-8e78-123159193b23";
const buy_webhook =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/2366cc41-bfd9-41c0-b9b4-bea1e60726f1";
// ── Plan hierarchy (index = tier, lower = cheaper) ────────────────────────
const PLAN_ORDER = [
"Trial",
"Side Hustle",
"On the Rise",
"Powerhouse",
];
const PLAN_META = {
Trial: {
cardId: "card-trial",
btnId: "btn-trial",
productId: null,
},
"Side Hustle": {
cardId: "card-sidehustle",
btnId: "btn-sidehustle",
productId: "sidehustle",
},
"On the Rise": {
cardId: "card-ontherise",
btnId: "btn-ontherise",
productId: "ontherise",
},
Powerhouse: {
cardId: "card-powerhouse",
btnId: "btn-powerhouse",
productId: "powerhouse",
},
};
// ── UUID from URL ─────────────────────────────────────────────────────────
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const uuid = params.get("uuid"); const uuid = params.get("uuid") || "";
document.getElementById("uuidText").textContent = uuid || "";
async function loadData() { // ── Buy handler ───────────────────────────────────────────────────────────
async function buyProduct(productId) {
if (!uuid) { if (!uuid) {
alert("Missing uuid parameter in URL"); alert("Missing uuid parameter in URL.");
return; return;
} }
document.getElementById("uuidText").textContent = uuid;
try { try {
const res = await fetch(webhookUrl, { const res = await fetch(buy_webhook, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uuid }) body: JSON.stringify({ uuid, product_id: productId }),
}); });
if (!res.ok) throw new Error("HTTP " + res.status);
if (!res.ok) throw new Error("Failed to fetch data");
const data = await res.json();
document.getElementById("gitValue").textContent = data.git ? "Enabled" : "Disabled";
document.getElementById("domainsValue").textContent = data.domains || "-";
document.getElementById("backupValue").textContent = data.backupSlots || "-";
document.getElementById("workersValue").textContent = data.workers || "-";
document.getElementById("hddValue").textContent = data.hdd ? `${data.hdd} MB` : "-";
document.getElementById("expiresValue").textContent = data.expires || "-";
document.getElementById("contractName").textContent = data.contract_name || "-";
// Disable buttons based on contract_name
if (data.contract_name === "On the Rise") {
document.getElementById("upgradeRiseBtn").disabled = true;
} else if (data.contract_name === "Powerhouse") {
document.getElementById("upgradeRiseBtn").disabled = true;
document.getElementById("upgradePowerhouseBtn").disabled = true;
}
} catch (err) {
console.error(err);
alert("Error loading data.");
}
}
async function buyProduct(product_id) {
if (!uuid) return;
try {
const res = await fetch(webhook_buy, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uuid, product_id })
});
if (!res.ok) throw new Error("Failed to send purchase request");
document.body.innerHTML = ` document.body.innerHTML = `
<div style="text-align:center;padding:80px;background:#fff;border-radius:12px;max-width:700px;margin:auto;box-shadow:0 4px 12px rgba(0,0,0,0.1);"> <div style="text-align:center;padding:80px 20px;background:#fff;border-radius:14px;
<h2>✅ Your request has been sent</h2> max-width:560px;margin:60px auto;box-shadow:0 4px 20px rgba(0,0,0,0.1);
<p>Please check your email for confirmation.</p> font-family:'Inter','Segoe UI',Arial,sans-serif;">
</div> <div style="font-size:3em;margin-bottom:16px;">&#x2705;</div>
`; <h2 style="color:#262626;font-weight:700;margin-bottom:10px;">Request sent!</h2>
<p style="color:#777;line-height:1.6;">
Please check your email for the payment link and confirmation.<br>
New features will be active as soon as the payment is complete.
</p>
</div>`;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert("Error sending request."); alert(
"Something went wrong. Please try again or contact support.",
);
} }
} }
document.getElementById("upgradeRiseBtn").addEventListener("click", () => buyProduct("ontherise")); // ── Wire buy buttons ──────────────────────────────────────────────────────
document.getElementById("upgradePowerhouseBtn").addEventListener("click", () => buyProduct("powerhouse")); document
document.getElementById("addDomainBtn").addEventListener("click", () => buyProduct("1-domain")); .getElementById("btn-sidehustle")
document.getElementById("addBackupsBtn").addEventListener("click", () => buyProduct("5-backup")); .addEventListener("click", () => buyProduct("sidehustle"));
document
.getElementById("btn-ontherise")
.addEventListener("click", () => buyProduct("ontherise"));
document
.getElementById("btn-powerhouse")
.addEventListener("click", () => buyProduct("powerhouse"));
loadData(); // ── Apply current-plan state ──────────────────────────────────────────────
function applyPlanState(contractName, expires) {
const currentIdx = PLAN_ORDER.indexOf(contractName);
if (currentIdx === -1) return; // unknown plan leave defaults
const badgeLabel = expires ? "✅ " + expires : "✅";
PLAN_ORDER.forEach(function (plan, idx) {
var meta = PLAN_META[plan];
if (!meta) return;
var btn = document.getElementById(meta.btnId);
var card = document.getElementById(meta.cardId);
if (!btn || !card) return;
if (idx === currentIdx) {
// Current plan green frame, badge with expiry date
card.classList.add("current");
var badge = document.createElement("div");
badge.className = "plan-badge";
badge.textContent = badgeLabel;
card.insertBefore(badge, card.firstChild);
// Trial is always green with no text skip button changes
if (plan !== "Trial") {
btn.textContent = "Add 1 Year";
btn.disabled = false;
btn.classList.add("is-current-plan");
}
} else if (idx < currentIdx) {
// Lower tier hide entirely
card.style.display = "none";
}
// Higher tiers: leave as "Upgrade Now" (enabled)
});
}
// ── Load contract from webhook ────────────────────────────────────────────
async function loadContract() {
if (!uuid) return;
try {
const res = await fetch(getContract_Webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uuid }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const payload = await res.json();
// Webhook returns: [{ "expires": "YYYY-MM-DD", "contractName": "Trial" }]
const record = Array.isArray(payload)
? payload[0]
: payload;
if (record && record.contract_name) {
applyPlanState(record.contract_name, record.expires);
}
} catch (err) {
console.error("Could not load contract data:", err);
// Graceful degradation: page remains usable with default button states
}
}
loadContract();
</script> </script>
</body> </body>
</html> </html>