Hotel Channel Manager
An OTA channel-sync status screen for Aurelia Hotels showing Booking.com, Expedia, Airbnb, Direct, and Google channels with connection status, last-sync time, mapped rooms, rate-parity state, and pending changes. Sync single channels or all at once with an animated syncing sequence, toggle connections, and resolve parity warnings — all with a live overall health summary.
MCP
Code
/* ── Design tokens ───────────────────────────────────────────────────────── */
:root {
--navy: #1a2b4a;
--navy-d: #0f1d36;
--navy-2: #2d4570;
--cream: #f7f3eb;
--cream-2: #ece5d4;
--bone: #fbf8f2;
--gold: #c9a649;
--gold-light: #e0c378;
--gold-d: #a88a2e;
--ink: #161e2c;
--ink-2: #2e3a52;
--warm-gray: #6c7280;
--line: rgba(22, 30, 44, 0.1);
--line-strong: rgba(22, 30, 44, 0.18);
--success: #4a7752;
--danger: #b34232;
--warning: #d99020;
--info: #4a6da0;
--font-display: "Cormorant Garamond", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(22, 30, 44, 0.06), 0 2px 8px rgba(22, 30, 44, 0.06);
--shadow-2: 0 12px 36px rgba(15, 29, 54, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ─────────────────────────────────────────────────────────────── */
.cms {
height: 100vh;
display: grid;
grid-template-columns: 220px 1fr;
}
/* ── Rail ───────────────────────────────────────────────────────────────── */
.rail {
background: var(--navy);
color: var(--bone);
display: flex;
flex-direction: column;
padding: 22px 16px 14px;
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 4px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--gold);
color: var(--navy-d);
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
flex-shrink: 0;
}
.brand-name {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
}
.brand-prop {
font-size: 0.68rem;
color: var(--gold-light);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 2px;
}
.nav {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.nav-item {
font-size: 0.84rem;
font-weight: 500;
color: rgba(251, 248, 242, 0.72);
text-decoration: none;
padding: 9px 12px;
border-radius: var(--r-md);
display: flex;
align-items: center;
gap: 8px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--bone);
}
.nav-item.is-active {
background: rgba(201, 166, 73, 0.16);
color: var(--gold-light);
font-weight: 600;
}
.nav-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--gold);
flex-shrink: 0;
}
/* ── Rail summary ────────────────────────────────────────────────────────── */
.rail-summary {
margin-top: 20px;
padding: 14px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--r-md);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.summary-title {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold-light);
margin-bottom: 10px;
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.sum-label {
font-size: 0.76rem;
color: rgba(251, 248, 242, 0.65);
}
.sum-val {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 700;
color: var(--bone);
font-variant-numeric: tabular-nums;
}
.sum-val.warn {
color: var(--gold-light);
}
.rail-foot {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.clock {
font-family: var(--font-mono);
font-weight: 600;
font-size: 0.84rem;
color: var(--gold-light);
}
.agent {
font-size: 0.7rem;
color: rgba(251, 248, 242, 0.55);
letter-spacing: 0.04em;
}
/* ── Main ───────────────────────────────────────────────────────────────── */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--cream);
}
/* ── Topbar ─────────────────────────────────────────────────────────────── */
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
padding: 22px 28px 14px;
border-bottom: 1px solid var(--line);
background: var(--bone);
flex-shrink: 0;
}
.kicker {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold-d);
font-weight: 600;
}
.topbar h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.8rem;
letter-spacing: -0.005em;
margin-top: 2px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* ── Health pill ─────────────────────────────────────────────────────────── */
.health-pill {
display: flex;
align-items: center;
gap: 7px;
background: var(--bone);
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 7px 14px;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-2);
}
.hp-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--warm-gray);
flex-shrink: 0;
transition: background 0.3s;
}
.hp-dot.ok {
background: var(--success);
}
.hp-dot.warn {
background: var(--warning);
}
.hp-dot.error {
background: var(--danger);
}
.btn-primary {
background: var(--navy);
color: var(--bone);
border: none;
font-family: inherit;
font-size: 0.86rem;
font-weight: 600;
padding: 9px 18px;
border-radius: var(--r-md);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--navy-2);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Cards grid ─────────────────────────────────────────────────────────── */
.cards-grid {
flex: 1;
overflow-y: auto;
padding: 22px 28px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
align-content: start;
}
/* ── Channel card ────────────────────────────────────────────────────────── */
.ch-card {
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-1);
overflow: hidden;
transition: box-shadow 0.15s;
display: flex;
flex-direction: column;
}
.ch-card:hover {
box-shadow: var(--shadow-2);
}
.ch-card.is-disconnected {
opacity: 0.7;
}
/* card header */
.ch-header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--line);
}
.ch-logo {
width: 42px;
height: 42px;
border-radius: var(--r-md);
display: grid;
place-items: center;
font-size: 1.2rem;
font-weight: 900;
font-family: var(--font-display);
flex-shrink: 0;
}
.ch-title-block {
flex: 1;
min-width: 0;
}
.ch-name {
font-weight: 700;
font-size: 0.98rem;
color: var(--navy-d);
letter-spacing: -0.005em;
}
.ch-type {
font-size: 0.72rem;
color: var(--warm-gray);
margin-top: 1px;
}
.ch-status-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 999px;
white-space: nowrap;
}
.ch-status-badge.connected {
background: rgba(74, 119, 82, 0.14);
color: var(--success);
}
.ch-status-badge.disconnected {
background: rgba(22, 30, 44, 0.08);
color: var(--warm-gray);
}
.ch-status-badge.syncing {
background: rgba(74, 109, 160, 0.16);
color: var(--info);
}
.ch-status-badge.error {
background: rgba(179, 66, 50, 0.14);
color: var(--danger);
}
/* card body stats */
.ch-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--line);
border-bottom: 1px solid var(--line);
}
.ch-stat {
background: var(--bone);
padding: 11px 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ch-stat-label {
font-size: 0.66rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--warm-gray);
}
.ch-stat-val {
font-family: var(--font-mono);
font-size: 0.9rem;
font-weight: 700;
color: var(--navy-d);
font-variant-numeric: tabular-nums;
}
.ch-stat-val.ok {
color: var(--success);
}
.ch-stat-val.warn {
color: var(--warning);
}
.ch-stat-val.pending {
color: var(--info);
}
.ch-stat-val.none {
color: var(--warm-gray);
}
/* card body sync row */
.ch-sync-row {
padding: 10px 18px 6px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.76rem;
color: var(--warm-gray);
}
.sync-icon {
font-size: 0.9rem;
line-height: 1;
display: inline-block;
transition: transform 0.3s;
}
.sync-icon.spinning {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.sync-time {
font-family: var(--font-mono);
font-size: 0.74rem;
color: var(--warm-gray);
}
/* parity warning */
.ch-parity-warn {
margin: 0 18px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: rgba(217, 144, 32, 0.1);
border: 1px solid rgba(217, 144, 32, 0.3);
border-radius: var(--r-sm);
padding: 8px 12px;
font-size: 0.76rem;
color: var(--warning);
font-weight: 600;
}
.ch-parity-warn button {
background: var(--warning);
color: white;
border: none;
font-family: inherit;
font-size: 0.72rem;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
}
.ch-parity-warn button:hover {
filter: brightness(1.1);
}
/* card footer actions */
.ch-footer {
padding: 12px 18px 16px;
display: flex;
gap: 8px;
margin-top: auto;
}
.ch-btn {
flex: 1;
background: var(--cream);
border: 1px solid var(--line-strong);
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-2);
padding: 8px 12px;
border-radius: var(--r-sm);
cursor: pointer;
text-align: center;
transition: background 0.12s;
}
.ch-btn:hover {
background: var(--cream-2);
border-color: var(--navy-2);
color: var(--navy-d);
}
.ch-btn.primary {
background: var(--navy);
color: var(--bone);
border-color: var(--navy);
}
.ch-btn.primary:hover {
background: var(--navy-2);
}
.ch-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Toast ──────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
padding: 11px 20px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: var(--shadow-2);
white-space: nowrap;
pointer-events: none;
}
/* ── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 960px) {
.cms {
grid-template-columns: 1fr;
}
.rail {
display: none;
}
.topbar {
flex-direction: column;
align-items: flex-start;
padding: 14px 16px 10px;
}
.cards-grid {
padding: 14px 16px;
}
}
@media (max-width: 560px) {
.topbar h1 {
font-size: 1.3rem;
}
.cards-grid {
grid-template-columns: 1fr;
}
.ch-stats {
grid-template-columns: repeat(2, 1fr);
}
}// ── Channel data ─────────────────────────────────────────────────────────────
const CHANNELS = [
{
id: "booking",
name: "Booking.com",
type: "OTA · Preferred Partner",
logoChar: "B",
logoBg: "linear-gradient(135deg,#003580,#0069b4)",
logoColor: "#fff",
connected: true,
lastSync: "2026-06-09 07:42",
roomsMapped: 7,
totalRooms: 7,
parity: "ok",
pending: 0,
},
{
id: "expedia",
name: "Expedia",
type: "OTA · Elite Partner",
logoChar: "E",
logoBg: "linear-gradient(135deg,#fca011,#e08000)",
logoColor: "#fff",
connected: true,
lastSync: "2026-06-09 07:38",
roomsMapped: 6,
totalRooms: 7,
parity: "warn",
pending: 3,
},
{
id: "airbnb",
name: "Airbnb",
type: "Vacation Rental",
logoChar: "A",
logoBg: "linear-gradient(135deg,#ff5a5f,#c0392b)",
logoColor: "#fff",
connected: true,
lastSync: "2026-06-09 06:55",
roomsMapped: 4,
totalRooms: 7,
parity: "ok",
pending: 1,
},
{
id: "direct",
name: "Direct (Website)",
type: "Owned Channel",
logoChar: "Æ",
logoBg: "linear-gradient(135deg,#1a2b4a,#0f1d36)",
logoColor: "#c9a649",
connected: true,
lastSync: "2026-06-09 08:01",
roomsMapped: 7,
totalRooms: 7,
parity: "ok",
pending: 0,
},
{
id: "google",
name: "Google Hotel Ads",
type: "Meta-search",
logoChar: "G",
logoBg: "linear-gradient(135deg,#4285f4,#1a66d2)",
logoColor: "#fff",
connected: false,
lastSync: "2026-06-08 22:10",
roomsMapped: 5,
totalRooms: 7,
parity: "ok",
pending: 0,
},
];
// ── State ────────────────────────────────────────────────────────────────────
// channels is a shallow mutable copy so we can update lastSync / parity etc.
const state = CHANNELS.map((c) => ({ ...c }));
let syncingIds = new Set();
// ── Toast helper ─────────────────────────────────────────────────────────────
const toast = document.getElementById("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
// ── Summary bar ──────────────────────────────────────────────────────────────
function updateSummary() {
const connected = state.filter((c) => c.connected).length;
const warnings = state.filter((c) => c.parity === "warn").length;
const synced = state.filter((c) => c.connected && !syncingIds.has(c.id)).length;
document.getElementById("sumConnected").textContent = connected;
document.getElementById("sumSynced").textContent = synced;
document.getElementById("sumWarnings").textContent = warnings;
const hpDot = document.getElementById("hpDot");
const hpLabel = document.getElementById("hpLabel");
if (warnings > 0) {
hpDot.className = "hp-dot warn";
hpLabel.textContent = `${warnings} warning${warnings !== 1 ? "s" : ""}`;
} else if (connected === state.length) {
hpDot.className = "hp-dot ok";
hpLabel.textContent = "All channels healthy";
} else {
hpDot.className = "hp-dot warn";
hpLabel.textContent = `${state.length - connected} disconnected`;
}
}
// ── Format last sync time ─────────────────────────────────────────────────────
function fmtSync(ts) {
if (!ts) return "Never";
const [datePart, timePart] = ts.split(" ");
const today = "2026-06-09";
if (datePart === today) return `Today ${timePart}`;
const yesterday = "2026-06-08";
if (datePart === yesterday) return `Yesterday ${timePart}`;
return ts;
}
// ── Render a single card ──────────────────────────────────────────────────────
function renderCard(ch) {
const syncing = syncingIds.has(ch.id);
const statusLabel = syncing ? "Syncing…" : !ch.connected ? "Disconnected" : "Connected";
const statusClass = syncing ? "syncing" : !ch.connected ? "disconnected" : "connected";
const parityHTML =
ch.parity === "warn" && ch.connected
? `<div class="ch-parity-warn">
⚠ Rate parity mismatch — rates out of sync with Booking.com
<button class="resolve-btn" data-id="${ch.id}">Resolve</button>
</div>`
: "";
const pendingClass = ch.pending > 0 ? "pending" : "none";
const pendingLabel = ch.pending > 0 ? `${ch.pending} pending` : "None";
const parityValClass = ch.parity === "ok" ? "ok" : "warn";
const parityValLabel = ch.parity === "ok" ? "✓ Parity OK" : "⚠ Mismatch";
return `<article class="ch-card${!ch.connected ? " is-disconnected" : ""}" data-id="${ch.id}">
<div class="ch-header">
<div class="ch-logo" style="background:${ch.logoBg};color:${ch.logoColor}">${ch.logoChar}</div>
<div class="ch-title-block">
<div class="ch-name">${ch.name}</div>
<div class="ch-type">${ch.type}</div>
</div>
<span class="ch-status-badge ${statusClass}">${statusLabel}</span>
</div>
<div class="ch-stats">
<div class="ch-stat">
<span class="ch-stat-label">Rooms mapped</span>
<span class="ch-stat-val">${ch.roomsMapped}/${ch.totalRooms}</span>
</div>
<div class="ch-stat">
<span class="ch-stat-label">Rate parity</span>
<span class="ch-stat-val ${parityValClass}">${parityValLabel}</span>
</div>
<div class="ch-stat">
<span class="ch-stat-label">Pending</span>
<span class="ch-stat-val ${pendingClass}">${pendingLabel}</span>
</div>
</div>
<div class="ch-sync-row">
<span class="sync-icon${syncing ? " spinning" : ""}">⟳</span>
<span>Last sync:</span>
<span class="sync-time">${fmtSync(ch.lastSync)}</span>
</div>
${parityHTML}
<div class="ch-footer">
<button class="ch-btn sync-btn" data-id="${ch.id}"${!ch.connected || syncing ? " disabled" : ""}>
${syncing ? "Syncing…" : "Sync now"}
</button>
<button class="ch-btn toggle-btn${ch.connected ? "" : " primary"}" data-id="${ch.id}">
${ch.connected ? "Disconnect" : "Connect"}
</button>
</div>
</article>`;
}
// ── Render all cards ──────────────────────────────────────────────────────────
function renderAll() {
document.getElementById("cardsGrid").innerHTML = state.map(renderCard).join("");
bindCardEvents();
updateSummary();
}
// ── Bind card events ──────────────────────────────────────────────────────────
function bindCardEvents() {
// Sync now buttons
document.querySelectorAll(".sync-btn").forEach((btn) => {
btn.addEventListener("click", () => syncChannel(btn.dataset.id));
});
// Connect/disconnect toggles
document.querySelectorAll(".toggle-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const ch = state.find((c) => c.id === btn.dataset.id);
if (!ch) return;
ch.connected = !ch.connected;
renderAll();
showToast(`${ch.name} ${ch.connected ? "connected" : "disconnected"}`);
});
});
// Resolve parity
document.querySelectorAll(".resolve-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const ch = state.find((c) => c.id === btn.dataset.id);
if (!ch) return;
ch.parity = "ok";
ch.pending = 0;
renderAll();
showToast(`Rate parity resolved for ${ch.name}`);
});
});
}
// ── Sync single channel ───────────────────────────────────────────────────────
function syncChannel(id, delay = 0) {
const ch = state.find((c) => c.id === id);
if (!ch || !ch.connected || syncingIds.has(id)) return Promise.resolve();
syncingIds.add(id);
renderAll();
return new Promise((resolve) => {
setTimeout(
() => {
// build a "now" timestamp from fixed mock date context
const now = new Date("2026-06-09T00:00:00");
const rand = Math.floor(Math.random() * 3600000); // random within an hour
const ts = new Date(now.getTime() + rand + delay * 1000);
const hhmm = ts.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
ch.lastSync = `2026-06-09 ${hhmm}`;
ch.pending = 0;
syncingIds.delete(id);
renderAll();
resolve();
},
1600 + delay * 200
);
});
}
// ── Sync all ──────────────────────────────────────────────────────────────────
document.getElementById("syncAllBtn").addEventListener("click", async () => {
const btn = document.getElementById("syncAllBtn");
btn.disabled = true;
showToast("Syncing all connected channels…");
const connected = state.filter((c) => c.connected);
for (let i = 0; i < connected.length; i++) {
syncChannel(connected[i].id, i);
}
// wait for all to finish
const longestDelay = 1600 + (connected.length - 1) * 200 + 200;
setTimeout(() => {
btn.disabled = false;
showToast("All channels synced successfully");
}, longestDelay);
});
// ── Clock ────────────────────────────────────────────────────────────────────
function tick() {
const now = new Date();
const hhmm = now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
const wk = now.toLocaleDateString("en-GB", { weekday: "short" });
document.getElementById("clock").textContent = `${hhmm} · ${wk}`;
}
tick();
setInterval(tick, 1000);
// ── Boot ─────────────────────────────────────────────────────────────────────
renderAll();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Channel Manager · Aurelia Hotels</title>
</head>
<body>
<main class="cms">
<!-- ── Rail ── -->
<aside class="rail">
<div class="brand">
<span class="brand-mark">Æ</span>
<div>
<p class="brand-name">Aurelia Hotels</p>
<p class="brand-prop">Aurelia · Madrid</p>
</div>
</div>
<nav class="nav">
<a class="nav-item" href="#">Dashboard</a>
<a class="nav-item" href="#">Reservations</a>
<a class="nav-item" href="#">Room rack</a>
<a class="nav-item" href="#">Rate mgmt</a>
<a class="nav-item is-active" href="#"><span class="nav-dot"></span>Channels</a>
<a class="nav-item" href="#">Inventory</a>
<a class="nav-item" href="#">Reports</a>
</nav>
<!-- Summary widget -->
<div class="rail-summary">
<p class="summary-title">Sync health</p>
<div class="summary-row">
<span class="sum-label">Total channels</span>
<span class="sum-val" id="sumTotal">5</span>
</div>
<div class="summary-row">
<span class="sum-label">Connected</span>
<span class="sum-val" id="sumConnected">—</span>
</div>
<div class="summary-row">
<span class="sum-label">Synced today</span>
<span class="sum-val" id="sumSynced">—</span>
</div>
<div class="summary-row">
<span class="sum-label">Warnings</span>
<span class="sum-val warn" id="sumWarnings">—</span>
</div>
</div>
<footer class="rail-foot">
<span class="clock" id="clock">--:-- · --</span>
<span class="agent">Distribution · Marc L.</span>
</footer>
</aside>
<!-- ── Main ── -->
<section class="main">
<header class="topbar">
<div>
<p class="kicker">Distribution</p>
<h1>Channel Manager</h1>
</div>
<div class="topbar-actions">
<div class="health-pill" id="healthPill">
<span class="hp-dot" id="hpDot"></span>
<span id="hpLabel">Checking…</span>
</div>
<button class="btn-primary" id="syncAllBtn">⟳ Sync all channels</button>
</div>
</header>
<!-- ── Channel cards grid ── -->
<div class="cards-grid" id="cardsGrid"></div>
</section>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hotel Channel Manager
A full-screen OTA channel-management console for Aurelia Hotels. Each channel card shows connection status, the last successful sync time, how many room types are mapped, rate-parity compliance, and any pending rate or availability changes awaiting push. Hit “Sync now” on any card to watch an animated syncing spinner resolve to a green “Synced” state and update the timestamp. The global “Sync all” button runs through all connected channels sequentially. A connect/disconnect toggle lets you enable or pause a channel. Rate-parity warnings surface with a one-click “Resolve” action. The header summary bar always reflects overall sync health — total channels, synced count, and outstanding warnings.