Compare commits
17 Commits
3996bef0ff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2176b6b2fb | |||
| 66821b7276 | |||
| 9aac7eba2a | |||
| 5c5015883d | |||
| 1693e003c8 | |||
| c06fa1d91f | |||
| 11586c08b6 | |||
| 2ebc7d1f96 | |||
| c9d6c937f8 | |||
| 8f68959256 | |||
| 8af11561d0 | |||
| 5e94b9d40c | |||
| 66bffcfcbc | |||
| a7db4ebe67 | |||
| a8a12d63ba | |||
| 97b1d256ea | |||
| a1473d9930 |
+686
@@ -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">🖥 ${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 ? " · 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;">
|
||||
⚠ 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
@@ -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 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>
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
+406
-228
@@ -1,269 +1,447 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Check and Upgrade Your Plan</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", Arial, sans-serif;
|
||||
background-color: #f4f5f7;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Your Plan</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", Arial, sans-serif;
|
||||
background-color: #f4f5f7;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.summary-container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
|
||||
padding: 40px;
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
.page-title {
|
||||
text-align: center;
|
||||
color: #262626;
|
||||
font-size: 1.45em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.page-subtitle {
|
||||
text-align: center;
|
||||
font-size: 1.05em;
|
||||
color: #444;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.7;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* New small centered contract name */
|
||||
.contract-name {
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.plans-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
color: #777;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.plan-card:hover {
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.11);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 8px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
#card-ontherise {
|
||||
border: 3px dotted #e0e0e0;
|
||||
}
|
||||
|
||||
.grid-item.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.1em;
|
||||
color: #222;
|
||||
}
|
||||
.plan-name {
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
color: #262626;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.plan-price {
|
||||
font-size: 1.55em;
|
||||
font-weight: 800;
|
||||
color: #875a7b;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
gap: 15px;
|
||||
}
|
||||
.plan-price-period {
|
||||
font-size: 0.75em;
|
||||
color: #aaa;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plan-tagline {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background-color: #875A7B; /* Odoo purple */
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 22px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
min-width: 180px;
|
||||
}
|
||||
.plan-btn {
|
||||
background-color: #875a7b;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 9px 0;
|
||||
font-size: 0.88em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-bottom: 18px;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.1s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: #744e6a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.plan-btn:hover:not(:disabled) {
|
||||
background-color: #744e6a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.plan-btn:disabled {
|
||||
background-color: #bdbdbd;
|
||||
color: #fff;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background-color: #5a636a;
|
||||
}
|
||||
.plan-btn.is-current-plan:disabled {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="summary-container">
|
||||
<h2>Check and Upgrade Your Plan</h2>
|
||||
<div class="contract-name" id="contractName">Loading...</div>
|
||||
<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 don’t want to upgrade, simply don’t complete the payment. Current yearly prices are listed on our homepage, and new features will apply when you extend your contract next time.
|
||||
</div>
|
||||
.plan-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<div class="label">GIT</div>
|
||||
<div class="value" id="gitValue">Loading...</div>
|
||||
</div>
|
||||
.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;
|
||||
}
|
||||
|
||||
<div class="grid-item">
|
||||
<div class="label">Domains</div>
|
||||
<div class="value" id="domainsValue">Loading...</div>
|
||||
</div>
|
||||
.plan-features li:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
<div class="grid-item">
|
||||
<div class="label">Backup Slots</div>
|
||||
<div class="value" id="backupValue">Loading...</div>
|
||||
</div>
|
||||
.plan-features li::before {
|
||||
content: "–";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #875a7b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
<div class="grid-item">
|
||||
<div class="label">Workers</div>
|
||||
<div class="value" id="workersValue">Loading...</div>
|
||||
</div>
|
||||
.plan-guarantee {
|
||||
font-size: 0.68em;
|
||||
color: #bbb;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
font-style: italic;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
<div class="grid-item full">
|
||||
<div class="label">HDD (MB)</div>
|
||||
<div class="value" id="hddValue">Loading...</div>
|
||||
</div>
|
||||
.footer-uuid {
|
||||
text-align: center;
|
||||
font-size: 0.75em;
|
||||
color: #ccc;
|
||||
margin-top: 40px;
|
||||
user-select: all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-subtitle">
|
||||
You <b>immediately</b> unlock all new features and higher limits.<br />
|
||||
Any extra time is <b>added</b> to your existing subscription.
|
||||
</div>
|
||||
|
||||
<div class="grid-item full">
|
||||
<div class="label">Expires</div>
|
||||
<div class="value" id="expiresValue">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plans-row" id="plansRow">
|
||||
<!-- ── Trial ─────────────────────────────────────────────────── -->
|
||||
<div class="plan-card" id="card-trial">
|
||||
<div class="plan-name">Trial</div>
|
||||
<div class="plan-price">4 weeks free</div>
|
||||
<div class="plan-price-period"> </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"> </div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<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>
|
||||
<!-- ── Side Hustle ───────────────────────────────────────────── -->
|
||||
<div class="plan-card" id="card-sidehustle">
|
||||
<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 class="footer" id="uuidDisplay">
|
||||
UUID: <span id="uuidText">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── On the Rise ───────────────────────────────────────────── -->
|
||||
<div class="plan-card" id="card-ontherise">
|
||||
<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>
|
||||
|
||||
<script>
|
||||
const webhookUrl = "https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/0c8536be-d175-4740-8e78-123159193b23";
|
||||
const webhook_buy = "https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/2366cc41-bfd9-41c0-b9b4-bea1e60726f1";
|
||||
<!-- ── Powerhouse ────────────────────────────────────────────── -->
|
||||
<div class="plan-card" id="card-powerhouse">
|
||||
<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>
|
||||
<button class="plan-btn" id="btn-powerhouse">
|
||||
Upgrade Now
|
||||
</button>
|
||||
<ul class="plan-features">
|
||||
<li>All of On the Rise</li>
|
||||
<li>ODOO Enterprise Ready</li>
|
||||
<li>2 Workers</li>
|
||||
<li>One Year of Sercice</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const uuid = params.get("uuid");
|
||||
<div class="footer-uuid">UUID: <span id="uuidText">–</span></div>
|
||||
|
||||
async function loadData() {
|
||||
if (!uuid) {
|
||||
alert("Missing uuid parameter in URL");
|
||||
return;
|
||||
}
|
||||
<script>
|
||||
// ── Webhooks ─────────────────────────────────────────────────────────────
|
||||
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";
|
||||
|
||||
document.getElementById("uuidText").textContent = uuid;
|
||||
// ── Plan hierarchy (index = tier, lower = cheaper) ────────────────────────
|
||||
const PLAN_ORDER = [
|
||||
"Trial",
|
||||
"Side Hustle",
|
||||
"On the Rise",
|
||||
"Powerhouse",
|
||||
];
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uuid })
|
||||
});
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
if (!res.ok) throw new Error("Failed to fetch data");
|
||||
const data = await res.json();
|
||||
// ── UUID from URL ─────────────────────────────────────────────────────────
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const uuid = params.get("uuid") || "";
|
||||
document.getElementById("uuidText").textContent = uuid || "–";
|
||||
|
||||
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 || "-";
|
||||
// ── Buy handler ───────────────────────────────────────────────────────────
|
||||
async function buyProduct(productId) {
|
||||
if (!uuid) {
|
||||
alert("Missing uuid parameter in URL.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(buy_webhook, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uuid, product_id: productId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
|
||||
// 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;
|
||||
}
|
||||
document.body.innerHTML = `
|
||||
<div style="text-align:center;padding:80px 20px;background:#fff;border-radius:14px;
|
||||
max-width:560px;margin:60px auto;box-shadow:0 4px 20px rgba(0,0,0,0.1);
|
||||
font-family:'Inter','Segoe UI',Arial,sans-serif;">
|
||||
<div style="font-size:3em;margin-bottom:16px;">✅</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) {
|
||||
console.error(err);
|
||||
alert(
|
||||
"Something went wrong. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Error loading data.");
|
||||
}
|
||||
}
|
||||
// ── Wire buy buttons ──────────────────────────────────────────────────────
|
||||
document
|
||||
.getElementById("btn-sidehustle")
|
||||
.addEventListener("click", () => buyProduct("sidehustle"));
|
||||
document
|
||||
.getElementById("btn-ontherise")
|
||||
.addEventListener("click", () => buyProduct("ontherise"));
|
||||
document
|
||||
.getElementById("btn-powerhouse")
|
||||
.addEventListener("click", () => buyProduct("powerhouse"));
|
||||
|
||||
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 })
|
||||
});
|
||||
// ── Apply current-plan state ──────────────────────────────────────────────
|
||||
function applyPlanState(contractName, expires) {
|
||||
const currentIdx = PLAN_ORDER.indexOf(contractName);
|
||||
if (currentIdx === -1) return; // unknown plan – leave defaults
|
||||
|
||||
if (!res.ok) throw new Error("Failed to send purchase request");
|
||||
const badgeLabel = expires ? "✅ " + expires : "✅";
|
||||
|
||||
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);">
|
||||
<h2>✅ Your request has been sent</h2>
|
||||
<p>Please check your email for confirmation.</p>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Error sending request.");
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
document.getElementById("upgradeRiseBtn").addEventListener("click", () => buyProduct("ontherise"));
|
||||
document.getElementById("upgradePowerhouseBtn").addEventListener("click", () => buyProduct("powerhouse"));
|
||||
document.getElementById("addDomainBtn").addEventListener("click", () => buyProduct("1-domain"));
|
||||
document.getElementById("addBackupsBtn").addEventListener("click", () => buyProduct("5-backup"));
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
// ── 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user